From 2aad9e0d50cad681bed63cc0e720487da8ccf3ad Mon Sep 17 00:00:00 2001 From: FR33THY32 Date: Wed, 13 May 2026 14:28:23 +0500 Subject: [PATCH 1/4] feat: add Pi provider integration --- .../orchestrationEngine.integration.test.ts | 3 + apps/server/package.json | 3 + .../Layers/ProjectionPipeline.test.ts | 106 + .../Layers/ProjectionPipeline.ts | 22 + .../Layers/ProviderRuntimeIngestion.ts | 13 +- .../src/orchestration/projector.test.ts | 69 +- apps/server/src/orchestration/projector.ts | 30 + .../modelSelectionCompatibility.test.ts | 23 + .../modelSelectionCompatibility.ts | 8 +- apps/server/src/provider/Layers/PiAdapter.ts | 1755 +++++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 23 +- .../Layers/ProviderAdapterRegistry.ts | 2 + .../src/provider/Layers/ProviderHealth.ts | 24 + .../Layers/ProviderSessionDirectory.ts | 3 +- .../server/src/provider/Services/PiAdapter.ts | 20 + .../src/provider/providerStatusCache.ts | 1 + apps/server/src/provider/runtimeLayer.ts | 3 + apps/server/src/serverSettings.ts | 9 +- apps/web/src/appSettings.test.ts | 45 +- apps/web/src/appSettings.ts | 51 +- apps/web/src/components/ChatView.tsx | 93 +- apps/web/src/components/Icons.tsx | 12 + apps/web/src/components/PluginLibrary.tsx | 10 +- apps/web/src/components/Sidebar.tsx | 27 +- .../src/components/SidebarSearchPalette.tsx | 4 +- apps/web/src/components/chat/ChatHeader.tsx | 5 +- .../CompactComposerControlsMenu.browser.tsx | 5 +- .../components/chat/ComposerCommandMenu.tsx | 4 +- .../components/chat/ContextWindowMeter.tsx | 5 + .../chat/ProviderModelPicker.browser.tsx | 63 + .../components/chat/ProviderModelPicker.tsx | 30 +- .../components/chat/TraitsPicker.browser.tsx | 18 +- apps/web/src/components/chat/TraitsPicker.tsx | 8 +- .../chat/composerProviderRegistry.tsx | 12 + .../web/src/components/chat/composerTraits.ts | 4 + .../chat/runtimeModelCapabilities.ts | 5 +- apps/web/src/composerDraftStore.test.ts | 59 +- apps/web/src/composerDraftStore.ts | 74 +- .../web/src/hooks/useComposerSlashCommands.ts | 5 - apps/web/src/hooks/useHandleNewThread.ts | 9 +- .../src/lib/providerDiscoveryReactQuery.ts | 11 +- apps/web/src/lib/threadHandoff.test.ts | 12 + apps/web/src/lib/threadHandoff.ts | 9 +- apps/web/src/providerModelOptions.ts | 27 +- apps/web/src/routes/_chat.$threadId.tsx | 5 +- apps/web/src/routes/_chat.settings.tsx | 27 +- apps/web/src/session-logic.test.ts | 7 + apps/web/src/session-logic.ts | 1 + apps/web/src/store.test.ts | 26 + apps/web/src/store.ts | 3 +- apps/web/src/wsNativeApi.test.ts | 1 + packages/contracts/src/agentMentions.ts | 2 + packages/contracts/src/model.ts | 25 +- packages/contracts/src/orchestration.test.ts | 33 + packages/contracts/src/orchestration.ts | 16 + packages/contracts/src/providerDiscovery.ts | 2 + packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/settings.ts | 15 + packages/shared/src/model.ts | 39 +- packages/shared/src/serverSettings.ts | 1 + 60 files changed, 2817 insertions(+), 111 deletions(-) create mode 100644 apps/server/src/persistence/modelSelectionCompatibility.test.ts create mode 100644 apps/server/src/provider/Layers/PiAdapter.ts create mode 100644 apps/server/src/provider/Services/PiAdapter.ts diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 5ebaaf9d..7cc07130 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -109,6 +109,9 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => Effect.gen(function* () { const createdAt = nowIso(); const provider = harness.adapterHarness?.provider ?? "codex"; + if (provider === "pi") { + throw new Error("Pi integration tests require an explicit model selection."); + } const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider]; yield* harness.engine.dispatch({ diff --git a/apps/server/package.json b/apps/server/package.json index 9c944c1a..20035e80 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -24,6 +24,9 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.111", + "@earendil-works/pi-agent-core": "^0.74.0", + "@earendil-works/pi-ai": "^0.74.0", + "@earendil-works/pi-coding-agent": "^0.74.0", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@opencode-ai/sdk": "^1.14.48", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1c91ca95..3faff191 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -168,6 +168,112 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { } }), ); + + it.effect("persists turn-start thread settings into projection rows", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-02-26T13:00:00.000Z"; + const turnRequestedAt = "2026-02-26T13:00:05.000Z"; + + yield* eventStore.append({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-turn-settings-project"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-turn-settings"), + occurredAt: createdAt, + commandId: CommandId.makeUnsafe("cmd-turn-settings-project"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-turn-settings-project"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-turn-settings"), + title: "Project", + workspaceRoot: "/tmp/project-turn-settings", + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-turn-settings-thread"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-turn-settings"), + occurredAt: createdAt, + commandId: CommandId.makeUnsafe("cmd-turn-settings-thread"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-turn-settings-thread"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-turn-settings"), + projectId: ProjectId.makeUnsafe("project-turn-settings"), + title: "Thread", + modelSelection: { + provider: "pi", + model: "openai/gpt-5.1", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + yield* eventStore.append({ + type: "thread.turn-start-requested", + eventId: EventId.makeUnsafe("evt-turn-settings-start"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-turn-settings"), + occurredAt: turnRequestedAt, + commandId: CommandId.makeUnsafe("cmd-turn-settings-start"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-turn-settings-start"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-turn-settings"), + messageId: MessageId.makeUnsafe("message-turn-settings"), + modelSelection: { + provider: "pi", + model: "openai/gpt-5.5", + }, + runtimeMode: "approval-required", + interactionMode: "default", + createdAt: turnRequestedAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly modelSelectionJson: string; + readonly runtimeMode: string; + readonly interactionMode: string; + readonly updatedAt: string; + }>` + SELECT + model_selection_json AS "modelSelectionJson", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + updated_at AS "updatedAt" + FROM projection_threads + WHERE thread_id = 'thread-turn-settings' + `; + + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]!.modelSelectionJson), { + provider: "pi", + model: "openai/gpt-5.5", + }); + assert.equal(rows[0]!.runtimeMode, "approval-required"); + assert.equal(rows[0]!.interactionMode, "default"); + assert.equal(rows[0]!.updatedAt, turnRequestedAt); + }), + ); }); it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ee38ad64..743ba60e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -718,6 +718,28 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { return; } + case "thread.turn-start-requested": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + const modelSelectionPatch = + event.payload.modelSelection !== undefined && + event.payload.modelSelection.provider === existingRow.value.modelSelection.provider + ? { modelSelection: event.payload.modelSelection } + : {}; + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + ...modelSelectionPatch, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + updatedAt: event.payload.createdAt, + }); + return; + } + case "thread.deleted": { attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); const existingRow = yield* projectionThreadRepository.getById({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b0b9f6d0..8e56f1ba 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -468,15 +468,17 @@ function runtimeEventToActivities( if (!message) { return []; } + const errorClass = asString(runtimePayloadRecord(event)?.class); return [ { id: event.eventId, createdAt: event.createdAt, tone: "error", kind: "runtime.error", - summary: "Runtime error", + summary: "Provider runtime error", payload: toActivityPayload({ - message: truncateDetail(message), + message: truncateDetail(message, 500), + ...(errorClass ? { class: errorClass } : {}), }), turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -779,15 +781,16 @@ function runtimeEventToActivities( } case "turn.completed": { + const state = runtimeTurnState(event); return [ { id: event.eventId, createdAt: event.createdAt, - tone: "info" as const, + tone: state === "failed" ? "error" : "info", kind: "turn.completed", - summary: "Turn completed", + summary: state === "failed" ? "Turn failed" : "Turn completed", payload: toActivityPayload({ - state: runtimeTurnState(event), + state, ...(typeof event.payload.totalCostUsd === "number" ? { totalCostUsd: event.payload.totalCostUsd } : {}), diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index c5832499..2f3af4ae 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -112,6 +112,73 @@ describe("orchestration projector", () => { ]); }); + it("updates thread settings from turn start events", async () => { + const createdAt = "2026-02-23T08:00:00.000Z"; + const turnRequestedAt = "2026-02-23T08:00:05.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: "pi", + model: "openai/gpt-5.1", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ), + ); + + const next = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.turn-start-requested", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: turnRequestedAt, + commandId: "cmd-turn-start", + payload: { + threadId: "thread-1", + messageId: "message-1", + modelSelection: { + provider: "pi", + model: "openai/gpt-5.5", + }, + runtimeMode: "approval-required", + interactionMode: "default", + createdAt: turnRequestedAt, + }, + }), + ), + ); + + expect(next.threads[0]?.modelSelection).toEqual({ + provider: "pi", + model: "openai/gpt-5.5", + }); + expect(next.threads[0]?.runtimeMode).toBe("approval-required"); + expect(next.threads[0]?.interactionMode).toBe("default"); + expect(next.threads[0]?.updatedAt).toBe(turnRequestedAt); + }); + it("fails when event payload cannot be decoded by runtime schema", async () => { const now = new Date().toISOString(); const model = createEmptyReadModel(now); @@ -155,7 +222,7 @@ describe("orchestration projector", () => { model, makeEvent({ sequence: 7, - type: "thread.turn-start-requested", + type: "thread.turn-interrupt-requested", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0ddb9038..1efadf1c 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -26,6 +26,7 @@ import { ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, + ThreadTurnStartRequestedPayload, } from "./Schemas.ts"; type ThreadPatch = Partial>; @@ -452,6 +453,35 @@ export function projectEvent( })), ); + case "thread.turn-start-requested": + return decodeForEvent( + ThreadTurnStartRequestedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + const modelSelectionPatch = + payload.modelSelection !== undefined && + payload.modelSelection.provider === thread.modelSelection.provider + ? { modelSelection: payload.modelSelection } + : {}; + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...modelSelectionPatch, + runtimeMode: payload.runtimeMode, + interactionMode: payload.interactionMode, + updatedAt: payload.createdAt, + }), + }; + }), + ); + case "thread.message-sent": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/modelSelectionCompatibility.test.ts b/apps/server/src/persistence/modelSelectionCompatibility.test.ts new file mode 100644 index 00000000..0a6e3ce0 --- /dev/null +++ b/apps/server/src/persistence/modelSelectionCompatibility.test.ts @@ -0,0 +1,23 @@ +import { assert, it } from "@effect/vitest"; + +import { normalizePersistedModelSelection } from "./modelSelectionCompatibility.ts"; + +it("preserves canonical Pi model selections", () => { + assert.deepEqual(normalizePersistedModelSelection({ provider: "pi", model: "openai/gpt-5.5" }), { + provider: "pi", + model: "openai/gpt-5.5", + }); +}); + +it("infers Pi from persisted instance labels", () => { + assert.deepEqual( + normalizePersistedModelSelection({ + instanceId: "local-pi-runtime-instance", + model: "openai/gpt-5.5", + }), + { + provider: "pi", + model: "openai/gpt-5.5", + }, + ); +}); diff --git a/apps/server/src/persistence/modelSelectionCompatibility.ts b/apps/server/src/persistence/modelSelectionCompatibility.ts index 7ccf308d..e9965c56 100644 --- a/apps/server/src/persistence/modelSelectionCompatibility.ts +++ b/apps/server/src/persistence/modelSelectionCompatibility.ts @@ -3,7 +3,7 @@ // Layer: Persistence compatibility helper // Exports: normalizeLegacyModelSelection, normalizePersistedModelSelection -type ModelProviderKind = "codex" | "claudeAgent" | "cursor" | "gemini" | "opencode"; +type ModelProviderKind = "codex" | "claudeAgent" | "cursor" | "gemini" | "opencode" | "pi"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -21,6 +21,9 @@ function readTrimmedString(record: Record, key: string): string // Imported instance ids may be runtime names rather than DP Code provider literals. function inferProviderFromLabel(label: string): ModelProviderKind | undefined { const lowerLabel = label.toLowerCase(); + if (/(^|[^a-z0-9])pi([^a-z0-9]|$)/u.test(lowerLabel)) { + return "pi"; + } if (lowerLabel.includes("opencode")) { return "opencode"; } @@ -45,7 +48,8 @@ function inferLegacyModelProvider(provider: unknown, model: string): ModelProvid provider === "claudeAgent" || provider === "cursor" || provider === "gemini" || - provider === "opencode" + provider === "opencode" || + provider === "pi" ) { return provider; } diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts new file mode 100644 index 00000000..604b4b2b --- /dev/null +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -0,0 +1,1755 @@ +import crypto from "node:crypto"; +import path from "node:path"; + +import { + AuthStorage, + ModelRegistry, + SessionManager, + createAgentSessionFromServices, + createAgentSessionRuntime, + createAgentSessionServices, + getAgentDir, + type AgentSession as PiAgentSession, + type AgentSessionEvent, + type CreateAgentSessionRuntimeFactory, +} from "@earendil-works/pi-coding-agent"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai"; +import { + type ChatAttachment, + EventId, + type ProviderComposerCapabilities, + type ProviderListCommandsResult, + type ProviderListModelsResult, + type ProviderListSkillsResult, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeItemId, + ThreadId, + type ThreadTokenUsageSnapshot, + TurnId, +} from "@t3tools/contracts"; +import { Effect, FileSystem, Layer, Queue, Stream } from "effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { PiAdapter, type PiAdapterShape } from "../Services/PiAdapter.ts"; +import type { ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "pi" as const; +const DEFAULT_PI_THINKING_LEVEL: ThinkingLevel = "medium"; +const PI_THINKING_OPTIONS: ReadonlyArray<{ + readonly value: ThinkingLevel; + readonly label: string; + readonly description: string; + readonly isDefault?: true; +}> = [ + { value: "off", label: "Off", description: "No extra reasoning" }, + { value: "minimal", label: "Minimal", description: "Light reasoning" }, + { value: "low", label: "Low", description: "Faster reasoning" }, + { value: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true }, + { value: "high", label: "High", description: "Deeper reasoning" }, + { value: "xhigh", label: "Extra High", description: "Maximum reasoning" }, +]; + +type PiModelRegistry = Pick; + +interface PiSessionContext { + runtime: Awaited>; + modelRegistry: PiModelRegistry; + session: ProviderSession; + turns: PiStoredTurn[]; + activeTurnId: TurnId | undefined; + activeAssistantItemId: RuntimeItemId | undefined; + activeReasoningItemId: RuntimeItemId | undefined; + activeToolItems: Map; + stopped: boolean; + lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; + unsubscribe: (() => void) | undefined; +} + +interface PiStoredTurn { + readonly id: TurnId; + readonly items: unknown[]; + leafId?: string | null; +} + +interface PiTrackedToolCall { + readonly toolCallId: string; + readonly toolName: string; + readonly args: unknown; + readonly itemId: RuntimeItemId; + readonly itemType: "command_execution" | "file_change" | "dynamic_tool_call" | "web_search"; +} + +export interface PiAdapterLiveOptions { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.trim().length > 0) { + return cause.message; + } + return fallback; +} + +function trimToUndefined(value: string | null | undefined): string | undefined { + const trimmed = typeof value === "string" ? value.trim() : ""; + return trimmed.length > 0 ? trimmed : undefined; +} + +function isPiThinkingLevel(value: string | null | undefined): value is ThinkingLevel { + return ( + value === "off" || + value === "minimal" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" + ); +} + +function normalizePiThinkingLevel(value: string | null | undefined): ThinkingLevel | undefined { + return isPiThinkingLevel(value) ? value : undefined; +} + +function parseModelReference( + modelId: string | null | undefined, +): { readonly provider?: string; readonly id: string } | undefined { + const trimmed = trimToUndefined(modelId); + if (!trimmed) { + return undefined; + } + if (trimmed.includes("/")) { + const [provider, ...rest] = trimmed.split("/"); + const id = rest.join("/"); + if (provider && id) { + return { provider, id }; + } + } + if (trimmed.includes(":")) { + const [provider, ...rest] = trimmed.split(":"); + const id = rest.join(":"); + if (provider && id) { + return { provider, id }; + } + } + return { id: trimmed }; +} + +function createProviderModelFallback( + registry: PiModelRegistry, + parsed: { readonly provider: string; readonly id: string }, +): Model | undefined { + const providerDefault = registry.getAll().find((model) => model.provider === parsed.provider); + if (!providerDefault) { + return undefined; + } + return { + id: parsed.id, + name: parsed.id, + api: providerDefault.api, + provider: parsed.provider, + baseUrl: providerDefault.baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + ...(providerDefault.compat ? { compat: providerDefault.compat } : {}), + }; +} + +function findModelInRegistry( + registry: PiModelRegistry, + modelId: string | null | undefined, +): Model | undefined { + const parsed = parseModelReference(modelId); + if (!parsed) { + return undefined; + } + if (parsed.provider) { + return ( + registry.find(parsed.provider, parsed.id) ?? + createProviderModelFallback(registry, { provider: parsed.provider, id: parsed.id }) + ); + } + return registry + .getAll() + .find((model) => model.id === parsed.id || `${model.provider}/${model.id}` === parsed.id); +} + +function extractResumeSessionFile(resumeCursor: unknown): string | undefined { + if (typeof resumeCursor === "string" && resumeCursor.trim().length > 0) { + return resumeCursor; + } + if (!resumeCursor || typeof resumeCursor !== "object") { + return undefined; + } + const record = resumeCursor as Record; + for (const key of ["sessionFile", "sessionFilePath", "nativeHandle", "path"]) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return undefined; +} + +function getSessionFile(session: PiAgentSession): string | undefined { + return session.sessionFile ?? session.sessionManager.getSessionFile(); +} + +function makeSessionSnapshot(context: PiSessionContext): ProviderSession { + const resumeCursor = getSessionFile(context.runtime.session); + return { + provider: PROVIDER, + status: context.stopped ? "closed" : context.activeTurnId ? "running" : "ready", + runtimeMode: context.session.runtimeMode, + threadId: context.session.threadId, + createdAt: context.session.createdAt, + updatedAt: new Date().toISOString(), + ...(context.session.cwd ? { cwd: context.session.cwd } : {}), + ...(context.session.model ? { model: context.session.model } : {}), + ...(resumeCursor ? { resumeCursor } : {}), + ...(context.activeTurnId ? { activeTurnId: context.activeTurnId } : {}), + ...(context.session.lastError ? { lastError: context.session.lastError } : {}), + }; +} + +function normalizeTokenUsage( + stats: ReturnType, + contextWindow?: number | null, +): ThreadTokenUsageSnapshot | undefined { + const inputTokens = stats.tokens.input; + const cachedInputTokens = stats.tokens.cacheRead; + const outputTokens = stats.tokens.output; + const totalProcessedTokens = stats.tokens.total; + const contextUsage = stats.contextUsage; + const contextUsageWindow = + typeof contextUsage?.contextWindow === "number" && + Number.isFinite(contextUsage.contextWindow) && + contextUsage.contextWindow > 0 + ? Math.floor(contextUsage.contextWindow) + : undefined; + const fallbackWindow = + typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 + ? Math.floor(contextWindow) + : undefined; + const maxTokens = contextUsageWindow ?? fallbackWindow; + const contextUsageTokens = + typeof contextUsage?.tokens === "number" && + Number.isFinite(contextUsage.tokens) && + contextUsage.tokens >= 0 + ? Math.round(contextUsage.tokens) + : undefined; + const usedPercent = + typeof contextUsage?.percent === "number" && Number.isFinite(contextUsage.percent) + ? Math.max(0, Math.min(100, contextUsage.percent)) + : undefined; + const usedTokensFromPercent = + contextUsageTokens === undefined && usedPercent !== undefined && maxTokens !== undefined + ? Math.round((usedPercent / 100) * maxTokens) + : undefined; + const usedTokens = + contextUsageTokens ?? + usedTokensFromPercent ?? + (contextUsage + ? 0 + : maxTokens !== undefined + ? Math.min(totalProcessedTokens, maxTokens) + : totalProcessedTokens); + if ( + usedTokens <= 0 && + inputTokens <= 0 && + cachedInputTokens <= 0 && + outputTokens <= 0 && + maxTokens === undefined && + usedPercent === undefined + ) { + return undefined; + } + return { + usedTokens, + ...(usedPercent !== undefined ? { usedPercent } : {}), + ...(totalProcessedTokens > usedTokens ? { totalProcessedTokens } : {}), + inputTokens, + cachedInputTokens, + outputTokens, + ...(maxTokens !== undefined ? { maxTokens } : {}), + lastUsedTokens: usedTokens, + lastInputTokens: inputTokens, + lastCachedInputTokens: cachedInputTokens, + lastOutputTokens: outputTokens, + }; +} + +function isPiReloadCommand(text: string): boolean { + return /^\/reload(?:\s|$)/iu.test(text.trim()); +} + +function classifyPiRuntimeError( + message: string, +): "provider_error" | "transport_error" | "permission_error" | "validation_error" | "unknown" { + const normalized = message.toLowerCase(); + if ( + normalized.includes("network") || + normalized.includes("connection") || + normalized.includes("timeout") || + normalized.includes("econn") || + normalized.includes("fetch failed") + ) { + return "transport_error"; + } + if ( + normalized.includes("api key") || + normalized.includes("auth") || + normalized.includes("unauthorized") || + normalized.includes("forbidden") || + normalized.includes("permission") + ) { + return "permission_error"; + } + if ( + normalized.includes("invalid") || + normalized.includes("validation") || + normalized.includes("not available") + ) { + return "validation_error"; + } + if ( + normalized.includes("rate limit") || + normalized.includes("quota") || + normalized.includes("usage limit") || + normalized.includes("overloaded") || + normalized.includes("provider") + ) { + return "provider_error"; + } + return "unknown"; +} + +function runtimeErrorDetail(cause: unknown): unknown { + if (cause instanceof Error) { + return { + name: cause.name, + message: cause.message, + ...(cause.stack ? { stack: cause.stack } : {}), + }; + } + return cause; +} + +function textFromContent(content: string | (TextContent | ImageContent)[]): string { + if (typeof content === "string") { + return content; + } + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n\n"); +} + +function toolRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function firstStringValue( + record: Record | undefined, + keys: readonly string[], +): string | undefined { + if (!record) return undefined; + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return undefined; +} + +function textFromToolResult(result: unknown): string | undefined { + if (typeof result === "string") { + return result; + } + const record = toolRecord(result); + if (!record) { + return undefined; + } + const directText = firstStringValue(record, [ + "output", + "stdout", + "stderr", + "text", + "summary", + "message", + "error", + ]); + if (directText) { + return directText; + } + const content = Array.isArray(record.content) ? record.content : []; + const parts = content.flatMap((block) => { + const blockRecord = toolRecord(block); + return blockRecord?.type === "text" && typeof blockRecord.text === "string" + ? [blockRecord.text] + : []; + }); + return parts.length > 0 ? parts.join("\n") : undefined; +} + +function toolExitCode(result: unknown): number | null | undefined { + const record = toolRecord(result); + if (!record) return undefined; + const exitCode = record.exitCode; + if (typeof exitCode === "number" && Number.isFinite(exitCode)) return exitCode; + const code = record.code; + if (typeof code === "number" && Number.isFinite(code)) return code; + return null; +} + +function toolRawOutput(result: unknown): Record | undefined { + if (result === undefined) return undefined; + const text = textFromToolResult(result); + const exitCode = toolExitCode(result); + if (typeof result === "string") { + return { stdout: result, content: result }; + } + if (result === null) { + return {}; + } + const record = toolRecord(result); + if (!record) { + return text ? { stdout: text, content: text } : undefined; + } + return { + ...record, + ...(text ? { stdout: text, content: text } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), + }; +} + +function toolPath(args: unknown): string | undefined { + return firstStringValue(toolRecord(args), ["path", "filePath", "file", "relativePath"]); +} + +function toolCommand(args: unknown): string | undefined { + return firstStringValue(toolRecord(args), ["command", "cmd"]); +} + +function toolSearchQuery(toolName: string, args: unknown): string | undefined { + const record = toolRecord(args); + if (!record) return undefined; + if (toolName === "grep" || toolName === "find") { + return firstStringValue(record, ["pattern", "query"]); + } + return firstStringValue(record, ["query", "pattern"]); +} + +function toolEditEntries(args: unknown): ReadonlyArray> | undefined { + const record = toolRecord(args); + if (!record) return undefined; + if (Array.isArray(record.edits)) { + return record.edits.flatMap((edit) => { + const editRecord = toolRecord(edit); + return editRecord ? [editRecord] : []; + }); + } + const oldText = firstStringValue(record, ["oldText", "old_string", "oldString"]); + const newText = firstStringValue(record, ["newText", "new_string", "newString"]); + if (oldText !== undefined || newText !== undefined) { + return [ + { + ...(oldText !== undefined ? { oldText } : {}), + ...(newText !== undefined ? { newText } : {}), + }, + ]; + } + return undefined; +} + +function toolItemType(toolName: string): PiTrackedToolCall["itemType"] { + switch (toolName) { + case "bash": + return "command_execution"; + case "edit": + case "write": + return "file_change"; + case "grep": + case "find": + return "web_search"; + default: + return "dynamic_tool_call"; + } +} + +function toolTitle(toolName: string, args: unknown): string { + const command = toolName === "bash" ? toolCommand(args) : undefined; + if (command) return command; + const filePath = toolPath(args); + if ( + filePath && + (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "ls") + ) { + return `${toolName} ${filePath}`; + } + const query = toolSearchQuery(toolName, args); + if (query && (toolName === "find" || toolName === "grep")) { + return `${toolName} ${query}`; + } + return toolName; +} + +function toolLifecycleData(input: { + toolCallId: string; + toolName: string; + args: unknown; + result?: unknown; + partialResult?: unknown; + isError?: boolean; +}): Record { + const { toolCallId, toolName, args } = input; + const rawOutput = toolRawOutput(input.result ?? input.partialResult); + const path = toolPath(args); + const query = toolSearchQuery(toolName, args); + const command = toolCommand(args); + const edits = toolEditEntries(args); + const content = toolRecord(args)?.content; + const outputDetails = toolRecord(rawOutput?.details); + const unifiedDiff = firstStringValue(outputDetails, ["diff"]); + const base: Record = { + toolCallId, + callId: toolCallId, + toolName, + name: toolName, + tool: toolName, + kind: toolName, + args, + input: args, + rawInput: args, + ...(rawOutput ? { rawOutput } : {}), + ...(input.partialResult !== undefined ? { partialResult: input.partialResult } : {}), + ...(input.result !== undefined ? { result: input.result } : {}), + ...(input.isError !== undefined ? { isError: input.isError } : {}), + }; + + switch (toolName) { + case "bash": + return { + ...base, + kind: "execute", + ...(command ? { command } : {}), + ...(rawOutput?.exitCode !== undefined ? { exitCode: rawOutput.exitCode } : {}), + }; + case "read": + return { + ...base, + kind: "read", + ...(path + ? { + path, + filePath: path, + files: [{ path }], + commandActions: [{ type: "read", name: "read", path }], + } + : {}), + }; + case "edit": + return { + ...base, + kind: "edit", + ...(path ? { path, filePath: path, files: [{ path }], changes: [{ path }] } : {}), + ...(edits ? { edits: edits.map((edit) => ({ ...edit, ...(path ? { path } : {}) })) } : {}), + ...(unifiedDiff ? { unifiedDiff } : {}), + }; + case "write": + return { + ...base, + kind: "write", + ...(path ? { path, filePath: path, files: [{ path }], changes: [{ path }] } : {}), + ...(typeof content === "string" ? { content } : {}), + }; + case "find": + return { + ...base, + kind: "search", + searchKind: "find", + ...(query ? { query } : {}), + ...(path ? { path } : {}), + ...(query || path + ? { commandActions: [{ type: "search", name: "find", query, path }] } + : {}), + }; + case "grep": + return { + ...base, + kind: "search", + searchKind: "grep", + ...(query ? { query } : {}), + ...(path ? { path } : {}), + ...(query || path + ? { commandActions: [{ type: "search", name: "grep", query, path }] } + : {}), + }; + case "ls": + return { + ...base, + kind: "listFiles", + ...(path + ? { + path, + query: path, + commandActions: [{ type: "listFiles", name: "ls", path }], + } + : {}), + }; + default: + return base; + } +} + +function mapMessageHistory(session: PiAgentSession): unknown[] { + const items: unknown[] = []; + const pendingTools = new Map(); + for (const message of session.messages) { + if (message.role === "user") { + const text = textFromContent(message.content); + if (text) items.push({ type: "user_message", text }); + continue; + } + if (message.role === "assistant") { + for (const content of message.content) { + if (content.type === "text" && content.text) { + items.push({ type: "assistant_message", text: content.text }); + continue; + } + if (content.type === "thinking" && content.thinking) { + items.push({ type: "reasoning", text: content.thinking }); + continue; + } + if (content.type === "toolCall") { + pendingTools.set(content.id, { toolName: content.name, args: content.arguments }); + items.push({ + type: "tool_call", + status: "started", + callId: content.id, + toolName: content.name, + itemType: toolItemType(content.name), + title: toolTitle(content.name, content.arguments), + args: content.arguments, + data: toolLifecycleData({ + toolCallId: content.id, + toolName: content.name, + args: content.arguments, + }), + }); + } + } + continue; + } + if (message.role === "toolResult") { + const pending = pendingTools.get(message.toolCallId); + pendingTools.delete(message.toolCallId); + const toolName = pending?.toolName ?? message.toolName; + const args = pending?.args; + const result = { content: message.content }; + items.push({ + type: "tool_call", + status: message.isError ? "failed" : "completed", + callId: message.toolCallId, + toolName, + itemType: toolItemType(toolName), + title: toolTitle(toolName, args), + output: textFromContent(message.content), + isError: message.isError, + data: toolLifecycleData({ + toolCallId: message.toolCallId, + toolName, + args, + result, + isError: message.isError, + }), + }); + } + } + return items; +} + +function makeAgentDir(agentDir: string | undefined): string { + return trimToUndefined(agentDir) ?? getAgentDir(); +} + +const makePiAdapter = (options?: PiAdapterLiveOptions) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const runtimeEventQueue = yield* Queue.unbounded(); + const sessions = new Map(); + const modelRegistries = new Map(); + const ownsNativeEventLogger = options?.nativeEventLogger === undefined; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + + const getModelRegistry = (agentDir: string): ModelRegistry => { + const existing = modelRegistries.get(agentDir); + if (existing) return existing; + const authStorage = AuthStorage.create(path.join(agentDir, "auth.json")); + const registry = ModelRegistry.create(authStorage, path.join(agentDir, "models.json")); + modelRegistries.set(agentDir, registry); + return registry; + }; + + const makeEventBase = ( + context: PiSessionContext, + options?: { readonly includeTurnId?: boolean }, + ) => ({ + eventId: EventId.makeUnsafe(crypto.randomUUID()), + provider: PROVIDER, + threadId: context.session.threadId, + createdAt: new Date().toISOString(), + ...(options?.includeTurnId !== false && context.activeTurnId + ? { turnId: context.activeTurnId } + : {}), + }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => { + Effect.runPromise(Queue.offer(runtimeEventQueue, event)).catch(() => undefined); + if (nativeEventLogger && event.raw) { + Effect.runPromise(nativeEventLogger.write(event.raw, event.threadId)).catch( + () => undefined, + ); + } + }; + + const offerRuntimeError = ( + context: PiSessionContext, + input: { + readonly message: string; + readonly cause?: unknown; + readonly method: string; + readonly messageType?: string; + }, + ) => { + offerRuntimeEvent({ + ...makeEventBase(context, { includeTurnId: false }), + type: "runtime.error", + payload: { + message: input.message, + class: classifyPiRuntimeError(input.message), + ...(input.cause !== undefined ? { detail: runtimeErrorDetail(input.cause) } : {}), + }, + raw: { + source: "pi.sdk.event", + method: input.method, + ...(input.messageType ? { messageType: input.messageType } : {}), + payload: input.cause ?? { message: input.message }, + }, + } satisfies ProviderRuntimeEvent); + }; + + const recordItem = (context: PiSessionContext, item: unknown) => { + const turn = context.activeTurnId + ? context.turns.find((candidate) => candidate.id === context.activeTurnId) + : context.turns.at(-1); + turn?.items.push(item); + }; + + const requireSession = Effect.fn("PiAdapter.requireSession")(function* (threadId: ThreadId) { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + if (context.stopped) { + return yield* new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }); + } + return context; + }); + + const handleMessageUpdate = ( + context: PiSessionContext, + event: Extract, + ) => { + if (event.message.role !== "assistant") return; + const update = event.assistantMessageEvent; + if (update.type === "text_delta") { + if (!context.activeAssistantItemId) { + context.activeAssistantItemId = RuntimeItemId.makeUnsafe( + `pi-assistant-${crypto.randomUUID()}`, + ); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeAssistantItemId, + type: "item.started", + payload: { itemType: "assistant_message", status: "inProgress", title: "Assistant" }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + recordItem(context, { type: "assistant_message", delta: update.delta }); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeAssistantItemId, + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: update.delta, + contentIndex: update.contentIndex, + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + if (update.type === "thinking_delta") { + if (!context.activeReasoningItemId) { + context.activeReasoningItemId = RuntimeItemId.makeUnsafe( + `pi-reasoning-${crypto.randomUUID()}`, + ); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeReasoningItemId, + type: "item.started", + payload: { itemType: "reasoning", status: "inProgress", title: "Reasoning" }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + recordItem(context, { type: "reasoning", delta: update.delta }); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeReasoningItemId, + type: "content.delta", + payload: { + streamKind: "reasoning_text", + delta: update.delta, + contentIndex: update.contentIndex, + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + }; + + const handleSessionEvent = (context: PiSessionContext, event: AgentSessionEvent) => { + switch (event.type) { + case "agent_start": + offerRuntimeEvent({ + ...makeEventBase(context), + type: "thread.state.changed", + payload: { state: "active" }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + case "turn_start": + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.started", + payload: { + ...(context.runtime.session.model + ? { + model: `${context.runtime.session.model.provider}/${context.runtime.session.model.id}`, + } + : {}), + effort: context.runtime.session.thinkingLevel, + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + case "message_update": + handleMessageUpdate(context, event); + return; + case "tool_execution_start": { + const itemId = RuntimeItemId.makeUnsafe(`pi-tool-${event.toolCallId}`); + const tracked: PiTrackedToolCall = { + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + itemId, + itemType: toolItemType(event.toolName), + }; + context.activeToolItems.set(event.toolCallId, tracked); + const title = toolTitle(event.toolName, event.args); + recordItem(context, { + type: "tool_call", + status: "started", + toolName: event.toolName, + args: event.args, + }); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId, + providerRefs: { providerItemId: ProviderItemId.makeUnsafe(event.toolCallId) }, + type: "item.started", + payload: { + itemType: tracked.itemType, + status: "inProgress", + title, + data: toolLifecycleData({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + }), + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + case "tool_execution_update": { + const tracked = context.activeToolItems.get(event.toolCallId); + if (!tracked) return; + const detail = textFromToolResult(event.partialResult); + recordItem(context, { + type: "tool_call", + status: "updated", + toolName: event.toolName, + output: detail, + }); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: tracked.itemId, + providerRefs: { providerItemId: ProviderItemId.makeUnsafe(event.toolCallId) }, + type: "item.updated", + payload: { + itemType: tracked.itemType, + status: "inProgress", + title: toolTitle(event.toolName, tracked.args), + ...(detail ? { detail } : {}), + data: toolLifecycleData({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: tracked.args, + partialResult: event.partialResult, + }), + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + case "tool_execution_end": { + const tracked = context.activeToolItems.get(event.toolCallId) ?? { + toolCallId: event.toolCallId, + toolName: event.toolName, + args: undefined, + itemId: RuntimeItemId.makeUnsafe(`pi-tool-${event.toolCallId}`), + itemType: toolItemType(event.toolName), + }; + context.activeToolItems.delete(event.toolCallId); + const detail = textFromToolResult(event.result); + recordItem(context, { + type: "tool_call", + status: event.isError ? "failed" : "completed", + toolName: event.toolName, + output: detail, + result: event.result, + }); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: tracked.itemId, + providerRefs: { providerItemId: ProviderItemId.makeUnsafe(event.toolCallId) }, + type: "item.completed", + payload: { + itemType: tracked.itemType, + status: event.isError ? "failed" : "completed", + title: toolTitle(event.toolName, tracked.args), + ...(detail ? { detail } : {}), + data: toolLifecycleData({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: tracked.args, + result: event.result, + isError: event.isError, + }), + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + case "compaction_start": { + const itemId = RuntimeItemId.makeUnsafe(`pi-compaction-${crypto.randomUUID()}`); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId, + type: "item.updated", + payload: { + itemType: "context_compaction", + status: "inProgress", + title: "Compacting context", + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + case "compaction_end": { + const itemId = RuntimeItemId.makeUnsafe(`pi-compaction-${crypto.randomUUID()}`); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId, + type: "item.completed", + payload: { + itemType: "context_compaction", + status: event.aborted ? "failed" : "completed", + title: "Context compacted", + data: event, + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + case "agent_end": { + const stats = context.runtime.session.getSessionStats(); + const usage = normalizeTokenUsage(stats, context.runtime.session.model?.contextWindow); + context.lastKnownTokenUsage = usage; + const turnId = context.activeTurnId; + const errorMessage = context.runtime.session.agent.state.errorMessage; + const leafId = context.runtime.session.sessionManager.getLeafId(); + const turn = turnId + ? context.turns.find((candidate) => candidate.id === turnId) + : undefined; + if (turn) turn.leafId = leafId; + if (context.activeAssistantItemId) { + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeAssistantItemId, + type: "item.completed", + payload: { + itemType: "assistant_message", + status: errorMessage ? "failed" : "completed", + title: "Assistant", + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + if (context.activeReasoningItemId) { + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeReasoningItemId, + type: "item.completed", + payload: { + itemType: "reasoning", + status: errorMessage ? "failed" : "completed", + title: "Reasoning", + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + if (usage) { + offerRuntimeEvent({ + ...makeEventBase(context), + type: "thread.token-usage.updated", + payload: { usage }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.completed", + payload: errorMessage + ? { state: "failed", stopReason: "error", errorMessage, usage: stats } + : { state: "completed", stopReason: null, usage: stats }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + if (errorMessage) { + offerRuntimeError(context, { + message: errorMessage, + method: "prompt", + messageType: event.type, + cause: event, + }); + } + context.activeTurnId = undefined; + context.activeAssistantItemId = undefined; + context.activeReasoningItemId = undefined; + context.session = makeSessionSnapshot(context); + return; + } + default: + return; + } + }; + + const createSdkRuntime = async (input: { + cwd: string; + agentDir: string; + sessionManager: SessionManager; + modelId?: string; + thinkingLevel?: ThinkingLevel; + }) => { + const registry = getModelRegistry(input.agentDir); + const createRuntime: CreateAgentSessionRuntimeFactory = async ({ + cwd, + agentDir, + sessionManager, + sessionStartEvent, + }) => { + const services = await createAgentSessionServices({ + cwd, + agentDir, + modelRegistry: registry, + }); + const model = findModelInRegistry(services.modelRegistry, input.modelId); + if (input.modelId && !model) { + throw new Error( + `Pi model '${input.modelId}' is not available. Use a discovered model or a provider-qualified custom model slug like 'openai/gpt-5.5'.`, + ); + } + return { + ...(await createAgentSessionFromServices({ + services, + sessionManager, + ...(sessionStartEvent ? { sessionStartEvent } : {}), + ...(model ? { model } : {}), + thinkingLevel: input.thinkingLevel ?? DEFAULT_PI_THINKING_LEVEL, + })), + services, + diagnostics: services.diagnostics, + }; + }; + const runtime = await createAgentSessionRuntime(createRuntime, { + cwd: input.sessionManager.getCwd(), + agentDir: input.agentDir, + sessionManager: input.sessionManager, + }); + await runtime.session.bindExtensions({}); + return { runtime, modelRegistry: runtime.services.modelRegistry }; + }; + + const startSession: PiAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + const cwd = trimToUndefined(input.cwd) ?? serverConfig.cwd; + const agentDir = makeAgentDir(input.providerOptions?.pi?.agentDir); + const sessionFile = extractResumeSessionFile(input.resumeCursor); + const sessionManager = sessionFile + ? SessionManager.open(sessionFile, undefined, cwd) + : SessionManager.create(cwd); + const modelId = + input.modelSelection?.provider === "pi" ? input.modelSelection.model : undefined; + const thinkingLevel = + input.modelSelection?.provider === "pi" + ? normalizePiThinkingLevel(input.modelSelection.options?.thinkingLevel) + : undefined; + const { runtime, modelRegistry } = yield* Effect.tryPromise({ + try: () => + createSdkRuntime({ + cwd, + agentDir, + sessionManager, + ...(modelId ? { modelId } : {}), + ...(thinkingLevel ? { thinkingLevel } : {}), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/start", + detail: toMessage(cause, "Failed to start Pi session."), + cause, + }), + }); + const now = new Date().toISOString(); + const model = runtime.session.model + ? `${runtime.session.model.provider}/${runtime.session.model.id}` + : modelId; + const resumeCursor = getSessionFile(runtime.session); + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + threadId: input.threadId, + createdAt: now, + updatedAt: now, + ...(model ? { model } : {}), + ...(resumeCursor ? { resumeCursor } : {}), + }; + const context: PiSessionContext = { + runtime, + modelRegistry, + session, + turns: [], + activeTurnId: undefined, + activeAssistantItemId: undefined, + activeReasoningItemId: undefined, + activeToolItems: new Map(), + stopped: false, + lastKnownTokenUsage: undefined, + unsubscribe: undefined, + }; + context.unsubscribe = runtime.session.subscribe((event) => + handleSessionEvent(context, event), + ); + sessions.set(input.threadId, context); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "session.started", + payload: { message: "Pi session started", resume: session.resumeCursor }, + } satisfies ProviderRuntimeEvent); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "thread.started", + payload: { providerThreadId: runtime.session.sessionId }, + } satisfies ProviderRuntimeEvent); + const initialUsage = normalizeTokenUsage( + runtime.session.getSessionStats(), + runtime.session.model?.contextWindow, + ); + context.lastKnownTokenUsage = initialUsage; + if (initialUsage) { + offerRuntimeEvent({ + ...makeEventBase(context), + type: "thread.token-usage.updated", + payload: { usage: initialUsage }, + } satisfies ProviderRuntimeEvent); + } + return session; + }); + + const buildPromptPayload = (input: { + readonly input?: string | undefined; + readonly attachments?: ReadonlyArray | undefined; + }) => + Effect.gen(function* () { + const text = input.input ?? ""; + const images = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + if (attachment.type !== "image" || !attachment.mimeType) return undefined; + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "turn/start", + issue: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: toMessage(cause, "Failed to read attachment file."), + cause, + }), + ), + ); + return { + type: "image" as const, + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }; + }), + { concurrency: 1 }, + ); + return { + text, + images: images.filter((image): image is ImageContent => image !== undefined), + }; + }); + + const sendTurn: PiAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + if (context.activeTurnId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "A Pi turn is already active for this thread.", + }); + } + if (input.modelSelection?.provider === "pi") { + const model = findModelInRegistry(context.modelRegistry, input.modelSelection.model); + if (!model) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "model/set", + issue: `Pi model '${input.modelSelection.model}' is not available. Use a discovered model or a provider-qualified custom model slug like 'openai/gpt-5.5'.`, + }); + } + yield* Effect.tryPromise({ + try: () => context.runtime.session.setModel(model), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "model/set", + detail: toMessage(cause, "Failed to set Pi model."), + cause, + }), + }); + const thinkingLevel = normalizePiThinkingLevel( + input.modelSelection.options?.thinkingLevel, + ); + if (thinkingLevel) { + context.runtime.session.setThinkingLevel(thinkingLevel); + } + } + const payload = yield* buildPromptPayload(input); + const turnId = TurnId.makeUnsafe(crypto.randomUUID()); + context.activeTurnId = turnId; + context.turns.push({ id: turnId, items: [] }); + context.session = makeSessionSnapshot(context); + if (payload.images.length === 0 && isPiReloadCommand(payload.text)) { + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.started", + payload: { + ...(context.runtime.session.model + ? { + model: `${context.runtime.session.model.provider}/${context.runtime.session.model.id}`, + } + : {}), + effort: context.runtime.session.thinkingLevel, + }, + raw: { source: "pi.sdk.event", method: "reload", payload: { command: payload.text } }, + } satisfies ProviderRuntimeEvent); + yield* Effect.tryPromise({ + try: () => context.runtime.session.reload(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/reload", + detail: toMessage(cause, "Failed to reload Pi resources."), + cause, + }), + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const message = error.message; + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.completed", + payload: { state: "failed", stopReason: "error", errorMessage: message }, + raw: { source: "pi.sdk.event", method: "reload", payload: error }, + } satisfies ProviderRuntimeEvent); + offerRuntimeError(context, { + message, + method: "session/reload", + cause: error, + }); + context.activeTurnId = undefined; + context.session = makeSessionSnapshot(context); + return yield* Effect.fail(error); + }), + ), + ); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.completed", + payload: { state: "completed", stopReason: "reload" }, + raw: { source: "pi.sdk.event", method: "reload", payload: { command: payload.text } }, + } satisfies ProviderRuntimeEvent); + context.activeTurnId = undefined; + context.session = makeSessionSnapshot(context); + return { + threadId: input.threadId, + turnId, + resumeCursor: getSessionFile(context.runtime.session), + }; + } + void context.runtime.session + .prompt(payload.text, payload.images.length > 0 ? { images: payload.images } : undefined) + .catch((cause) => { + const message = toMessage(cause, "Pi turn failed."); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.completed", + payload: { state: "failed", stopReason: "error", errorMessage: message }, + raw: { source: "pi.sdk.event", method: "prompt", payload: cause }, + } satisfies ProviderRuntimeEvent); + offerRuntimeError(context, { message, method: "prompt", cause }); + context.activeTurnId = undefined; + context.session = makeSessionSnapshot(context); + }); + return { + threadId: input.threadId, + turnId, + resumeCursor: getSessionFile(context.runtime.session), + }; + }); + + const steerTurn: NonNullable = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + const payload = yield* buildPromptPayload(input); + const turnId = context.activeTurnId ?? TurnId.makeUnsafe(crypto.randomUUID()); + if (!context.activeTurnId) { + context.activeTurnId = turnId; + context.turns.push({ id: turnId, items: [] }); + } + if (context.runtime.session.isStreaming) { + yield* Effect.tryPromise({ + try: () => context.runtime.session.steer(payload.text, payload.images), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/steer", + detail: toMessage(cause, "Failed to steer Pi turn."), + cause, + }), + }); + } else { + void context.runtime.session + .prompt( + payload.text, + payload.images.length > 0 ? { images: payload.images } : undefined, + ) + .catch((cause) => { + const message = toMessage(cause, "Pi turn failed."); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.completed", + payload: { state: "failed", stopReason: "error", errorMessage: message }, + raw: { source: "pi.sdk.event", method: "prompt", payload: cause }, + } satisfies ProviderRuntimeEvent); + offerRuntimeError(context, { message, method: "prompt", cause }); + context.activeTurnId = undefined; + context.session = makeSessionSnapshot(context); + }); + } + return { + threadId: input.threadId, + turnId, + resumeCursor: getSessionFile(context.runtime.session), + }; + }); + + const interruptTurn: PiAdapterShape["interruptTurn"] = (threadId) => + requireSession(threadId).pipe( + Effect.flatMap((context) => + Effect.tryPromise({ + try: () => context.runtime.session.abort(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/interrupt", + detail: toMessage(cause, "Failed to interrupt Pi turn."), + cause, + }), + }), + ), + Effect.asVoid, + ); + + const respondUnsupported = (threadId: ThreadId, method: string) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: `Pi does not expose DP Code approval/user-input requests for thread ${threadId}.`, + }), + ); + + const stopSession: PiAdapterShape["stopSession"] = (threadId) => + requireSession(threadId).pipe( + Effect.flatMap((context) => + Effect.tryPromise({ + try: async () => { + context.unsubscribe?.(); + await context.runtime.dispose(); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/stop", + detail: toMessage(cause, "Failed to stop Pi session."), + cause, + }), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + context.stopped = true; + sessions.delete(threadId); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "thread.state.changed", + payload: { state: "closed", detail: { reason: "stopped" } }, + } satisfies ProviderRuntimeEvent); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "session.exited", + payload: { reason: "stopped", exitKind: "graceful" }, + } satisfies ProviderRuntimeEvent); + }), + ), + ), + ), + Effect.asVoid, + ); + + const listSessions: PiAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values()).map(makeSessionSnapshot)); + + const hasSession: PiAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const snapshotThread = (context: PiSessionContext): ProviderThreadSnapshot => { + const historyItems = mapMessageHistory(context.runtime.session); + const activeTurn = context.activeTurnId + ? context.turns.find((turn) => turn.id === context.activeTurnId) + : undefined; + const turns = [ + ...(historyItems.length > 0 + ? [ + { + id: TurnId.makeUnsafe(`pi-history-${context.runtime.session.sessionId}`), + items: historyItems, + }, + ] + : []), + ...(activeTurn ? [{ id: activeTurn.id, items: [...activeTurn.items] }] : []), + ]; + return { + threadId: context.session.threadId, + ...(context.session.cwd ? { cwd: context.session.cwd } : {}), + turns: + turns.length > 0 + ? turns + : context.turns.map((turn) => ({ id: turn.id, items: [...turn.items] })), + }; + }; + + const readThread: PiAdapterShape["readThread"] = (threadId) => + requireSession(threadId).pipe(Effect.map(snapshotThread)); + + const rollbackThread: PiAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const nextLength = Math.max(0, context.turns.length - Math.max(0, numTurns)); + context.turns.splice(nextLength); + const leafId = context.turns.at(-1)?.leafId; + if (leafId) { + context.runtime.session.sessionManager.branch(leafId); + } else if (nextLength === 0) { + context.runtime.session.sessionManager.resetLeaf(); + } + return snapshotThread(context); + }); + + const compactThread: NonNullable = (threadId) => + requireSession(threadId).pipe( + Effect.flatMap((context) => + Effect.tryPromise({ + try: () => context.runtime.session.compact(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/compact", + detail: toMessage(cause, "Failed to compact Pi thread."), + cause, + }), + }), + ), + Effect.asVoid, + ); + + const stopAll: PiAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.keys()), (threadId) => stopSession(threadId), { + concurrency: "unbounded", + discard: true, + }).pipe(Effect.asVoid); + + const listModels: NonNullable = (input) => + Effect.tryPromise({ + try: async () => { + const agentDir = makeAgentDir(input.agentDir); + const registry = getModelRegistry(agentDir); + registry.refresh(); + const models = registry.getAvailable().map((model) => ({ + slug: `${model.provider}/${model.id}`, + name: model.name, + upstreamProviderId: model.provider, + upstreamProviderName: registry.getProviderDisplayName(model.provider), + ...(model.reasoning + ? { + supportedReasoningEfforts: PI_THINKING_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + description: option.description, + })), + defaultReasoningEffort: DEFAULT_PI_THINKING_LEVEL, + } + : {}), + })); + return { models, source: "pi.sdk", cached: false } satisfies ProviderListModelsResult; + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "model/list", + detail: toMessage(cause, "Failed to list Pi models."), + cause, + }), + }); + + const listSkills: NonNullable = (input) => + Effect.tryPromise({ + try: async () => { + const active = input.threadId + ? sessions.get(ThreadId.makeUnsafe(input.threadId)) + : undefined; + const loader = active?.runtime.session.resourceLoader; + const services = loader + ? undefined + : await createAgentSessionServices({ cwd: input.cwd, agentDir: getAgentDir() }); + const result = (loader ?? services!.resourceLoader).getSkills(); + return { + skills: result.skills.map((skill) => { + const description = trimToUndefined(skill.description); + const scope = trimToUndefined(skill.sourceInfo.source); + return { + name: skill.name, + ...(description ? { description } : {}), + path: skill.filePath, + enabled: !skill.disableModelInvocation, + ...(scope ? { scope } : {}), + }; + }), + source: "pi.sdk", + cached: false, + } satisfies ProviderListSkillsResult; + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "skill/list", + detail: toMessage(cause, "Failed to list Pi skills."), + cause, + }), + }); + + const listCommands: NonNullable = (input) => + Effect.tryPromise({ + try: async () => { + const active = input.threadId + ? sessions.get(ThreadId.makeUnsafe(input.threadId)) + : undefined; + const session = active?.runtime.session; + const reloadCommand = { + name: "reload", + description: "Reload Pi extensions, skills, prompts, themes, tools, and settings", + }; + if (session) { + const extensionCommands = session.extensionRunner + .getRegisteredCommands() + .map((command) => ({ + name: command.invocationName, + description: trimToUndefined(command.description) ?? "Extension command", + })); + const promptCommands = session.promptTemplates.map((template) => ({ + name: template.name, + description: trimToUndefined(template.description) ?? "Prompt template", + })); + const skillCommands = session.resourceLoader.getSkills().skills.map((skill) => ({ + name: `skill:${skill.name}`, + description: trimToUndefined(skill.description) ?? "Skill", + })); + return { + commands: [reloadCommand, ...extensionCommands, ...promptCommands, ...skillCommands], + source: "pi.sdk", + cached: false, + } satisfies ProviderListCommandsResult; + } + const services = await createAgentSessionServices({ + cwd: input.cwd, + agentDir: getAgentDir(), + }); + const promptCommands = services.resourceLoader.getPrompts().prompts.map((template) => ({ + name: template.name, + description: trimToUndefined(template.description) ?? "Prompt template", + })); + const skillCommands = services.resourceLoader.getSkills().skills.map((skill) => ({ + name: `skill:${skill.name}`, + description: trimToUndefined(skill.description) ?? "Skill", + })); + return { + commands: [reloadCommand, ...promptCommands, ...skillCommands], + source: "pi.sdk", + cached: false, + } satisfies ProviderListCommandsResult; + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "command/list", + detail: toMessage(cause, "Failed to list Pi commands."), + cause, + }), + }); + + const getComposerCapabilities: NonNullable = () => + Effect.succeed({ + provider: PROVIDER, + supportsSkillMentions: true, + supportsSkillDiscovery: true, + supportsNativeSlashCommandDiscovery: true, + supportsPluginMentions: false, + supportsPluginDiscovery: false, + supportsRuntimeModelList: true, + supportsThreadCompaction: true, + supportsThreadImport: false, + } satisfies ProviderComposerCapabilities); + + yield* Effect.addFinalizer(() => + stopAll().pipe( + Effect.ignore, + Effect.andThen( + ownsNativeEventLogger && nativeEventLogger + ? nativeEventLogger.close().pipe(Effect.ignore) + : Effect.void, + ), + Effect.andThen(Queue.shutdown(runtimeEventQueue)), + ), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + supportsSkillMentions: true, + supportsSkillDiscovery: true, + supportsNativeSlashCommandDiscovery: true, + supportsPluginMentions: false, + supportsPluginDiscovery: false, + supportsRuntimeModelList: true, + supportsTurnSteering: true, + }, + startSession, + sendTurn, + steerTurn, + interruptTurn, + respondToRequest: (threadId) => respondUnsupported(threadId, "request/respond"), + respondToUserInput: (threadId) => respondUnsupported(threadId, "user-input/respond"), + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + compactThread, + stopAll, + listModels, + listSkills, + listCommands, + getComposerCapabilities, + get streamEvents() { + return Stream.fromQueue(runtimeEventQueue); + }, + } satisfies PiAdapterShape; + }); + +export const PiAdapterLive = Layer.effect(PiAdapter, makePiAdapter()); + +export function makePiAdapterLive(options?: PiAdapterLiveOptions) { + return Layer.effect(PiAdapter, makePiAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 37a23ebf..6ca33cd5 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -9,6 +9,7 @@ import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { GeminiAdapter, GeminiAdapterShape } from "../Services/GeminiAdapter.ts"; import { OpenCodeAdapter, OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { PiAdapter, PiAdapterShape } from "../Services/PiAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -99,6 +100,23 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { streamEvents: Stream.empty, }; +const fakePiAdapter: PiAdapterShape = { + provider: "pi", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -109,6 +127,7 @@ const layer = it.layer( Layer.succeed(CursorAdapter, fakeCursorAdapter), Layer.succeed(GeminiAdapter, fakeGeminiAdapter), Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), + Layer.succeed(PiAdapter, fakePiAdapter), ), ), NodeServices.layer, @@ -124,14 +143,16 @@ layer("ProviderAdapterRegistryLive", (it) => { const cursor = yield* registry.getByProvider("cursor"); const gemini = yield* registry.getByProvider("gemini"); const opencode = yield* registry.getByProvider("opencode"); + const pi = yield* registry.getByProvider("pi"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); assert.equal(cursor, fakeCursorAdapter); assert.equal(gemini, fakeGeminiAdapter); assert.equal(opencode, fakeOpenCodeAdapter); + assert.equal(pi, fakePiAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent", "cursor", "gemini", "opencode"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "cursor", "gemini", "opencode", "pi"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 8df5e976..fea71823 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -20,6 +20,7 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { GeminiAdapter } from "../Services/GeminiAdapter.ts"; import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import { PiAdapter } from "../Services/PiAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -36,6 +37,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption yield* CursorAdapter, yield* GeminiAdapter, yield* OpenCodeAdapter, + yield* PiAdapter, ]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 6a24812b..6d1bce3b 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -17,6 +17,7 @@ import type { import { parseCodexConfigModelProvider } from "@t3tools/shared/codexConfig"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { query as claudeQuery, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { Array, Cache, @@ -59,6 +60,7 @@ const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; const CURSOR_PROVIDER = "cursor" as const; const GEMINI_PROVIDER = "gemini" as const; const OPENCODE_PROVIDER = "opencode" as const; +const PI_PROVIDER = "pi" as const; type ProviderStatuses = ReadonlyArray; // ── Pure helpers ──────────────────────────────────────────────────── @@ -1184,6 +1186,25 @@ export const checkOpenCodeProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +// ── Pi health check ───────────────────────────────────────────── + +export const checkPiProviderStatus: Effect.Effect = Effect.sync(() => { + const checkedAt = new Date().toISOString(); + const registry = ModelRegistry.create(AuthStorage.create()); + const modelCount = registry.getAvailable().length; + return { + provider: PI_PROVIDER, + status: modelCount > 0 ? "ready" : "warning", + available: modelCount > 0, + authStatus: modelCount > 0 ? "authenticated" : "unknown", + checkedAt, + message: + modelCount > 0 + ? `Pi SDK is available with ${modelCount} authenticated model${modelCount === 1 ? "" : "s"}.` + : "Pi SDK is available, but no authenticated models were found in ~/.pi/agent/auth.json.", + } satisfies ServerProviderStatus; +}); + // ── Cursor health check ───────────────────────────────────────────── export const checkCursorProviderStatus: Effect.Effect< @@ -1294,6 +1315,7 @@ export const ProviderHealthLive = Layer.effect( CURSOR_PROVIDER, GEMINI_PROVIDER, OPENCODE_PROVIDER, + PI_PROVIDER, ].map( (provider) => [ @@ -1313,6 +1335,7 @@ export const ProviderHealthLive = Layer.effect( CURSOR_PROVIDER, GEMINI_PROVIDER, OPENCODE_PROVIDER, + PI_PROVIDER, ] as const, (provider) => readProviderStatusCache(cachePathByProvider.get(provider)!).pipe( @@ -1352,6 +1375,7 @@ export const ProviderHealthLive = Layer.effect( checkCursorProviderStatus, checkGeminiProviderStatus, checkOpenCodeProviderStatus, + checkPiProviderStatus, ], { concurrency: "unbounded", diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index d0b7de64..958a1d09 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -27,7 +27,8 @@ function decodeProviderKind( providerName === "claudeAgent" || providerName === "cursor" || providerName === "gemini" || - providerName === "opencode" + providerName === "opencode" || + providerName === "pi" ) { return Effect.succeed(providerName); } diff --git a/apps/server/src/provider/Services/PiAdapter.ts b/apps/server/src/provider/Services/PiAdapter.ts new file mode 100644 index 00000000..7eee8967 --- /dev/null +++ b/apps/server/src/provider/Services/PiAdapter.ts @@ -0,0 +1,20 @@ +/** + * PiAdapter - Pi direct SDK implementation of the generic provider adapter contract. + * + * Pi is intentionally treated as an unopinionated harness: DP Code does not add + * permissions or plan-mode semantics on top of it. + * + * @module PiAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface PiAdapterShape extends ProviderAdapterShape { + readonly provider: "pi"; +} + +export class PiAdapter extends ServiceMap.Service()( + "t3/provider/Services/PiAdapter", +) {} diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 5faa8cce..536573d6 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -15,6 +15,7 @@ const PROVIDER_STATUS_CACHE_IDS = [ "cursor", "gemini", "opencode", + "pi", ] as const satisfies ReadonlyArray; const decodeProviderStatusCache = Schema.decodeUnknownEffect( diff --git a/apps/server/src/provider/runtimeLayer.ts b/apps/server/src/provider/runtimeLayer.ts index c2ff76bc..445316d3 100644 --- a/apps/server/src/provider/runtimeLayer.ts +++ b/apps/server/src/provider/runtimeLayer.ts @@ -11,6 +11,7 @@ import { makeCursorAdapterLive } from "./Layers/CursorAdapter"; import { makeEventNdjsonLogger } from "./Layers/EventNdjsonLogger"; import { makeGeminiAdapterLive } from "./Layers/GeminiAdapter"; import { makeOpenCodeAdapterLive } from "./Layers/OpenCodeAdapter"; +import { makePiAdapterLive } from "./Layers/PiAdapter"; import { ProviderAdapterRegistryLive } from "./Layers/ProviderAdapterRegistry"; import { ProviderDiscoveryServiceLive } from "./Layers/ProviderDiscoveryService"; import { makeProviderServiceLive } from "./Layers/ProviderService"; @@ -61,12 +62,14 @@ export function makeServerProviderLayer(): Layer.Layer< {}, nativeEventLogger ? { nativeEventLogger } : undefined, ); + const piAdapterLayer = makePiAdapterLive(nativeEventLogger ? { nativeEventLogger } : undefined); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), Layer.provide(cursorAdapterLayer), Layer.provide(geminiAdapterLayer), Layer.provide(openCodeAdapterLayer), + Layer.provide(piAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); const providerServiceLayer = makeProviderServiceLive( diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 7aa2f18d..db0084fb 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -9,7 +9,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, type ModelSelection, - type ProviderKind, + type ProviderWithDefaultModel, ServerSettings, ServerSettingsError, type ServerSettingsPatch, @@ -79,7 +79,12 @@ export class ServerSettingsService extends ServiceMap.Service< ); } -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "gemini", "opencode"]; +const PROVIDER_ORDER: readonly ProviderWithDefaultModel[] = [ + "codex", + "claudeAgent", + "gemini", + "opencode", +]; function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { const selection = settings.textGenerationModelSelection; diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 58ef328c..c6ac6c43 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -137,7 +137,14 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: ["galapagos-alpha"], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + { + codex: ["galapagos-alpha"], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, "galapagos-alpha", ), ).toBe("galapagos-alpha"); @@ -147,7 +154,7 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [], pi: [] }, "", ), ).toBe("gpt-5.5"); @@ -157,7 +164,7 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [], pi: [] }, "GPT-5.3 Codex", ), ).toBe("gpt-5.3-codex"); @@ -167,7 +174,7 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "claudeAgent", - { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [], pi: [] }, "sonnet", ), ).toBe("claude-sonnet-4-6"); @@ -177,7 +184,7 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [], pi: [] }, "custom/selected-model", ), ).toBe("custom/selected-model"); @@ -263,6 +270,8 @@ describe("getProviderStartOptions", () => { openCodeBinaryPath: "", openCodeServerPassword: "", openCodeServerUrl: "", + piAgentDir: "", + piBinaryPath: "", }), ).toEqual({ claudeAgent: { @@ -293,6 +302,8 @@ describe("getProviderStartOptions", () => { openCodeBinaryPath: "", openCodeServerPassword: "", openCodeServerUrl: "", + piAgentDir: "", + piBinaryPath: "", }), ).toBeUndefined(); }); @@ -305,6 +316,7 @@ describe("provider-indexed custom model settings", () => { customCursorModels: ["cursor/custom-model"], customGeminiModels: ["gemini/custom-flash"], customOpenCodeModels: ["openrouter/gpt-oss-120b"], + customPiModels: ["anthropic/custom-pi"], } as const; it("exports one provider config per provider", () => { @@ -314,6 +326,7 @@ describe("provider-indexed custom model settings", () => { "cursor", "gemini", "opencode", + "pi", ]); }); @@ -323,6 +336,7 @@ describe("provider-indexed custom model settings", () => { expect(getCustomModelsForProvider(settings, "cursor")).toEqual(["cursor/custom-model"]); expect(getCustomModelsForProvider(settings, "gemini")).toEqual(["gemini/custom-flash"]); expect(getCustomModelsForProvider(settings, "opencode")).toEqual(["openrouter/gpt-oss-120b"]); + expect(getCustomModelsForProvider(settings, "pi")).toEqual(["anthropic/custom-pi"]); }); it("reads default custom models for each provider", () => { @@ -332,6 +346,7 @@ describe("provider-indexed custom model settings", () => { customCursorModels: ["cursor/default-model"], customGeminiModels: ["gemini/default-flash"], customOpenCodeModels: ["openai/gpt-5"], + customPiModels: ["anthropic/default-pi"], } as const; expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); @@ -341,6 +356,7 @@ describe("provider-indexed custom model settings", () => { expect(getDefaultCustomModelsForProvider(defaults, "cursor")).toEqual(["cursor/default-model"]); expect(getDefaultCustomModelsForProvider(defaults, "gemini")).toEqual(["gemini/default-flash"]); expect(getDefaultCustomModelsForProvider(defaults, "opencode")).toEqual(["openai/gpt-5"]); + expect(getDefaultCustomModelsForProvider(defaults, "pi")).toEqual(["anthropic/default-pi"]); }); it("patches custom models for codex", () => { @@ -373,6 +389,12 @@ describe("provider-indexed custom model settings", () => { }); }); + it("patches custom models for pi", () => { + expect(patchCustomModels("pi", ["anthropic/custom-pi"])).toEqual({ + customPiModels: ["anthropic/custom-pi"], + }); + }); + it("builds a complete provider-indexed custom model record", () => { expect(getCustomModelsByProvider(settings)).toEqual({ codex: ["custom/codex-model"], @@ -380,6 +402,7 @@ describe("provider-indexed custom model settings", () => { cursor: ["cursor/custom-model"], gemini: ["gemini/custom-flash"], opencode: ["openrouter/gpt-oss-120b"], + pi: ["anthropic/custom-pi"], }); }); @@ -401,6 +424,9 @@ describe("provider-indexed custom model settings", () => { expect( modelOptionsByProvider.opencode.some((option) => option.slug === "openrouter/gpt-oss-120b"), ).toBe(true); + expect(modelOptionsByProvider.pi.some((option) => option.slug === "anthropic/custom-pi")).toBe( + true, + ); }); it("normalizes and deduplicates custom model options per provider", () => { @@ -414,6 +440,11 @@ describe("provider-indexed custom model settings", () => { "openrouter/gpt-oss-120b", "openrouter/gpt-oss-120b", ], + customPiModels: [ + " anthropic/claude-sonnet-4-5 ", + "anthropic/custom-pi", + "anthropic/custom-pi", + ], }); expect( @@ -438,6 +469,9 @@ describe("provider-indexed custom model settings", () => { expect( modelOptionsByProvider.opencode.filter((option) => option.slug === "openrouter/gpt-oss-120b"), ).toHaveLength(1); + expect( + modelOptionsByProvider.pi.filter((option) => option.slug === "anthropic/custom-pi"), + ).toHaveLength(1); }); }); @@ -470,6 +504,7 @@ describe("AppSettingsSchema", () => { customCursorModels: [], customGeminiModels: [], customOpenCodeModels: [], + customPiModels: [], }); }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index ad2365e0..91b7af23 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -52,7 +52,8 @@ type CustomModelSettingsKey = | "customClaudeModels" | "customCursorModels" | "customGeminiModels" - | "customOpenCodeModels"; + | "customOpenCodeModels" + | "customPiModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; settingsKey: CustomModelSettingsKey; @@ -69,6 +70,7 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record cursor: new Set(getModelOptions("cursor").map((option) => option.slug)), gemini: new Set(getModelOptions("gemini").map((option) => option.slug)), opencode: new Set(getModelOptions("opencode").map((option) => option.slug)), + pi: new Set(getModelOptions("pi").map((option) => option.slug)), }; const withDefaults = @@ -94,6 +96,8 @@ export const AppSettingsSchema = Schema.Struct({ cursorApiEndpoint: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), geminiBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), openCodeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + piBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + piAgentDir: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), openCodeServerUrl: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), openCodeServerPassword: Schema.String.check(Schema.isMaxLength(4096)).pipe( withDefaults(() => ""), @@ -120,6 +124,7 @@ export const AppSettingsSchema = Schema.Struct({ customCursorModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customGeminiModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customOpenCodeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customPiModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), uiFontFamily: Schema.String.check(Schema.isMaxLength(256)).pipe(withDefaults(() => "")), defaultProvider: ProviderKind.pipe(withDefaults(() => "codex" as const)), @@ -182,6 +187,15 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record): Ser : {}), }; } + if ( + hasOwn(patch, "piAgentDir") || + hasOwn(patch, "piBinaryPath") || + hasOwn(patch, "customPiModels") + ) { + providers.pi = { + ...(hasOwn(patch, "piAgentDir") ? { agentDir: patch.piAgentDir ?? "" } : {}), + ...(hasOwn(patch, "piBinaryPath") ? { binaryPath: patch.piBinaryPath ?? "" } : {}), + ...(hasOwn(patch, "customPiModels") ? { customModels: patch.customPiModels ?? [] } : {}), + }; + } if (Object.keys(providers).length > 0) { serverPatch.providers = providers; @@ -371,6 +400,8 @@ function buildInitialServerSettingsMigrationPatch(settings: AppSettings): Server "openCodeBinaryPath", "openCodeServerPassword", "openCodeServerUrl", + "piAgentDir", + "piBinaryPath", "textGenerationModel", ] as const) { if (settings[key] !== defaults[key]) { @@ -384,6 +415,7 @@ function buildInitialServerSettingsMigrationPatch(settings: AppSettings): Server "customCursorModels", "customGeminiModels", "customOpenCodeModels", + "customPiModels", ] as const) { if (settings[key].length > 0) { patch[key] = settings[key] as never; @@ -429,6 +461,7 @@ export function getCustomModelsByProvider( cursor: getCustomModelsForProvider(settings, "cursor"), gemini: getCustomModelsForProvider(settings, "gemini"), opencode: getCustomModelsForProvider(settings, "opencode"), + pi: getCustomModelsForProvider(settings, "pi"), }; } @@ -514,7 +547,7 @@ export function resolveAppModelSelection( ): string { const customModelsForProvider = customModels[provider]; const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); - return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); + return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider) ?? ""; } export function getCustomModelOptionsByProvider( @@ -527,6 +560,7 @@ export function getCustomModelOptionsByProvider( cursor: getAppModelOptions("cursor", customModelsByProvider.cursor), gemini: getAppModelOptions("gemini", customModelsByProvider.gemini), opencode: getAppModelOptions("opencode", customModelsByProvider.opencode), + pi: getAppModelOptions("pi", customModelsByProvider.pi), }; } @@ -542,6 +576,8 @@ export function getProviderStartOptions( | "openCodeBinaryPath" | "openCodeServerPassword" | "openCodeServerUrl" + | "piAgentDir" + | "piBinaryPath" >, ): ProviderStartOptions | undefined { const providerOptions: ProviderStartOptions = { @@ -586,6 +622,14 @@ export function getProviderStartOptions( }, } : {}), + ...(settings.piBinaryPath || settings.piAgentDir + ? { + pi: { + ...(settings.piBinaryPath ? { binaryPath: settings.piBinaryPath } : {}), + ...(settings.piAgentDir ? { agentDir: settings.piAgentDir } : {}), + }, + } + : {}), }; return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; @@ -599,6 +643,7 @@ export function getCustomBinaryPathForProvider( | "cursorBinaryPath" | "geminiBinaryPath" | "openCodeBinaryPath" + | "piBinaryPath" >, provider: ProviderKind, ): string { @@ -613,6 +658,8 @@ export function getCustomBinaryPathForProvider( return settings.geminiBinaryPath; case "opencode": return settings.openCodeBinaryPath; + case "pi": + return settings.piBinaryPath; } } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 035ed8de..871ca6de 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -452,6 +452,8 @@ function getProviderStartOptionsCustomBinaryPath( return normalizeCustomBinaryPath(providerOptions?.opencode?.binaryPath); case "cursor": return normalizeCustomBinaryPath(providerOptions?.cursor?.binaryPath); + case "pi": + return normalizeCustomBinaryPath(providerOptions?.pi?.binaryPath); } } @@ -628,13 +630,17 @@ function mergeDynamicModelOptions(input: { } function skillMentionPrefix(provider: string): string { + if (provider === "pi") return "/skill:"; return provider === "claudeAgent" ? "/" : "$"; } function promptIncludesSkillMention(prompt: string, skillName: string, provider: string): boolean { - const prefix = escapeRegExp(skillMentionPrefix(provider)); - const pattern = new RegExp(`(^|\\s)${prefix}${escapeRegExp(skillName)}(?=\\s|$)`, "i"); - return pattern.test(prompt); + const escapedSkillName = escapeRegExp(skillName); + const prefixes = provider === "pi" ? ["/skill:"] : [skillMentionPrefix(provider)]; + return prefixes.some((prefix) => { + const pattern = new RegExp(`(^|\\s)${escapeRegExp(prefix)}${escapedSkillName}(?=\\s|$)`, "i"); + return pattern.test(prompt); + }); } const PROMPT_MENTION_NAME_REGEX = createComposerMentionTokenRegex({ @@ -1402,6 +1408,7 @@ export default function ChatView({ cursor: resolveHint("cursor"), gemini: resolveHint("gemini"), opencode: resolveHint("opencode"), + pi: resolveHint("pi"), }; }, [ activeProject?.defaultModelSelection, @@ -1433,6 +1440,14 @@ export default function ChatView({ binaryPath: settings.openCodeBinaryPath || null, }), ); + const piDynamicModelsQuery = useQuery( + providerModelsQueryOptions({ + provider: "pi", + binaryPath: settings.piBinaryPath || null, + agentDir: settings.piAgentDir || null, + enabled: selectedProvider === "pi" || lockedProvider === "pi" || isModelPickerOpen, + }), + ); const claudeDynamicAgentsQuery = useQuery( providerAgentsQueryOptions({ provider: "claudeAgent" }), ); @@ -1481,6 +1496,7 @@ export default function ChatView({ customModelsByProvider.opencode, composerModelHintByProvider.opencode, ), + pi: getAppModelOptions("pi", customModelsByProvider.pi, composerModelHintByProvider.pi), }; const result: Record< ProviderKind, @@ -1496,9 +1512,17 @@ export default function ChatView({ : { ...cursorDynamicModelsQuery.data, models: cursorRuntimeModels }, gemini: geminiModelsQuery.data, opencode: openCodeDynamicModelsQuery.data, + pi: piDynamicModelsQuery.data, }; - for (const provider of ["claudeAgent", "codex", "cursor", "gemini", "opencode"] as const) { + for (const provider of [ + "claudeAgent", + "codex", + "cursor", + "gemini", + "opencode", + "pi", + ] as const) { const dynamicModels = dynamicSources[provider]?.models; if (dynamicModels && dynamicModels.length > 0) { result[provider] = mergeDynamicModelOptions({ @@ -1528,6 +1552,7 @@ export default function ChatView({ customModelsByProvider, geminiModelsQuery.data, openCodeDynamicModelsQuery.data, + piDynamicModelsQuery.data, ]); const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadId, @@ -1544,6 +1569,7 @@ export default function ChatView({ cursor: cursorRuntimeModels, gemini: geminiModelsQuery.data?.models ?? [], opencode: openCodeDynamicModelsQuery.data?.models ?? [], + pi: piDynamicModelsQuery.data?.models ?? [], }), [ claudeDynamicModelsQuery.data?.models, @@ -1551,6 +1577,7 @@ export default function ChatView({ cursorRuntimeModels, geminiModelsQuery.data?.models, openCodeDynamicModelsQuery.data?.models, + piDynamicModelsQuery.data?.models, ], ); const providerModelsQueryByProvider = { @@ -1559,6 +1586,7 @@ export default function ChatView({ cursor: cursorDynamicModelsQuery, gemini: geminiModelsQuery, opencode: openCodeDynamicModelsQuery, + pi: piDynamicModelsQuery, } as const; const selectedRuntimeModel = useMemo( () => @@ -1582,12 +1610,28 @@ export default function ChatView({ ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; - const selectedModelSelection = useMemo( - () => buildModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), - [selectedModel, selectedModelOptionsForDispatch, selectedProvider], - ); + const draftModelSelectionForSelectedProvider = + composerDraft.modelSelectionByProvider[selectedProvider] ?? null; + const selectedModelSelection = useMemo(() => { + if (selectedProvider === "pi" && draftModelSelectionForSelectedProvider?.provider === "pi") { + return buildModelSelection( + selectedProvider, + draftModelSelectionForSelectedProvider.model, + selectedModelOptionsForDispatch ?? draftModelSelectionForSelectedProvider.options, + ); + } + return buildModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch); + }, [ + draftModelSelectionForSelectedProvider, + selectedModel, + selectedModelOptionsForDispatch, + selectedProvider, + ]); const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); - const selectedModelForPicker = selectedModel; + const selectedModelForPicker = + selectedModelSelection.provider === selectedProvider + ? selectedModelSelection.model + : selectedModel; const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByProvider[selectedProvider]; return currentOptions.some((option) => option.slug === selectedModelForPicker) @@ -1595,9 +1639,11 @@ export default function ChatView({ : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const persistedComposerModelSelection = - activeThread?.modelSelection ?? activeProject?.defaultModelSelection ?? null; - const draftModelSelectionForSelectedProvider = - composerDraft.modelSelectionByProvider[selectedProvider] ?? null; + sessionProvider && activeThread?.modelSelection.provider !== sessionProvider + ? activeProject?.defaultModelSelection?.provider === selectedProvider + ? activeProject.defaultModelSelection + : null + : (activeThread?.modelSelection ?? activeProject?.defaultModelSelection ?? null); const selectedProviderModelsQuery = providerModelsQueryByProvider[selectedProvider]; const providerModelsLoading = selectedProvider === "cursor" @@ -2252,6 +2298,8 @@ export default function ChatView({ composerSkillCwd !== null, }), ); + const canDiscoverProviderSkills = + selectedProvider === "pi" || supportsSkillDiscovery(providerComposerCapabilitiesQuery.data); const providerSkillsQuery = useQuery( providerSkillsQueryOptions({ provider: selectedProvider, @@ -2259,8 +2307,8 @@ export default function ChatView({ threadId, query: skillTriggerQuery, enabled: - isSkillTrigger && - supportsSkillDiscovery(providerComposerCapabilitiesQuery.data) && + (isSkillTrigger || selectedProvider === "pi") && + canDiscoverProviderSkills && composerSkillCwd !== null, }), ); @@ -5443,9 +5491,11 @@ export default function ChatView({ const title = buildPromptThreadTitleFallback(titleSeed); const threadCreateModelSelection: ModelSelection = buildModelSelection( selectedProviderForSend, - selectedModelForSend || - targetProjectDefaultModelSelectionForSend?.model || - DEFAULT_MODEL_BY_PROVIDER.codex, + selectedModelSelectionForSend.provider === selectedProviderForSend + ? selectedModelSelectionForSend.model + : selectedModelForSend || + targetProjectDefaultModelSelectionForSend?.model || + DEFAULT_MODEL_BY_PROVIDER.codex, selectedModelSelectionForSend.options, ); @@ -6859,7 +6909,12 @@ export default function ChatView({ providerPluginsQuery.isLoading || providerPluginsQuery.isFetching)) || (composerTriggerKind === "slash-command" && - (providerCommandsQuery.isLoading || providerCommandsQuery.isFetching)); + (providerCommandsQuery.isLoading || providerCommandsQuery.isFetching)) || + (composerTriggerKind === "skill" && + (providerComposerCapabilitiesQuery.isLoading || + providerComposerCapabilitiesQuery.isFetching || + providerSkillsQuery.isLoading || + providerSkillsQuery.isFetching)); const onPromptChange = useCallback( ( @@ -7716,7 +7771,7 @@ export default function ChatView({ activeThreadId={activeThread.id} activeThreadTitle={activeThreadDisplayTitle} activeThreadEntryPoint={terminalState.entryPoint} - activeProvider={activeThread.modelSelection.provider} + activeProvider={activeThread.session?.provider ?? activeThread.modelSelection.provider} activeProjectName={activeProjectDisplayName} threadBreadcrumbs={threadBreadcrumbs} isSidechat={Boolean(activeThread.sidechatSourceThreadId)} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 2c60f718..94c69b13 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -294,6 +294,18 @@ export const AntigravityIcon: Icon = (props) => ( ); +export const PiIcon: Icon = (props) => ( + + + + +); + export const OpenCodeIcon: Icon = (props) => ( diff --git a/apps/web/src/components/PluginLibrary.tsx b/apps/web/src/components/PluginLibrary.tsx index 5f7b4385..a75233ec 100644 --- a/apps/web/src/components/PluginLibrary.tsx +++ b/apps/web/src/components/PluginLibrary.tsx @@ -26,7 +26,7 @@ import { SiStripe, SiVercel, } from "react-icons/si"; -import { ClaudeAI, CursorIcon, Gemini, OpenCodeIcon } from "./Icons"; +import { ClaudeAI, CursorIcon, Gemini, OpenCodeIcon, PiIcon } from "./Icons"; import { useStore } from "~/store"; import { buildPluginSearchBlob, @@ -82,6 +82,7 @@ const PROVIDER_ICON: Record cursor: CursorIcon, gemini: Gemini, opencode: OpenCodeIcon, + pi: PiIcon, }; const PROVIDER_DISCOVERY_ORDER: ReadonlyArray = [ "codex", @@ -89,6 +90,7 @@ const PROVIDER_DISCOVERY_ORDER: ReadonlyArray = [ "cursor", "gemini", "opencode", + "pi", ]; const KNOWN_PLUGIN_BRANDS: Record = { canva: { icon: SiCanva, color: "#00C4CC" }, @@ -390,6 +392,7 @@ export function PluginLibrary() { const cursorCapabilitiesQuery = useQuery(providerComposerCapabilitiesQueryOptions("cursor")); const geminiCapabilitiesQuery = useQuery(providerComposerCapabilitiesQueryOptions("gemini")); const openCodeCapabilitiesQuery = useQuery(providerComposerCapabilitiesQueryOptions("opencode")); + const piCapabilitiesQuery = useQuery(providerComposerCapabilitiesQueryOptions("pi")); const providerCapabilities = useMemo>( () => ({ @@ -413,6 +416,10 @@ export function PluginLibrary() { plugins: supportsPluginDiscovery(openCodeCapabilitiesQuery.data), skills: supportsSkillDiscovery(openCodeCapabilitiesQuery.data), }, + pi: { + plugins: supportsPluginDiscovery(piCapabilitiesQuery.data), + skills: supportsSkillDiscovery(piCapabilitiesQuery.data), + }, }), [ claudeCapabilitiesQuery.data, @@ -420,6 +427,7 @@ export function PluginLibrary() { cursorCapabilitiesQuery.data, geminiCapabilitiesQuery.data, openCodeCapabilitiesQuery.data, + piCapabilitiesQuery.data, ], ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bdc95abd..f41784f3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -52,7 +52,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, type OrchestrationReadModel, PROVIDER_DISPLAY_NAMES, @@ -63,6 +62,7 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { isGenericChatThreadTitle } from "@t3tools/shared/chatThreads"; +import { getDefaultModel } from "@t3tools/shared/model"; import { resolveThreadWorkspaceCwd } from "@t3tools/shared/threadEnvironment"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useLocation, useNavigate, useParams, useSearch } from "@tanstack/react-router"; @@ -112,7 +112,7 @@ import { dispatchThreadRename } from "../lib/threadRename"; import { quotePosixShellArgument } from "../lib/shellQuote"; import { DEFAULT_THREAD_TERMINAL_ID, type SidebarThreadSummary, type Thread } from "../types"; import { shouldRenderTerminalWorkspace } from "./ChatView.logic"; -import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon } from "./Icons"; +import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon, PiIcon } from "./Icons"; import { AppNavigationButtons } from "./AppNavigationButtons"; import { ProjectSidebarIcon } from "./ProjectSidebarIcon"; import { ThreadPinToggleButton } from "./ThreadPinToggleButton"; @@ -370,6 +370,9 @@ function ProviderGlyph({ provider, className }: { provider: ProviderKind; classN ); } + if (provider === "pi") { + return ; + } return ; } function WorktreeBadgeGlyph({ className }: { className?: string }) { @@ -1880,7 +1883,7 @@ export default function Sidebar() { createWorkspaceRootIfMissing: options.createIfMissing === true, defaultModelSelection: { provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + model: getDefaultModel("codex"), }, createdAt, }); @@ -2052,13 +2055,19 @@ export default function Sidebar() { throw new Error("The target project could not be resolved."); } + const providerDefaultModel = getDefaultModel(provider); const modelSelection = activeProject.defaultModelSelection?.provider === provider ? activeProject.defaultModelSelection - : { - provider, - model: DEFAULT_MODEL_BY_PROVIDER[provider], - }; + : providerDefaultModel + ? { + provider, + model: providerDefaultModel, + } + : null; + if (!modelSelection) { + throw new Error("Select a Pi model before importing a Pi thread."); + } const threadId = newThreadId(); const createdAt = new Date().toISOString(); const trimmedExternalId = externalId.trim(); @@ -4072,7 +4081,7 @@ export default function Sidebar() { ) : showThreadProviderAvatar ? ( ) : showThreadProviderAvatar ? ( ) : props.provider === "opencode" ? ( + ) : props.provider === "pi" ? ( + ) : ( )} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index b18f9dba..09c259de 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -35,7 +35,7 @@ import { readNativeApi } from "~/nativeApi"; import { resolveEditorIcon } from "../../editorMetadata"; import { usePreferredEditor } from "../../editorPreferences"; import { useIsDisposableThread } from "~/hooks/useIsDisposableThread"; -import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon, PiIcon } from "../Icons"; import { gitWorkingTreeDiffQueryOptions } from "~/lib/gitReactQuery"; import { summarizePatchStats } from "~/lib/diffRendering"; @@ -201,6 +201,9 @@ export const ChatHeader = memo(function ChatHeader({ if (provider === "opencode") { return ; } + if (provider === "pi") { + return ; + } if (provider === "codex") { return ; } diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index f18e10fa..05d5da00 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -1,4 +1,5 @@ -import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts"; +import { ModelSelection, ThreadId } from "@t3tools/contracts"; +import { getDefaultModel } from "@t3tools/shared/model"; import "../../index.css"; import { page } from "vitest/browser"; @@ -20,7 +21,7 @@ async function mountMenu(props?: { const draftsByThreadId = {} as ReturnType< typeof useComposerDraftStore.getState >["draftsByThreadId"]; - const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; + const model = props?.modelSelection?.model ?? getDefaultModel(provider) ?? getDefaultModel("codex"); draftsByThreadId[threadId] = { prompt: props?.prompt ?? "", diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 5dd5ae03..01580381 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -384,7 +384,9 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { {props.isLoading ? props.triggerKind === "mention" ? "Searching mentions..." - : "Loading commands..." + : props.triggerKind === "skill" + ? "Loading skills..." + : "Loading commands..." : (props.emptyStateText ?? (props.triggerKind === "mention" ? "No matching plugin or file." diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index aca40bcd..67f32136 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -92,6 +92,11 @@ export function ContextWindowMeter(props: { {display.tokenUsageLabel} tokens used so far )} + {usage.maxTokens !== null ? ( +
+ Model window: {formatContextWindowTokens(usage.maxTokens)} tokens +
+ ) : null} {pendingWindowLabel ? (
Next turn: {pendingWindowLabel}
) : null} diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 952fccba..89c9c15c 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -38,6 +38,14 @@ const MODEL_OPTIONS_BY_PROVIDER = { upstreamProviderName: "OpenAI", }, ], + pi: [ + { + slug: "anthropic/claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + upstreamProviderId: "anthropic", + upstreamProviderName: "Anthropic", + }, + ], } as const satisfies Record>; const MANY_OPENCODE_MODELS = Array.from({ length: 16 }, (_, index) => ({ @@ -84,6 +92,21 @@ const CURSOR_FAVORITE_SORT_MODELS = [ }, ] satisfies ReadonlyArray; +const PI_FAVORITE_SORT_MODELS = [ + { + slug: "anthropic/claude-pi-favorite-sort" as ModelSlug, + name: "Claude Pi Favorite Sort", + upstreamProviderId: "anthropic", + upstreamProviderName: "Anthropic", + }, + { + slug: "openai/gpt-pi-favorite-sort" as ModelSlug, + name: "GPT Pi Favorite Sort", + upstreamProviderId: "openai", + upstreamProviderName: "OpenAI", + }, +] satisfies ReadonlyArray; + async function mountPicker(props: { provider: ProviderKind; model: ModelSlug; @@ -376,6 +399,46 @@ describe("ProviderModelPicker", () => { } }); + it("shows favourited Pi models in their own top category", async () => { + const mounted = await mountPicker({ + provider: "pi", + model: "anthropic/claude-pi-favorite-sort", + lockedProvider: "pi", + modelOptionsByProvider: { + ...MODEL_OPTIONS_BY_PROVIDER, + pi: PI_FAVORITE_SORT_MODELS, + }, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text.indexOf("Anthropic")).toBeLessThan(text.indexOf("OpenAI")); + }); + + await page.getByRole("button", { name: "Add GPT Pi Favorite Sort to favourites" }).click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text.indexOf("Favourites")).toBeLessThan(text.indexOf("Anthropic")); + expect(text.indexOf("GPT Pi Favorite Sort")).toBeGreaterThan(text.indexOf("Favourites")); + expect(text.indexOf("GPT Pi Favorite Sort")).toBeLessThan(text.indexOf("Anthropic")); + }); + await expect + .element(page.getByRole("menuitemradio", { name: "GPT Pi Favorite Sort" })) + .toBeInTheDocument(); + expect( + Array.from(document.querySelectorAll('[role="menuitemradio"]')).filter((element) => + element.textContent?.includes("GPT Pi Favorite Sort"), + ), + ).toHaveLength(1); + } finally { + await mounted.cleanup(); + } + }); + it("shows a loading skeleton instead of fallback models for loading providers", async () => { const mounted = await mountPicker({ provider: "cursor", diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 373ec271..024eb5b8 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -23,7 +23,7 @@ import { MenuSubTrigger, MenuTrigger, } from "../ui/menu"; -import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon, PiIcon } from "../Icons"; import { cn } from "~/lib/utils"; import { PickerPanelShell } from "./PickerPanelShell"; import { PickerTriggerButton } from "./PickerTriggerButton"; @@ -52,6 +52,7 @@ const PROVIDER_ICON_BY_PROVIDER: Record = { cursor: CursorIcon, gemini: Gemini, opencode: OpenCodeIcon, + pi: PiIcon, }; function resolveLiveProviderAvailability(provider: ServerProviderStatus | undefined): { @@ -92,7 +93,7 @@ function providerIconClassName( provider: ProviderKind | ProviderPickerKind, fallbackClassName: string, ): string { - return provider === "claudeAgent" || provider === "gemini" + return provider === "claudeAgent" || provider === "gemini" || provider === "pi" ? "text-foreground" : fallbackClassName; } @@ -101,12 +102,13 @@ const SEARCHABLE_MODEL_PICKER_THRESHOLD = 15; const FAVORITE_MODEL_STORAGE_KEYS = { cursor: "dpcode:cursor-favourite-models:v1", opencode: "dpcode:opencode-favourite-models:v1", + pi: "dpcode:pi-favourite-models:v1", } as const; const FavoriteModelSlugs = Schema.Array(Schema.String); type FavoriteModelProvider = keyof typeof FAVORITE_MODEL_STORAGE_KEYS; function supportsModelFavorites(provider: ProviderKind): provider is FavoriteModelProvider { - return provider === "cursor" || provider === "opencode"; + return provider === "cursor" || provider === "opencode" || provider === "pi"; } // Keeps persisted favorite slugs compact and stable while preserving the user's order. @@ -180,6 +182,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { [], FavoriteModelSlugs, ); + const [piFavoriteModelSlugs, setPiFavoriteModelSlugs] = useLocalStorage( + FAVORITE_MODEL_STORAGE_KEYS.pi, + [], + FavoriteModelSlugs, + ); const deferredModelSearchQuery = useDeferredValue(modelSearchQuery); const activeProvider = props.lockedProvider ?? props.provider; const isMenuOpen = open ?? uncontrolledMenuOpen; @@ -191,12 +198,17 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { () => new Set(cursorFavoriteModelSlugs), [cursorFavoriteModelSlugs], ); + const piFavoriteModelSlugSet = useMemo( + () => new Set(piFavoriteModelSlugs), + [piFavoriteModelSlugs], + ); const favoriteModelSlugSets = useMemo( () => ({ cursor: cursorFavoriteModelSlugSet, opencode: openCodeFavoriteModelSlugSet, + pi: piFavoriteModelSlugSet, }), - [cursorFavoriteModelSlugSet, openCodeFavoriteModelSlugSet], + [cursorFavoriteModelSlugSet, openCodeFavoriteModelSlugSet, piFavoriteModelSlugSet], ); const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; const selectedModelLabel = resolveSelectedModelLabel({ @@ -232,10 +244,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const toggleFavoriteModel = useCallback( (provider: FavoriteModelProvider, slug: string) => { const setFavoriteModelSlugs = - provider === "cursor" ? setCursorFavoriteModelSlugs : setOpenCodeFavoriteModelSlugs; + provider === "cursor" + ? setCursorFavoriteModelSlugs + : provider === "pi" + ? setPiFavoriteModelSlugs + : setOpenCodeFavoriteModelSlugs; setFavoriteModelSlugs((current) => toggleFavoriteModelSlug(current, slug)); }, - [setCursorFavoriteModelSlugs, setOpenCodeFavoriteModelSlugs], + [setCursorFavoriteModelSlugs, setOpenCodeFavoriteModelSlugs, setPiFavoriteModelSlugs], ); const renderModelRadioGroup = (provider: ProviderKind) => { @@ -254,7 +270,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const providerOptions = props.modelOptionsByProvider[provider]; const shouldShowSearch = - (provider === "opencode" || provider === "cursor") && + (provider === "opencode" || provider === "cursor" || provider === "pi") && providerOptions.length >= SEARCHABLE_MODEL_PICKER_THRESHOLD; const normalizedModelSearchQuery = deferredModelSearchQuery.trim().toLowerCase(); const filteredOptions = diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 9769c225..4dd1bac9 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -39,7 +39,14 @@ function ClaudeTraitsPickerHarness(props: { selectedProvider: "claudeAgent", threadModelSelection: props.fallbackModelSelection, projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, }); const handlePromptChange = useCallback( (nextPrompt: string) => { @@ -560,7 +567,14 @@ function OpenCodeTraitsPickerHarness(props: { selectedProvider: "opencode", threadModelSelection: props.fallbackModelSelection, projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, }); const handlePromptChange = useCallback( (nextPrompt: string) => { diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index f3af3faa..7bb07bac 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -95,9 +95,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ? (geminiModelOptionsFromEffortValue(nextOption.value) ?? {}) : provider === "opencode" ? { variant: nextOption.value } - : provider === "codex" - ? { reasoningEffort: nextOption.value } - : { effort: nextOption.value }; + : provider === "pi" + ? { thinkingLevel: nextOption.value } + : provider === "codex" + ? { reasoningEffort: nextOption.value } + : { effort: nextOption.value }; setProviderModelOptions( threadId, provider, diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 441f65ac..c3ba1b63 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -20,6 +20,7 @@ import { normalizeClaudeModelOptions, normalizeGeminiModelOptions, normalizeOpenCodeModelOptions, + normalizePiModelOptions, resolveLabeledOptionValue, trimOrNull, } from "@t3tools/shared/model"; @@ -197,6 +198,12 @@ function getProviderStateFromCapabilities( normalizedOptions = normalizeOpenCodeModelOptions(providerOptions); break; } + case "pi": { + const providerOptions = modelOptions?.pi; + rawEffort = trimOrNull(providerOptions?.thinkingLevel); + normalizedOptions = normalizePiModelOptions(providerOptions); + break; + } } const draftEffort = trimOrNull(rawEffort); @@ -254,6 +261,11 @@ const composerProviderRegistry: Record = { renderTraitsMenuContent: (input) => renderTraitsMenuContentForProvider("opencode", input), renderTraitsPicker: (input) => renderTraitsPickerForProvider("opencode", input), }, + pi: { + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: (input) => renderTraitsMenuContentForProvider("pi", input), + renderTraitsPicker: (input) => renderTraitsPickerForProvider("pi", input), + }, }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { diff --git a/apps/web/src/components/chat/composerTraits.ts b/apps/web/src/components/chat/composerTraits.ts index 5ebfe7f3..5e954d77 100644 --- a/apps/web/src/components/chat/composerTraits.ts +++ b/apps/web/src/components/chat/composerTraits.ts @@ -9,6 +9,7 @@ import { type CursorModelOptions, type GeminiModelOptions, type OpenCodeModelOptions, + type PiModelOptions, type ProviderKind, type ProviderModelDescriptor, } from "@t3tools/contracts"; @@ -44,6 +45,9 @@ function getRawEffort( if (provider === "opencode") { return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant); } + if (provider === "pi") { + return trimOrNull((modelOptions as PiModelOptions | undefined)?.thinkingLevel); + } const caps = getModelCapabilities(provider, model); return getGeminiThinkingSelectionValue(caps, modelOptions as GeminiModelOptions | undefined); } diff --git a/apps/web/src/components/chat/runtimeModelCapabilities.ts b/apps/web/src/components/chat/runtimeModelCapabilities.ts index 4f8c11f9..be25e460 100644 --- a/apps/web/src/components/chat/runtimeModelCapabilities.ts +++ b/apps/web/src/components/chat/runtimeModelCapabilities.ts @@ -91,7 +91,10 @@ export function getRuntimeAwareModelCapabilities(input: { })) ?? staticCapabilities.contextWindowOptions; const runtimeEfforts = input.runtimeModel?.supportedReasoningEfforts; if ( - (input.provider !== "codex" && input.provider !== "cursor" && input.provider !== "opencode") || + (input.provider !== "codex" && + input.provider !== "cursor" && + input.provider !== "opencode" && + input.provider !== "pi") || !runtimeEfforts || runtimeEfforts.length === 0 ) { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index e40311bf..80d6eafa 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -126,7 +126,7 @@ function resetComposerDraftStore() { } function modelSelection( - provider: "codex" | "claudeAgent" | "opencode", + provider: ModelSelection["provider"], model: string, options?: ModelSelection["options"], ): ModelSelection { @@ -1111,7 +1111,14 @@ describe("composerDraftStore modelSelection", () => { selectedProvider: "opencode", threadModelSelection: modelSelection("opencode", "opencode/gpt-5-nano"), projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, availableModelOptionsByProvider: { opencode: [{ slug: "opencode/gpt-5-nano", name: "GPT-5 Nano" }], }, @@ -1129,7 +1136,14 @@ describe("composerDraftStore modelSelection", () => { selectedProvider: "opencode", threadModelSelection: modelSelection("opencode", "openai/gpt-5.4"), projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, availableModelOptionsByProvider: { opencode: [ { slug: "openai/gpt-5-codex", name: "GPT-5-Codex" }, @@ -1152,7 +1166,14 @@ describe("composerDraftStore modelSelection", () => { selectedProvider: "opencode", threadModelSelection: null, projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, availableModelOptionsByProvider: { opencode: [ { slug: "opencode/gpt-5-nano", name: "GPT-5 Nano" }, @@ -1164,6 +1185,36 @@ describe("composerDraftStore modelSelection", () => { expect(state.selectedModel).toBe("opencode/gpt-5-nano"); }); + it("preserves a selected Pi custom model when discovery omits it", () => { + const state = deriveEffectiveComposerModelState({ + draft: { + modelSelectionByProvider: { + pi: modelSelection("pi", "openai/gpt-5.5"), + }, + activeProvider: "pi", + }, + selectedProvider: "pi", + threadModelSelection: null, + projectModelSelection: null, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, + availableModelOptionsByProvider: { + pi: [ + { slug: "openai/gpt-5.1", name: "GPT-5.1" }, + { slug: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + ], + }, + }); + + expect(state.selectedModel).toBe("openai/gpt-5.5"); + }); + it("updates only the draft when sticky persistence is disabled", () => { const store = useComposerDraftStore.getState(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 941943e7..10d37e48 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -5,6 +5,7 @@ import { type GeminiThinkingBudget, type GeminiThinkingLevel, type ModelSlug, + type PiThinkingLevel, ModelSelection, OrchestrationThreadPullRequest, ProjectId, @@ -670,7 +671,8 @@ function normalizeProviderKind(value: unknown): ProviderKind | null { value === "claudeAgent" || value === "cursor" || value === "gemini" || - value === "opencode" + value === "opencode" || + value === "pi" ? value : null; } @@ -731,6 +733,14 @@ function makeModelSelection( ? { options: options as Extract["options"] } : {}), }; + case "pi": + return { + provider, + model, + ...(options + ? { options: options as Extract["options"] } + : {}), + }; } } @@ -760,6 +770,10 @@ function normalizeProviderModelOptions( candidate?.opencode && typeof candidate.opencode === "object" ? (candidate.opencode as Record) : null; + const piCandidate = + candidate?.pi && typeof candidate.pi === "object" + ? (candidate.pi as Record) + : null; const codexReasoningEffort: CodexReasoningEffort | undefined = codexCandidate?.reasoningEffort === "low" || @@ -890,7 +904,17 @@ function normalizeProviderModelOptions( ...(openCodeAgent !== undefined ? { agent: openCodeAgent } : {}), } : undefined; - if (!codex && !claude && !cursor && !gemini && !opencode) { + const piThinkingLevel: PiThinkingLevel | undefined = + piCandidate?.thinkingLevel === "off" || + piCandidate?.thinkingLevel === "minimal" || + piCandidate?.thinkingLevel === "low" || + piCandidate?.thinkingLevel === "medium" || + piCandidate?.thinkingLevel === "high" || + piCandidate?.thinkingLevel === "xhigh" + ? piCandidate.thinkingLevel + : undefined; + const pi = piThinkingLevel !== undefined ? { thinkingLevel: piThinkingLevel } : undefined; + if (!codex && !claude && !cursor && !gemini && !opencode && !pi) { return null; } return { @@ -899,6 +923,7 @@ function normalizeProviderModelOptions( ...(cursor ? { cursor } : {}), ...(gemini ? { gemini } : {}), ...(opencode ? { opencode } : {}), + ...(pi ? { pi } : {}), }; } @@ -948,7 +973,9 @@ function normalizeModelSelection( ? modelOptions?.cursor : provider === "opencode" ? modelOptions?.opencode - : undefined; + : provider === "pi" + ? modelOptions?.pi + : undefined; return makeModelSelection(provider, model, options); } @@ -1006,14 +1033,21 @@ function legacyToModelSelectionByProvider( const result: Partial> = {}; // Add entries from the options bag (for non-active providers) if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "gemini", "opencode"] as const) { + for (const provider of [ + "codex", + "claudeAgent", + "cursor", + "gemini", + "opencode", + "pi", + ] as const) { const options = modelOptions[provider]; if (options && Object.keys(options).length > 0) { - result[provider] = makeModelSelection( - provider, - modelSelection?.provider === provider ? modelSelection.model : getDefaultModel(provider), - options, - ); + const model = + modelSelection?.provider === provider ? modelSelection.model : getDefaultModel(provider); + if (model) { + result[provider] = makeModelSelection(provider, model, options); + } } } } @@ -1068,6 +1102,7 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, ) : null; + const unlistedDraftModel = input.selectedProvider === "pi" ? selectedDraftModel : null; const selectedModel = resolveAvailableModel(activeSelection?.model) ?? resolveAvailableModel( @@ -1083,9 +1118,11 @@ export function deriveEffectiveComposerModelState(input: { resolveAvailableModel(selectedDraftModel) ?? persistedThreadModel ?? persistedProjectModel ?? + unlistedDraftModel ?? input.availableModelOptionsByProvider?.[input.selectedProvider]?.[0]?.slug ?? selectedDraftModel ?? - baseModel; + baseModel ?? + ("" as ModelSlug); const modelOptions = modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? providerModelOptionsFromSelection(input.threadModelSelection) ?? @@ -1110,7 +1147,7 @@ export function resolvePreferredComposerModelSelection(input: { defaultProvider?: ProviderKind | null | undefined; }): ModelSelection { const draftProviderWithSelection = - (["codex", "claudeAgent", "cursor", "gemini", "opencode"] as const).find( + (["codex", "claudeAgent", "cursor", "gemini", "opencode", "pi"] as const).find( (provider) => input.draft?.modelSelectionByProvider?.[provider] !== undefined, ) ?? null; const preferredProvider = @@ -1129,8 +1166,8 @@ export function resolvePreferredComposerModelSelection(input: { (input.projectModelSelection?.provider === preferredProvider ? input.projectModelSelection : null) ?? { - provider: preferredProvider, - model: getDefaultModel(preferredProvider), + provider: preferredProvider === "pi" ? "codex" : preferredProvider, + model: getDefaultModel(preferredProvider) ?? getDefaultModel("codex"), } ); } @@ -2702,9 +2739,13 @@ export const useComposerDraftStore = create()( const nextMap = { ...base.modelSelectionByProvider }; const currentForProvider = nextMap[normalizedProvider]; if (providerOpts) { + const nextModel = currentForProvider?.model ?? fallbackModel; + if (!nextModel) { + return state; + } nextMap[normalizedProvider] = makeModelSelection( normalizedProvider, - currentForProvider?.model ?? fallbackModel, + nextModel, providerOpts, ); } else if (currentForProvider?.options) { @@ -2722,7 +2763,10 @@ export const useComposerDraftStore = create()( const stickyBase = nextStickyMap[normalizedProvider] ?? base.modelSelectionByProvider[normalizedProvider] ?? - makeModelSelection(normalizedProvider, fallbackModel); + (fallbackModel ? makeModelSelection(normalizedProvider, fallbackModel) : null); + if (!stickyBase) { + return state; + } if (providerOpts) { nextStickyMap[normalizedProvider] = makeModelSelection( normalizedProvider, diff --git a/apps/web/src/hooks/useComposerSlashCommands.ts b/apps/web/src/hooks/useComposerSlashCommands.ts index e00aacf5..504d65ce 100644 --- a/apps/web/src/hooks/useComposerSlashCommands.ts +++ b/apps/web/src/hooks/useComposerSlashCommands.ts @@ -164,11 +164,6 @@ export function useComposerSlashCommands(input: { : "An error occurred while compacting context.", }); }); - toastManager.add({ - type: "success", - title: "Compaction started", - description: "The current provider is compacting the thread context.", - }); return true; } catch (error) { toastManager.add({ diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 4a9dc5d0..40093038 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,4 +1,5 @@ -import { type ProjectId, ThreadId, DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; +import { type ProjectId, ThreadId } from "@t3tools/contracts"; +import { getDefaultModel } from "@t3tools/shared/model"; import { useNavigate } from "@tanstack/react-router"; import { useCallback } from "react"; import { useAppSettings } from "../appSettings"; @@ -42,9 +43,13 @@ export function useHandleNewThread() { if (!options?.provider) { return; } + const defaultModel = getDefaultModel(options.provider); + if (!defaultModel) { + return; + } setModelSelection(threadId, { provider: options.provider, - model: DEFAULT_MODEL_BY_PROVIDER[options.provider], + model: defaultModel, }); }; const restoreComposerDraft = ( diff --git a/apps/web/src/lib/providerDiscoveryReactQuery.ts b/apps/web/src/lib/providerDiscoveryReactQuery.ts index 4a98613d..23354464 100644 --- a/apps/web/src/lib/providerDiscoveryReactQuery.ts +++ b/apps/web/src/lib/providerDiscoveryReactQuery.ts @@ -56,8 +56,12 @@ export const providerDiscoveryQueryKeys = { ["provider-discovery", "plugins", provider, cwd] as const, plugin: (provider: ProviderKind, marketplacePath: string, pluginName: string) => ["provider-discovery", "plugin", provider, marketplacePath, pluginName] as const, - models: (provider: ProviderKind, binaryPath: string | null, apiEndpoint: string | null) => - ["provider-discovery", "models", provider, binaryPath, apiEndpoint] as const, + models: ( + provider: ProviderKind, + binaryPath: string | null, + apiEndpoint: string | null, + agentDir: string | null, + ) => ["provider-discovery", "models", provider, binaryPath, apiEndpoint, agentDir] as const, agents: (provider: ProviderKind) => ["provider-discovery", "agents", provider] as const, }; @@ -128,6 +132,7 @@ export function providerModelsQueryOptions(input: { provider: ProviderKind; binaryPath?: string | null; apiEndpoint?: string | null; + agentDir?: string | null; enabled?: boolean; }) { return queryOptions({ @@ -135,6 +140,7 @@ export function providerModelsQueryOptions(input: { input.provider, input.binaryPath ?? null, input.apiEndpoint ?? null, + input.agentDir ?? null, ), queryFn: async () => { const api = ensureNativeApi(); @@ -142,6 +148,7 @@ export function providerModelsQueryOptions(input: { provider: input.provider, ...(input.binaryPath ? { binaryPath: input.binaryPath } : {}), ...(input.apiEndpoint ? { apiEndpoint: input.apiEndpoint } : {}), + ...(input.agentDir ? { agentDir: input.agentDir } : {}), }); }, enabled: input.enabled ?? true, diff --git a/apps/web/src/lib/threadHandoff.test.ts b/apps/web/src/lib/threadHandoff.test.ts index 867f3d7a..15178761 100644 --- a/apps/web/src/lib/threadHandoff.test.ts +++ b/apps/web/src/lib/threadHandoff.test.ts @@ -12,30 +12,42 @@ describe("threadHandoff", () => { "cursor", "gemini", "opencode", + "pi", ]); expect(resolveAvailableHandoffTargetProviders("claudeAgent")).toEqual([ "codex", "cursor", "gemini", "opencode", + "pi", ]); expect(resolveAvailableHandoffTargetProviders("cursor")).toEqual([ "codex", "claudeAgent", "gemini", "opencode", + "pi", ]); expect(resolveAvailableHandoffTargetProviders("gemini")).toEqual([ "codex", "claudeAgent", "cursor", "opencode", + "pi", ]); expect(resolveAvailableHandoffTargetProviders("opencode")).toEqual([ "codex", "claudeAgent", "cursor", "gemini", + "pi", + ]); + expect(resolveAvailableHandoffTargetProviders("pi")).toEqual([ + "codex", + "claudeAgent", + "cursor", + "gemini", + "opencode", ]); }); diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts index 0e320016..6163d881 100644 --- a/apps/web/src/lib/threadHandoff.ts +++ b/apps/web/src/lib/threadHandoff.ts @@ -1,5 +1,4 @@ import { - DEFAULT_MODEL_BY_PROVIDER, EventId, MessageId, type OrchestrationThreadActivity, @@ -8,6 +7,7 @@ import { type ProviderKind, type ThreadHandoffImportedMessage, } from "@t3tools/contracts"; +import { getDefaultModel } from "@t3tools/shared/model"; import { type Thread } from "../types"; import { stripEmbeddedAssistantSelections } from "./assistantSelections"; import { randomUUID } from "./utils"; @@ -18,6 +18,7 @@ const HANDOFF_PROVIDER_ORDER: ReadonlyArray = [ "cursor", "gemini", "opencode", + "pi", ]; const IMPORTABLE_THREAD_ACTIVITY_KINDS = new Set([ "account.rate-limits.updated", @@ -148,8 +149,12 @@ export function resolveThreadHandoffModelSelection(input: { if (input.projectDefaultModelSelection?.provider === input.targetProvider) { return input.projectDefaultModelSelection; } + const defaultModel = getDefaultModel(input.targetProvider); + if (!defaultModel) { + throw new Error("Select a Pi model before handing off to Pi."); + } return { provider: input.targetProvider, - model: DEFAULT_MODEL_BY_PROVIDER[input.targetProvider], + model: defaultModel, }; } diff --git a/apps/web/src/providerModelOptions.ts b/apps/web/src/providerModelOptions.ts index ff0a04d9..5288b9f7 100644 --- a/apps/web/src/providerModelOptions.ts +++ b/apps/web/src/providerModelOptions.ts @@ -11,6 +11,8 @@ import type { ModelSelection, OpenCodeModelOptions, OpenCodeModelSelection, + PiModelOptions, + PiModelSelection, ProviderKind, ProviderModelOptions, } from "@t3tools/contracts"; @@ -48,7 +50,7 @@ export function formatProviderModelOptionName(input: { return trimmedSlug; } - if (input.provider === "opencode") { + if (input.provider === "opencode" || input.provider === "pi") { const modelIdentifier = trimmedSlug.includes("/") ? trimmedSlug.slice(trimmedSlug.lastIndexOf("/") + 1) : trimmedSlug; @@ -165,10 +167,16 @@ export function buildNextProviderOptions( ...patch, } as GeminiModelOptions; } + if (provider === "opencode") { + return { + ...(modelOptions as OpenCodeModelOptions | undefined), + ...patch, + } as OpenCodeModelOptions; + } return { - ...(modelOptions as OpenCodeModelOptions | undefined), + ...(modelOptions as PiModelOptions | undefined), ...patch, - } as OpenCodeModelOptions; + } as PiModelOptions; } export function buildModelSelection( @@ -196,6 +204,11 @@ export function buildModelSelection( model: string, options?: OpenCodeModelOptions | null | undefined, ): OpenCodeModelSelection; +export function buildModelSelection( + provider: "pi", + model: string, + options?: PiModelOptions | null | undefined, +): PiModelSelection; export function buildModelSelection( provider: ProviderKind, model: string, @@ -247,5 +260,13 @@ export function buildModelSelection( options: options as OpenCodeModelOptions, } : { provider, model }; + case "pi": + return options + ? { + provider, + model, + options: options as PiModelOptions, + } + : { provider, model }; } } diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 70d46afa..97cf6a80 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -28,7 +28,7 @@ import { Schema } from "effect"; import ChatView from "../components/ChatView"; import BrowserPanel from "../components/BrowserPanel"; -import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon } from "../components/Icons"; +import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon, PiIcon } from "../components/Icons"; import { ChatPaneDropOverlay } from "../components/chat-drop-overlay/ChatPaneDropOverlay"; import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; import { @@ -657,6 +657,9 @@ function PickerProviderGlyph(props: { provider: ProviderKind; className?: string /> ); } + if (props.provider === "pi") { + return