diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index dec23e49d6..4f831ba710 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -198,7 +198,11 @@ function resolveExecutableOnPath(command: string, env: NodeJS.ProcessEnv = proce if (!trimmed) return null; const lookup = process.platform === "win32" ? { command: "where.exe", args: [trimmed] } - : { command: env.SHELL?.trim() || "/bin/sh", args: ["-lc", `command -v ${shellEscapeArg(trimmed)}`] }; + // `-l` lets a user's shell profile replace PATH (notably on macOS, where + // zsh can restore Homebrew's PATH ahead of a lane-provided executable). + // Resolution must honor the environment we pass to the PTY, so use a + // non-login shell and let the caller supply the already-resolved PATH. + : { command: env.SHELL?.trim() || "/bin/sh", args: ["-c", `command -v ${shellEscapeArg(trimmed)}`] }; const result = spawnSync(lookup.command, lookup.args, { encoding: "utf8", env, @@ -327,7 +331,7 @@ const TOOL_SPECS: ToolSpec[] = [ additionalProperties: false, properties: { laneId: { type: "string", minLength: 1 }, - provider: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "shell"] }, + provider: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "pi", "shell"] }, permissionMode: { type: "string", enum: ["default", "auto", "plan", "edit", "full-auto", "config-toml"], default: "default" }, title: { type: "string" }, initialInput: { type: "string" }, @@ -1693,7 +1697,7 @@ function parseCliSessionProvider(value: unknown): LaunchProfile { if (!isLaunchProfile(provider)) { throw new JsonRpcError( JsonRpcErrorCode.invalidParams, - "provider must be one of claude, codex, cursor, droid, opencode, or shell", + "provider must be one of claude, codex, cursor, droid, opencode, pi, or shell", ); } return provider; @@ -2773,7 +2777,7 @@ function scopeBuiltInBrowserAdeActionArgs( } const EXTERNAL_SESSION_AUTH_FIND_LIMIT = 500; -const EXTERNAL_SESSION_PROVIDER_NAMES = new Set(["claude", "codex", "cursor", "droid", "opencode"]); +const EXTERNAL_SESSION_PROVIDER_NAMES = new Set(["claude", "codex", "cursor", "droid", "opencode", "pi"]); function isExternalSessionProviderName(value: string | null): value is ExternalSessionProvider { return Boolean(value && EXTERNAL_SESSION_PROVIDER_NAMES.has(value)); @@ -2870,7 +2874,7 @@ function scopeExternalSessionsListArgs( function externalSessionImportUsesSourceRunCwd(provider: ExternalSessionProvider, mode: string): boolean { if (mode === "resume") return provider !== "codex"; - if (mode === "fork") return provider === "opencode"; + if (mode === "fork") return provider === "opencode" || provider === "pi"; return false; } diff --git a/apps/ade-cli/src/services/agentRegistry.test.ts b/apps/ade-cli/src/services/agentRegistry.test.ts index f92aebe58a..44405af42e 100644 --- a/apps/ade-cli/src/services/agentRegistry.test.ts +++ b/apps/ade-cli/src/services/agentRegistry.test.ts @@ -68,6 +68,15 @@ describe("classifyAgentCliError", () => { }); }); + it("recognizes Pi provider credential failures and keeps its native login command", () => { + expect(classifyAgentCliError("No API key found for openai", "pi")).toMatchObject({ + agent: "pi", + displayName: "Pi", + category: "unauthenticated", + authCommand: "pi", + }); + }); + it("provides Factory Droid install and interactive authentication recovery", () => { expect(classifyAgentCliError("spawn droid ENOENT")).toMatchObject({ agent: "droid", diff --git a/apps/ade-cli/src/services/agentRegistry.ts b/apps/ade-cli/src/services/agentRegistry.ts index 415260a31a..147f7551db 100644 --- a/apps/ade-cli/src/services/agentRegistry.ts +++ b/apps/ade-cli/src/services/agentRegistry.ts @@ -101,6 +101,22 @@ export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [ /\bcursor(?:-agent)?\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, ], }, + { + agent: "pi", + displayName: "Pi", + binaryNames: ["pi"], + installCommand: npmGlobalInstallCommand("@earendil-works/pi-coding-agent"), + authCommand: "pi", + missingErrorPatterns: [ + /\bpi\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+pi\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bpi\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required|authentication required|no api key|api key required|no credentials|provider not configured)\b/i, + /\b(?:no api key|api key required|no credentials|provider not configured)\b.*\b(?:for|pi|provider)\b/i, + /\brun\s+[`'"]?pi\s+\/login[`'"]?/i, + ], + }, { agent: "droid", displayName: "Factory Droid", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 6d2aef9271..a4008b8791 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -297,6 +297,7 @@ const EXTERNAL_SESSION_PROVIDERS = new Set([ "cursor", "droid", "opencode", + "pi", ]); type SyncRemoteCommandServiceArgs = { @@ -1259,6 +1260,11 @@ async function summarizeChatSessionForRemote( ...(session.codexSandbox ? { codexSandbox: session.codexSandbox } : {}), ...(session.codexConfigSource ? { codexConfigSource: session.codexConfigSource } : {}), ...(session.opencodePermissionMode ? { opencodePermissionMode: session.opencodePermissionMode } : {}), + ...(session.piProfileId ? { piProfileId: session.piProfileId } : {}), + ...(session.piProviderId ? { piProviderId: session.piProviderId } : {}), + ...(session.piModelId ? { piModelId: session.piModelId } : {}), + ...(session.piSessionId ? { piSessionId: session.piSessionId } : {}), + ...(session.piSessionFile ? { piSessionFile: session.piSessionFile } : {}), ...(session.droidPermissionMode ? { droidPermissionMode: session.droidPermissionMode } : {}), ...(session.cursorModeSnapshot ? { cursorModeSnapshot: session.cursorModeSnapshot } : {}), ...(session.cursorModeId !== undefined ? { cursorModeId: session.cursorModeId } : {}), @@ -2375,6 +2381,11 @@ function parseAgentChatCreateArgs(value: Record): AgentChatCrea parsed.fastMode = asOptionalBoolean(value.fastMode) ?? asOptionalBoolean(value.codexFastMode); } if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatCreateArgs["opencodePermissionMode"]; + if ("piProfileId" in value) parsed.piProfileId = value.piProfileId == null ? null : asTrimmedString(value.piProfileId) ?? null; + if ("piProviderId" in value) parsed.piProviderId = value.piProviderId == null ? null : asTrimmedString(value.piProviderId) ?? null; + if ("piModelId" in value) parsed.piModelId = value.piModelId == null ? null : asTrimmedString(value.piModelId) ?? null; + if ("piSessionId" in value) parsed.piSessionId = value.piSessionId == null ? null : asTrimmedString(value.piSessionId) ?? null; + if ("piSessionFile" in value) parsed.piSessionFile = value.piSessionFile == null ? null : asTrimmedString(value.piSessionFile) ?? null; if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : (asTrimmedString(value.droidPermissionMode) ?? undefined) as AgentChatCreateArgs["droidPermissionMode"]; if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; if ("cursorConfigValues" in value) parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); @@ -2971,6 +2982,7 @@ function parseChatModelCatalogArgs(value: Record): AgentChatMod ...(mode === "cached" || mode === "refresh-stale" || mode === "force" ? { mode } : {}), ...( refreshProvider === "opencode" + || refreshProvider === "pi" || refreshProvider === "cursor" || refreshProvider === "droid" || refreshProvider === "lmstudio" @@ -3554,7 +3566,7 @@ async function resolveChatCreateArgs( if (payload.model.trim().length > 0) return payload; const available = await service.getAvailableModels({ provider: payload.provider, - ...(payload.provider === "opencode" ? { activateRuntime: true } : {}), + ...(payload.provider === "opencode" || payload.provider === "pi" ? { activateRuntime: true } : {}), }); const chosen = available[0]; if (!chosen) { diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 8e27067ead..609ffe8e56 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -372,6 +372,7 @@ const CHAT_BACKED_TERMINAL_TOOL_TYPES = new Set([ "opencode-chat", "cursor", "droid-chat", + "pi-chat", ]); const TRACKED_CLI_PROVIDERS = new Set([ @@ -380,6 +381,7 @@ const TRACKED_CLI_PROVIDERS = new Set([ "cursor", "droid", "opencode", + "pi", ]); /** @@ -400,6 +402,7 @@ export function trackedCliTerminalProvider(session: ChatTerminalSession): AdeCod if (toolType.startsWith("cursor")) return "cursor"; if (toolType.startsWith("droid")) return "droid"; if (toolType.startsWith("opencode")) return "opencode"; + if (toolType.startsWith("pi")) return "pi"; if (toolType.startsWith("claude")) return "claude"; const resumeCommand = typeof session.resumeCommand === "string" ? session.resumeCommand.trim().toLowerCase() : ""; return resumeCommand && /\bclaude\b/.test(resumeCommand) ? "claude" : null; @@ -454,8 +457,8 @@ export async function signalTerminal( await connection.action("terminal", "signal", { terminalId, signal }); } -/** The five provider CLIs the TUI can launch as a tracked terminal session. */ -export type CliTerminalProvider = Extract; +/** Provider CLIs the TUI can launch as tracked terminal sessions. */ +export type CliTerminalProvider = Extract; export type StartCliTerminalSessionResult = { provider: string; @@ -686,7 +689,7 @@ export async function getAvailableModels( // IDs such as `claude-opus-4-6-fast`, not a separate service-tier toggle. // Codex is intentionally NOT here: its tiers come from the app-server, which // loadAvailableModels always queries regardless of activateRuntime. - activateRuntime: provider === "cursor" || provider === "droid", + activateRuntime: provider === "cursor" || provider === "droid" || provider === "pi", ...(provider === "cursor" ? { cursorSource } : {}), }); } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index bab7381a39..7328fca8a7 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -2464,6 +2464,7 @@ function loginCommandsForProvider(provider: AdeCodeProvider): ProviderLoginComma if (provider === "claude") return [{ command: "claude", args: ["auth", "login"], label: "claude auth login" }]; if (provider === "codex") return [{ command: "codex", args: ["login"], label: "codex login" }]; if (provider === "opencode") return [{ command: "opencode", args: ["auth", "login"], label: "opencode auth login" }]; + if (provider === "pi") return [{ command: "pi", args: [], label: "pi (then /login)" }]; return []; } @@ -3377,6 +3378,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [modelPickerFavorites, setModelPickerFavorites] = useState([]); const [modelPickerRecents, setModelPickerRecents] = useState([]); const [modelCatalog, setModelCatalog] = useState(null); + const [modelCatalogRefreshingProvider, setModelCatalogRefreshingProvider] = useState(null); const connectionRef = useRef(null); const analyticsAppOpenedRef = useRef(false); @@ -3536,6 +3538,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const providerModelsCacheRef = useRef>(new Map()); const modelCatalogRef = useRef(null); const modelCatalogProviderRefreshedAtRef = useRef>(new Map()); + const modelCatalogRefreshSequenceRef = useRef(0); const pendingModelCommitTimerRef = useRef(null); const pendingModelCommitStateRef = useRef(null); @@ -6805,9 +6808,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, try { nextModels = conn ? await getAvailableModels(conn, provider, { interfaceMode }) : registryModelsForProvider(provider); providerModelsCacheRef.current.set(cacheKey, nextModels); - } catch { - nextModels = cached ?? registryModelsForProvider(provider); - } + } catch (error) { + nextModels = cached ?? registryModelsForProvider(provider); + if (provider === "pi") { + addNotice( + `Pi model discovery failed; showing cached models. ${error instanceof Error ? error.message : "Check the Pi installation and profile."}`, + "error", + ); + } + } } setModels(nextModels); if (options.applyDefault !== false) { @@ -6824,56 +6833,81 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }); } return nextModels; - }, []); + }, [addNotice]); const refreshModelCatalog = useCallback(async (options: { refreshProvider?: AgentChatModelCatalogRefreshProvider } = {}) => { const conn = connectionRef.current; if (!conn) return modelCatalogRef.current; - if (!options.refreshProvider && modelCatalogRef.current) { - setModelCatalog(modelCatalogRef.current); - return modelCatalogRef.current; - } - const cursorSource = options.refreshProvider === "cursor" - ? cursorSourceForInterfaceMode(modelStateRef.current.interfaceMode) - : undefined; - const refreshCacheKey = options.refreshProvider - ? modelCatalogRefreshCacheKey(options.refreshProvider, cursorSource) - : null; - if (options.refreshProvider && modelCatalogRef.current) { - const refreshedAt = refreshCacheKey ? modelCatalogProviderRefreshedAtRef.current.get(refreshCacheKey) : undefined; - if (refreshedAt && Date.now() - refreshedAt <= modelCatalogClientRefreshTtlMs(options.refreshProvider)) { - setModelCatalog(modelCatalogRef.current); - return modelCatalogRef.current; - } - } - try { + const refreshProvider = options.refreshProvider; + if (!refreshProvider && modelCatalogRef.current) { + setModelCatalog(modelCatalogRef.current); + return modelCatalogRef.current; + } + + const cursorSource = refreshProvider === "cursor" + ? cursorSourceForInterfaceMode(modelStateRef.current.interfaceMode) + : undefined; + const refreshCacheKey = refreshProvider + ? modelCatalogRefreshCacheKey(refreshProvider, cursorSource) + : null; + if (refreshProvider && modelCatalogRef.current) { + const refreshedAt = refreshCacheKey ? modelCatalogProviderRefreshedAtRef.current.get(refreshCacheKey) : undefined; + if (refreshedAt && Date.now() - refreshedAt <= modelCatalogClientRefreshTtlMs(refreshProvider)) { + setModelCatalog(modelCatalogRef.current); + return modelCatalogRef.current; + } + } + + const refreshSequence = refreshProvider ? ++modelCatalogRefreshSequenceRef.current : null; + const clearRefreshingProvider = () => { + if (refreshSequence !== null && modelCatalogRefreshSequenceRef.current === refreshSequence) { + setModelCatalogRefreshingProvider(null); + } + }; + if (refreshProvider) setModelCatalogRefreshingProvider(refreshProvider); + + try { const catalog = await getModelCatalog(conn, { - mode: options.refreshProvider ? "refresh-stale" : "cached", - ...(options.refreshProvider ? { refreshProvider: options.refreshProvider } : {}), + mode: refreshProvider ? "refresh-stale" : "cached", + ...(refreshProvider ? { refreshProvider } : {}), ...(cursorSource ? { cursorSource } : {}), }); - modelCatalogRef.current = catalog; - setModelCatalog(catalog); - if (refreshCacheKey && catalog.stale !== true) { - modelCatalogProviderRefreshedAtRef.current.set(refreshCacheKey, Date.now()); - } - if (options.refreshProvider && catalog.stale === true) { - void getModelCatalog(conn, { - mode: "force", - refreshProvider: options.refreshProvider, - ...(cursorSource ? { cursorSource } : {}), - }).then((freshCatalog) => { - if (connectionRef.current !== conn) return; - modelCatalogRef.current = freshCatalog; - if (refreshCacheKey) modelCatalogProviderRefreshedAtRef.current.set(refreshCacheKey, Date.now()); - setModelCatalog(freshCatalog); - }).catch(() => undefined); + modelCatalogRef.current = catalog; + setModelCatalog(catalog); + if (refreshCacheKey && catalog.stale !== true) { + modelCatalogProviderRefreshedAtRef.current.set(refreshCacheKey, Date.now()); } - return catalog; - } catch { - return modelCatalogRef.current; - } - }, []); + if (refreshProvider && catalog.stale === true) { + void getModelCatalog(conn, { + mode: "force", + refreshProvider, + ...(cursorSource ? { cursorSource } : {}), + }).then((freshCatalog) => { + if (connectionRef.current !== conn) return; + modelCatalogRef.current = freshCatalog; + if (refreshCacheKey) modelCatalogProviderRefreshedAtRef.current.set(refreshCacheKey, Date.now()); + setModelCatalog(freshCatalog); + }).catch((error) => { + addNotice( + `${providerLabel(refreshProvider)} model refresh failed; showing cached models. ${error instanceof Error ? error.message : "Try refreshing again."}`, + "error", + ); + }).finally(clearRefreshingProvider); + } else { + clearRefreshingProvider(); + } + return catalog; + } catch (error) { + clearRefreshingProvider(); + if (refreshProvider) { + addNotice( + `${providerLabel(refreshProvider)} model refresh failed; showing cached models. ${error instanceof Error ? error.message : "Try refreshing again."}`, + "error", + ); + } + return modelCatalogRef.current; + } + }, [addNotice]); const openForm = useCallback((content: Extract) => { // Cancel any pending feedback-success auto-close so a stale timer can't fire @@ -7221,7 +7255,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, recents: modelPickerRecents, modelState, aiStatus, - })); + refreshingProvider: modelCatalogRefreshingProvider, + })); const selection = defaultSelectionFor( modelState.modelId, modelPickerRecents, @@ -14120,9 +14155,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, catalog: modelCatalogRef.current ?? modelCatalog, favorites: modelPickerFavorites, recents: modelPickerRecents, - modelState, - aiStatus, - })); + modelState, + aiStatus, + refreshingProvider: modelCatalogRefreshingProvider, + })); const nextTabKey = nextModelPickerProviderTabKey({ providerTabs: layout.providerTabs, providerTabIndex: layout.providerTabIndex, @@ -15022,8 +15058,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, catalog: modelCatalogRef.current ?? modelCatalog, favorites: modelPickerFavorites, recents: modelPickerRecents, - modelState, - aiStatus, + modelState, + aiStatus, + refreshingProvider: modelCatalogRefreshingProvider, })); const pickerSettingsRows = (picker.settingsRows ?? []).filter((row) => row.kind !== "provider" && row.kind !== "model"); const lastModelIndex = Math.max(0, layout.entries.length - 1); @@ -16265,9 +16302,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, catalog: modelCatalogRef.current ?? modelCatalog, favorites: modelPickerFavorites, recents: modelPickerRecents, - modelState, - aiStatus, - })); + modelState, + aiStatus, + refreshingProvider: modelCatalogRefreshingProvider, + })); // Single geometry source: derive every clickable rect from the SAME // constants + windowing the render uses (modelPickerGeometry), so a // click lands on the row the user sees. Prefer the pane's MEASURED @@ -16832,9 +16870,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, activeReasoningEffort: footerReasoningLabel, aiStatus, interfaceMode: modelState.interfaceMode, + refreshingProvider: modelCatalogRefreshingProvider, }), [ aiStatus, modelCatalog, + modelCatalogRefreshingProvider, modelPickerFavorites, modelPickerRecents, modelState.interfaceMode, diff --git a/apps/ade-cli/src/tuiClient/closedCliSessions.ts b/apps/ade-cli/src/tuiClient/closedCliSessions.ts index c80e41ac87..483b26d50d 100644 --- a/apps/ade-cli/src/tuiClient/closedCliSessions.ts +++ b/apps/ade-cli/src/tuiClient/closedCliSessions.ts @@ -30,6 +30,7 @@ export function terminalSessionResumeProvider(session: ChatTerminalSession | nul if (toolType.startsWith("cursor")) return "cursor"; if (toolType.startsWith("droid")) return "droid"; if (toolType.startsWith("opencode")) return "opencode"; + if (toolType.startsWith("pi")) return "pi"; if (toolType.startsWith("claude")) return "claude"; return null; } @@ -50,7 +51,7 @@ export function isTerminalSessionResumable(session: ChatTerminalSession | null | /** Narrow a terminal session's derived provider to an AgentChatProvider (CLI terminals are always one of the five). */ function terminalSummaryProvider(session: ChatTerminalSession): AgentChatSessionSummary["provider"] { const provider = terminalSessionProvider(session); - return provider === "codex" || provider === "claude" || provider === "opencode" || provider === "cursor" || provider === "droid" + return provider === "codex" || provider === "claude" || provider === "opencode" || provider === "cursor" || provider === "droid" || provider === "pi" ? provider : "claude"; } diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 5ce327ea87..360d61aaf9 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -88,7 +88,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/info", description: "Open active chat info, plan, goal, and agents", placement: "right", category: "Nav" }, { name: "/skills", description: "List agent skills from project, user, and ADE bundled roots", placement: "right", category: "Nav" }, { name: "/secrets", description: "List project secret names and copy masked values", placement: "right", category: "Nav" }, - { name: "/compact", description: "Compact the active chat context", placement: "chat", argumentHint: "[instructions]", providers: ["claude", "codex"], category: "Model" }, + { name: "/compact", description: "Compact the active chat context", placement: "chat", argumentHint: "[instructions]", providers: ["claude", "codex", "pi"], category: "Model" }, { name: "/init", description: "Generate AGENTS.md and Claude pointer files", placement: "right", providers: ["claude"], category: "Nav" }, { name: "/usage", description: "Show Claude and Codex limits plus session usage", placement: "chat", category: "Model" }, { name: "/insights", description: "Generate Claude session insights through the active SDK session", placement: "chat", providers: ["claude"], category: "Model" }, diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx index dec1c51cd0..88e645e001 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx @@ -112,6 +112,7 @@ const PROVIDER_MARKS: Record = { together: { label: "Together", short: "TG", terminal: "T", color: "#22C55E" }, openrouter: { label: "OpenRouter", short: "OR", terminal: "⇄", color: "#6566F1", svgPath: OPENROUTER_PATH }, opencode: { label: "OpenCode", short: "OC", terminal: "▣", color: "#F0F0F2", svgPath: OPENCODE_PATH }, + pi: { label: "Pi", short: "Pi", terminal: "π", color: "#F97316" }, droid: { label: "Droid", short: "DR", terminal: "✺", color: "#06B6D4", svg: DROID_SVG }, factory: { label: "Droid", short: "DR", terminal: "✺", color: "#06B6D4", svg: DROID_SVG }, cursor: { label: "Cursor", short: "CU", terminal: "⬢", color: "#0EA5E9", svg: CURSOR_SVG }, @@ -141,6 +142,7 @@ const ROW_MARKS: Record = { kimiforcoding: PROVIDER_MARKS.kimiforcoding!, openrouter: PROVIDER_MARKS.openrouter!, opencode: PROVIDER_MARKS.opencode!, + pi: PROVIDER_MARKS.pi!, droid: PROVIDER_MARKS.droid!, factory: PROVIDER_MARKS.factory!, cursor: PROVIDER_MARKS.cursor!, @@ -465,6 +467,7 @@ function SettingsFooter({ function emptyStateLabel(state: ModelPickerState, railEntry: ModelPickerRailEntry | undefined): string { if (state.query.trim()) return "No models match your search."; + if (state.refreshingProvider) return `Checking ${providerFamilyLabel(state.refreshingProvider)} models…`; if (railEntry?.kind === "favorites") return "Star a model to pin it here."; if (railEntry?.kind === "recents") return "Models you use will appear here."; if (railEntry?.kind === "provider" && railEntry.authStatus === "unavailable") return "Sign in to use this provider."; diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts index 7159ff946c..4677a1fb60 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vitest"; import type { AgentChatModelCatalog, AgentChatModelInfo } from "../../../../../desktop/src/shared/types/chat"; +import type { AiSettingsStatus } from "../../../../../desktop/src/shared/types/config"; import { buildModelPickerLayoutInput, modelPickerRefreshProvider } from "../../modelPickerController"; import type { AdeCodeModelState, ModelPickerRightPaneContent } from "../../types"; -import { buildModelPickerLayout, defaultSelectionFor } from "./modelPickerLayout"; +import { buildModelPickerLayout, defaultSelectionFor, modelPickerProviderAuthStatus } from "./modelPickerLayout"; function modelInfo(overrides: Partial & { id: string }): AgentChatModelInfo { return { @@ -39,6 +40,11 @@ describe("buildModelPickerLayout", () => { expect(layout.railEntries[0]?.kind).toBe("favorites"); expect(layout.railEntries[1]?.kind).toBe("recents"); expect(layout.railEntries.some((entry) => entry.kind === "provider")).toBe(true); + const piRail = layout.railEntries.find((entry) => entry.kind === "provider" && entry.provider === "pi"); + expect(piRail?.kind).toBe("provider"); + if (piRail?.kind === "provider") { + expect(piRail.signInHint).toBe("Open Pi, then run /login"); + } }); it("scopes the entry list to the selected provider", () => { @@ -55,6 +61,21 @@ describe("buildModelPickerLayout", () => { expect(layout.entries.every((entry) => entry.family === "codex")).toBe(true); }); + it("keeps provider refresh state visible while a dynamic catalog is loading", () => { + const layout = buildModelPickerLayout({ + models: [], + favorites: [], + recents: [], + activeModelId: null, + query: "", + selection: { kind: "provider", provider: "pi" }, + focusedIndex: 0, + searchMode: false, + refreshingProvider: "pi", + }); + expect(layout.refreshingProvider).toBe("pi"); + }); + it("gates Cursor model availability on the interface mode", () => { const cursorModels: AgentChatModelInfo[] = [ modelInfo({ id: "cursor/sdk-only", displayName: "Cursor SDK Only", family: "cursor", cursorAvailability: { sdk: true, cli: false } }), @@ -85,6 +106,16 @@ describe("buildModelPickerLayout", () => { expect(build("cli")).toEqual({ sdkOnly: false, cliOnly: true }); }); + it("keeps configured CLI-only Pi available only in CLI mode", () => { + const status = { + providerConnections: { pi: { authAvailable: true, runtimeAvailable: false } }, + piInstallation: { sdkAvailable: false, cliAvailable: true, availableModelIds: [] }, + } as unknown as AiSettingsStatus; + + expect(modelPickerProviderAuthStatus(status, "pi", "chat")).toBe("unavailable"); + expect(modelPickerProviderAuthStatus(status, "pi", "cli")).toBe("ready"); + }); + it("shows static Anthropic rows immediately before the runtime catalog warms", () => { const layout = buildModelPickerLayout({ models: [modelInfo({ id: "openai/gpt-5", displayName: "GPT-5" })], @@ -151,6 +182,109 @@ describe("buildModelPickerLayout", () => { expect(layout.entries.every((entry) => entry.family === "claude")).toBe(true); }); + it("keeps Pi profile subsections distinct and searchable", () => { + const catalog: AgentChatModelCatalog = { + fetchedAt: "2026-05-29T00:00:00.000Z", + groups: [{ + key: "pi", + displayName: "Pi", + providers: [{ + key: "openai-codex", + displayName: "OpenAI Codex", + badgeColor: "#F97316", + modelCount: 2, + subsections: [ + { + key: "__piprov__:default:openai-codex", + label: "OpenAI Codex", + models: [{ + id: "pi/default/openai-codex/gpt-5.4", + runtimeModelId: "openai-codex/gpt-5.4", + provider: "pi", + providerKey: "openai-codex", + groupKey: "pi", + family: "openai", + providerId: "openai-codex", + providerName: "OpenAI Codex", + displayName: "GPT-5.4", + isDefault: true, + isAvailable: true, + }], + }, + { + key: "__piprov__:team:openai-codex", + label: "OpenAI Codex · team", + models: [{ + id: "pi/team/openai-codex/gpt-5.5", + runtimeModelId: "openai-codex/gpt-5.5", + provider: "pi", + providerKey: "openai-codex", + groupKey: "pi", + family: "openai", + providerId: "openai-codex", + providerName: "OpenAI Codex", + displayName: "GPT-5.5", + isDefault: false, + isAvailable: true, + }], + }, + ], + }], + }], + }; + + const baseInput = { + models: [], + catalog, + favorites: [], + recents: [], + activeModelId: null, + focusedIndex: 0, + searchMode: false, + }; + const layout = buildModelPickerLayout({ + ...baseInput, + query: "", + selection: { kind: "provider", provider: "pi" }, + }); + + expect(layout.providerTabs).toEqual([ + { key: "__piprov__:default:openai-codex", label: "OpenAI Codex" }, + { key: "__piprov__:team:openai-codex", label: "OpenAI Codex · team" }, + ]); + expect(layout.entries).toHaveLength(1); + expect(layout.entries[0]).toMatchObject({ + modelId: "pi/default/openai-codex/gpt-5.4", + subProvider: "OpenAI Codex", + subProviderKey: "__piprov__:default:openai-codex", + }); + + const teamLayout = buildModelPickerLayout({ + ...baseInput, + query: "", + providerTabKey: "__piprov__:team:openai-codex", + selection: { kind: "provider", provider: "pi" }, + }); + expect(teamLayout.entries).toHaveLength(1); + expect(teamLayout.entries[0]).toMatchObject({ + modelId: "pi/team/openai-codex/gpt-5.5", + subProvider: "OpenAI Codex · team", + subProviderKey: "__piprov__:team:openai-codex", + }); + + const searchLayout = buildModelPickerLayout({ + ...baseInput, + query: "team", + searchMode: true, + selection: { kind: "provider", provider: "pi" }, + }); + expect(searchLayout.entries.find((entry) => entry.modelId === "pi/team/openai-codex/gpt-5.5")).toMatchObject({ + modelId: "pi/team/openai-codex/gpt-5.5", + subProvider: "OpenAI Codex · team", + subProviderKey: "__piprov__:team:openai-codex", + }); + }); + it("orders recents by insertion order", () => { const layout = buildModelPickerLayout({ models, @@ -299,6 +433,7 @@ describe("modelPickerController", () => { expect(modelPickerRefreshProvider("droid")).toBe("droid"); expect(modelPickerRefreshProvider("lmstudio")).toBe("lmstudio"); expect(modelPickerRefreshProvider("ollama")).toBe("ollama"); + expect(modelPickerRefreshProvider("pi")).toBe("pi"); expect(modelPickerRefreshProvider("codex")).toBeNull(); expect(modelPickerRefreshProvider("claude")).toBeNull(); }); @@ -325,6 +460,7 @@ describe("modelPickerController", () => { recents: ["anthropic/claude-sonnet-5"], modelState, aiStatus: null, + refreshingProvider: "pi", })).toMatchObject({ query: "sonnet", searchMode: true, @@ -336,6 +472,7 @@ describe("modelPickerController", () => { activeReasoningEffort: "medium", interfaceMode: "chat", laneLabel: "purpose-lane", + refreshingProvider: "pi", }); }); }); diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts index 01ee93f69d..f7815fa7ad 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts @@ -1,6 +1,6 @@ import { scoreModelPickerSearch } from "../../../../../desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch"; import { sortModelItems } from "../../../../../desktop/src/renderer/components/shared/ModelPicker/modelOrdering"; -import type { AgentChatModelCatalog, AgentChatModelInfo } from "../../../../../desktop/src/shared/types/chat"; +import type { AgentChatModelCatalog, AgentChatModelCatalogRefreshProvider, AgentChatModelInfo } from "../../../../../desktop/src/shared/types/chat"; import type { AiSettingsStatus, AiRuntimeConnectionStatus } from "../../../../../desktop/src/shared/types/config"; import { getModelById, @@ -27,6 +27,7 @@ const PROVIDER_ORDER: readonly AdeCodeProvider[] = [ "droid", "cursor", "opencode", + "pi", "ollama", "lmstudio", ]; @@ -39,6 +40,7 @@ function openCodeProviderLabel(providerId: string): string { } function providerSignInHint(provider: AdeCodeProvider): string { + if (provider === "pi") return "Open Pi, then run /login"; return `/login ${provider}`; } @@ -47,6 +49,7 @@ function providerModelsCount(status: AiSettingsStatus | null | undefined, provid if (provider === "claude" || provider === "codex" || provider === "cursor" || provider === "droid") { return status.models?.[provider]?.length ?? 0; } + if (provider === "pi") return status.piInstallation?.availableModelIds.length ?? 0; const matchingRuntime = Object.values(status.runtimeConnections ?? {}).filter((connection) => { const key = String(connection.provider ?? "").toLowerCase(); return key === provider || key.includes(provider); @@ -69,6 +72,7 @@ function runtimeReady(connection: AiRuntimeConnectionStatus | null | undefined): export function modelPickerProviderAuthStatus( status: AiSettingsStatus | null | undefined, provider: AdeCodeProvider, + interfaceMode: AdeCodeInterfaceMode = "chat", ): ModelPickerAuthStatus { if (!status) return "unknown"; if (provider === "claude") { @@ -92,6 +96,15 @@ export function modelPickerProviderAuthStatus( } return "unavailable"; } + if (provider === "pi") { + const connection = status.providerConnections?.pi; + const sdkAvailable = status.piInstallation?.sdkAvailable === true; + const cliAvailable = status.piInstallation?.cliAvailable === true; + const runtimeUsable = sdkAvailable || (interfaceMode === "cli" && cliAvailable); + if (runtimeUsable && (connection?.runtimeAvailable || connection?.authAvailable || (status.piInstallation?.availableModelIds.length ?? 0) > 0)) return "ready"; + if (connection || status.piInstallation) return "unavailable"; + return "unknown"; + } if (provider === "opencode") { if ((status.opencodeProviders ?? []).some((entry) => entry.connected) || status.opencodeBinaryInstalled === true) return "ready"; if (status.opencodeBinaryInstalled === false || status.opencodeInventoryError) return "unavailable"; @@ -112,7 +125,7 @@ export function modelPickerProviderAuthStatus( function providerFromCatalogGroup(groupKey: string, fallbackFamily?: string): AdeCodeProvider { const normalized = groupKey.trim().toLowerCase(); - if (normalized === "claude" || normalized === "codex" || normalized === "opencode" || normalized === "cursor" || normalized === "droid") { + if (normalized === "claude" || normalized === "codex" || normalized === "opencode" || normalized === "cursor" || normalized === "droid" || normalized === "pi") { return normalized; } if (normalized === "ollama" || normalized === "lmstudio") return normalized; @@ -133,6 +146,7 @@ function entriesFromCatalog( favoritesSet: Set, aiStatus?: AiSettingsStatus | null, activeReasoningEffort?: string | null, + interfaceMode: AdeCodeInterfaceMode = "chat", ): ModelPickerEntry[] { const entries: ModelPickerEntry[] = []; const seen = new Set(); @@ -143,13 +157,17 @@ function entriesFromCatalog( if (seen.has(model.id)) continue; seen.add(model.id); const family = providerFromCatalogGroup(String(model.groupKey || group.key), model.family); - const authStatus = modelPickerProviderAuthStatus(aiStatus, family); - const catalogSubProvider = family === "cursor" || family === "droid" + const authStatus = modelPickerProviderAuthStatus(aiStatus, family, interfaceMode); + const catalogSubProvider = family === "pi" + ? subsection.label || model.providerName || provider.displayName || providerLabel(family) + : family === "cursor" || family === "droid" ? subsection.label || model.providerName || provider.displayName || undefined : family === "claude" || family === "codex" ? providerLabel(family) : model.providerName || provider.displayName || subsection.label || undefined; - const catalogSubProviderKey = family === "cursor" || family === "droid" + const catalogSubProviderKey = family === "pi" + ? subsection.key || model.providerId || provider.key || family + : family === "cursor" || family === "droid" ? subsection.key || model.providerId || provider.key || undefined : family === "claude" || family === "codex" ? family @@ -180,10 +198,11 @@ function entryFromDescriptor( favoritesSet: Set, aiStatus?: AiSettingsStatus | null, activeReasoningEffort?: string | null, + interfaceMode: AdeCodeInterfaceMode = "chat", ): ModelPickerEntry { const registryProvider = resolveProviderGroupForModel(descriptor); const provider = normalizeProvider(registryProvider); - const authStatus = modelPickerProviderAuthStatus(aiStatus, provider); + const authStatus = modelPickerProviderAuthStatus(aiStatus, provider, interfaceMode); return { modelId: descriptor.id, runtimeModelId: getRuntimeModelRefForDescriptor(descriptor, registryProvider), @@ -204,10 +223,11 @@ function staticRegistryFallbackEntries( favoritesSet: Set, aiStatus?: AiSettingsStatus | null, activeReasoningEffort?: string | null, + interfaceMode: AdeCodeInterfaceMode = "chat", ): ModelPickerEntry[] { return STATIC_REGISTRY_FALLBACK_PROVIDERS.flatMap((provider) => listModelDescriptorsForProvider(provider).map((descriptor) => - entryFromDescriptor(descriptor, favoritesSet, aiStatus, activeReasoningEffort) + entryFromDescriptor(descriptor, favoritesSet, aiStatus, activeReasoningEffort, interfaceMode) ) ); } @@ -217,6 +237,7 @@ function entryFromModelInfo( favoritesSet: Set, aiStatus?: AiSettingsStatus | null, activeReasoningEffort?: string | null, + interfaceMode: AdeCodeInterfaceMode = "chat", ): ModelPickerEntry { const modelId = modelInfo.modelId ?? modelInfo.id; const descriptor = descriptorFor(modelInfo); @@ -228,7 +249,7 @@ function entryFromModelInfo( ? getRuntimeModelRefForDescriptor(descriptor, registryProvider) : modelInfo.id; const cursorAvailability = modelInfo.cursorAvailability ?? descriptor?.cursorAvailability; - const authStatus = modelPickerProviderAuthStatus(aiStatus, provider); + const authStatus = modelPickerProviderAuthStatus(aiStatus, provider, interfaceMode); return { modelId, runtimeModelId, @@ -263,6 +284,7 @@ export type BuildLayoutInput = { settingsRows?: SetupPaneRow[]; footerFocus?: SetupPaneRowKind | null; laneLabel?: string | null; + refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; query: string; selection: { kind: "favorites" } | { kind: "recents" } | { kind: "provider"; provider: AdeCodeProvider }; providerTabKey?: string | null; @@ -291,10 +313,10 @@ function applyInterfaceAvailability(entry: ModelPickerEntry, interfaceMode: AdeC export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerState { const favoritesSet = new Set(input.favorites); const runtimeEntries = input.catalog - ? entriesFromCatalog(input.catalog, favoritesSet, input.aiStatus, input.activeReasoningEffort) - : input.models.map((m) => entryFromModelInfo(m, favoritesSet, input.aiStatus, input.activeReasoningEffort)); + ? entriesFromCatalog(input.catalog, favoritesSet, input.aiStatus, input.activeReasoningEffort, input.interfaceMode) + : input.models.map((m) => entryFromModelInfo(m, favoritesSet, input.aiStatus, input.activeReasoningEffort, input.interfaceMode)); const entriesById = new Map(); - for (const entry of staticRegistryFallbackEntries(favoritesSet, input.aiStatus, input.activeReasoningEffort)) { + for (const entry of staticRegistryFallbackEntries(favoritesSet, input.aiStatus, input.activeReasoningEffort, input.interfaceMode)) { entriesById.set(entry.modelId, entry); } for (const entry of runtimeEntries) { @@ -323,7 +345,7 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat kind: "provider" as const, provider, label: providerLabel(provider), - authStatus: modelPickerProviderAuthStatus(input.aiStatus, provider), + authStatus: modelPickerProviderAuthStatus(input.aiStatus, provider, input.interfaceMode), signInHint: providerSignInHint(provider), })), ]; @@ -468,6 +490,7 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat settingsRows: input.settingsRows ?? [], footerFocus: input.footerFocus ?? null, laneLabel: input.laneLabel ?? null, + refreshingProvider: input.refreshingProvider ?? null, }; } diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts index 3f7ea71ae2..96b5fbacfb 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts @@ -1,4 +1,5 @@ import type { AdeCodeProvider } from "../../types"; +import type { AgentChatModelCatalogRefreshProvider } from "../../../../../desktop/src/shared/types/chat"; import type { SetupPaneRow, SetupPaneRowKind } from "../../types"; import type { CursorModelAvailability } from "../../../../../desktop/src/shared/modelRegistry"; @@ -48,4 +49,5 @@ export type ModelPickerState = { settingsRows: SetupPaneRow[]; footerFocus: SetupPaneRowKind | null; laneLabel?: string | null; + refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; }; diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 4a795c9ebc..ed101aa63e 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -45,7 +45,7 @@ import { ModelPickerPane } from "./ModelPicker/ModelPickerPane"; import { buildModelPickerLayout } from "./ModelPicker/modelPickerLayout"; import { TokenBar } from "./FooterControls"; import { UsagePane } from "./UsagePane"; -import type { AgentChatModelCatalog, AgentChatModelInfo } from "../../../../desktop/src/shared/types/chat"; +import type { AgentChatModelCatalog, AgentChatModelCatalogRefreshProvider, AgentChatModelInfo } from "../../../../desktop/src/shared/types/chat"; import type { AiSettingsStatus } from "../../../../desktop/src/shared/types/config"; import { useHoveredHitId } from "../hitTestRegistry"; import { diffLineKind, type DiffLineKind } from "../format"; @@ -2326,6 +2326,7 @@ function RightPaneComponent({ activeReasoningEffort?: string | null; aiStatus?: AiSettingsStatus | null; interfaceMode?: AdeCodeInterfaceMode; + refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; }; }) { const { title, hint, branch } = paneTitle(content); @@ -2497,6 +2498,7 @@ function RightPaneComponent({ activeReasoningEffort: modelPickerInputs.activeReasoningEffort, aiStatus: modelPickerInputs.aiStatus, interfaceMode: modelPickerInputs.interfaceMode, + refreshingProvider: modelPickerInputs.refreshingProvider, settingsRows: content.settingsRows, footerFocus: content.footerFocus ?? null, laneLabel: content.laneLabel ?? null, diff --git a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx index 0d50ec91e2..87e7ae9d7c 100644 --- a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx +++ b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx @@ -50,6 +50,7 @@ const PROVIDER_LABELS: Record = { cursor: "Cursor", droid: "Droid", opencode: "OpenCode", + pi: "Pi", }; function providerLabel(provider?: AgentChatProvider | null): string { diff --git a/apps/ade-cli/src/tuiClient/externalSessionBrowser.ts b/apps/ade-cli/src/tuiClient/externalSessionBrowser.ts index b7f1946169..e34815ba4c 100644 --- a/apps/ade-cli/src/tuiClient/externalSessionBrowser.ts +++ b/apps/ade-cli/src/tuiClient/externalSessionBrowser.ts @@ -59,6 +59,7 @@ export const EXTERNAL_SESSION_PROVIDER_FILTERS = [ "cursor", "droid", "opencode", + "pi", ] as const; export type ExternalSessionProviderFilter = (typeof EXTERNAL_SESSION_PROVIDER_FILTERS)[number]; @@ -69,6 +70,7 @@ const PROVIDER_LABELS: Record = { cursor: "Cursor", droid: "Droid", opencode: "OpenCode", + pi: "Pi", }; export function externalSessionProviderLabel(provider: ExternalSessionProvider | "all"): string { diff --git a/apps/ade-cli/src/tuiClient/modelPickerController.ts b/apps/ade-cli/src/tuiClient/modelPickerController.ts index 510588925a..a20393dd55 100644 --- a/apps/ade-cli/src/tuiClient/modelPickerController.ts +++ b/apps/ade-cli/src/tuiClient/modelPickerController.ts @@ -21,6 +21,7 @@ export function buildModelPickerLayoutInput(args: { modelState: Pick; aiStatus: AiSettingsStatus | null; interfaceMode?: AdeCodeInterfaceMode; + refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; }): BuildLayoutInput { return { models: args.models, @@ -34,6 +35,7 @@ export function buildModelPickerLayoutInput(args: { settingsRows: args.picker.settingsRows ?? [], footerFocus: args.picker.footerFocus ?? null, laneLabel: args.picker.laneLabel ?? null, + refreshingProvider: args.refreshingProvider ?? args.picker.refreshingProvider ?? null, query: args.picker.query, selection: args.picker.selection, providerTabKey: args.picker.providerTabKey ?? null, diff --git a/apps/ade-cli/src/tuiClient/modelState.ts b/apps/ade-cli/src/tuiClient/modelState.ts index 1413f69eba..e655b3601d 100644 --- a/apps/ade-cli/src/tuiClient/modelState.ts +++ b/apps/ade-cli/src/tuiClient/modelState.ts @@ -56,13 +56,22 @@ export function runtimeProviderForUiProvider(provider: AdeCodeProvider): ModelPr return provider === "ollama" || provider === "lmstudio" ? "opencode" : provider; } +/** + * Pi and OpenCode-backed providers share ADE's four tool-permission modes. + * The persisted field is still named `opencodePermissionMode` for IPC/session + * compatibility, so keep that legacy detail behind one provider-level helper. + */ +export function usesToolPermissionModes(provider: AdeCodeProvider): boolean { + return provider === "pi" || runtimeProviderForUiProvider(provider) === "opencode"; +} + /** * The provider CLI a modelState provider launches as, or null when it has no * tracked CLI (Ollama / LM Studio are OpenCode-backed chat only). Gates the * Interface=CLI launch path. */ export function cliProviderForModelStateProvider(provider: AdeCodeProvider): CliTerminalProvider | null { - return provider === "claude" || provider === "codex" || provider === "cursor" || provider === "droid" || provider === "opencode" + return provider === "claude" || provider === "codex" || provider === "cursor" || provider === "droid" || provider === "opencode" || provider === "pi" ? provider : null; } @@ -395,7 +404,7 @@ export function permissionSummary(modelState: AdeCodeModelState): string { if (modelState.claudePermissionMode === "bypassPermissions") return "bypass"; return "default"; } - if (runtimeProviderForUiProvider(modelState.provider) === "opencode") return modelState.opencodePermissionMode; + if (usesToolPermissionModes(modelState.provider)) return modelState.opencodePermissionMode; if (modelState.provider === "droid") return modelState.droidPermissionMode; return cursorModeLabel(modelState.cursorModeId); } @@ -431,7 +440,7 @@ export function modeAccentColor(summary: string): string { function permissionOptionsDetail(modelState: AdeCodeModelState): string { if (modelState.provider === "codex") return CODEX_PRESETS.join(" · "); if (modelState.provider === "claude") return "default · plan · auto · bypass"; - if (runtimeProviderForUiProvider(modelState.provider) === "opencode") return OPENCODE_PERMISSION_OPTIONS.join(" · "); + if (usesToolPermissionModes(modelState.provider)) return OPENCODE_PERMISSION_OPTIONS.join(" · "); if (modelState.provider === "droid") return DROID_PERMISSION_OPTIONS.join(" · "); return cursorModeIdsForState(modelState).map((modeId) => cursorModeLabel(modeId)).join(" · "); } @@ -450,7 +459,7 @@ export function applyProviderPermissionMode(modelState: AdeCodeModelState): Part if (modelState.claudePermissionMode === "bypassPermissions") return { permissionMode: "full-auto", interactionMode: "default" }; return { permissionMode: "default", interactionMode: "default" }; } - if (runtimeProviderForUiProvider(modelState.provider) === "opencode") return { permissionMode: modelState.opencodePermissionMode }; + if (usesToolPermissionModes(modelState.provider)) return { permissionMode: modelState.opencodePermissionMode }; if (modelState.provider === "droid") return { permissionMode: droidPermissionToLegacy(modelState.droidPermissionMode) }; if (modelState.provider === "cursor") { if (modelState.cursorModeId === "plan") return { permissionMode: "plan" }; diff --git a/apps/ade-cli/src/tuiClient/planMode.ts b/apps/ade-cli/src/tuiClient/planMode.ts index f587b23c2d..005e6807ea 100644 --- a/apps/ade-cli/src/tuiClient/planMode.ts +++ b/apps/ade-cli/src/tuiClient/planMode.ts @@ -1,14 +1,14 @@ import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat"; import type { AdeCodeModelState } from "./types"; +import { usesToolPermissionModes } from "./modelState"; export function isPlanMode(modelState: AdeCodeModelState): boolean { + if (usesToolPermissionModes(modelState.provider)) return modelState.opencodePermissionMode === "plan"; switch (modelState.provider) { case "claude": return modelState.claudePermissionMode === "plan" || modelState.interactionMode === "plan"; case "codex": return modelState.codexApprovalPolicy === "on-request" && modelState.codexSandbox === "read-only"; - case "opencode": - return modelState.opencodePermissionMode === "plan"; case "droid": return modelState.droidPermissionMode === "read-only"; case "cursor": diff --git a/apps/ade-cli/src/tuiClient/providerMetadata.ts b/apps/ade-cli/src/tuiClient/providerMetadata.ts index aba9d24edf..02d381a920 100644 --- a/apps/ade-cli/src/tuiClient/providerMetadata.ts +++ b/apps/ade-cli/src/tuiClient/providerMetadata.ts @@ -8,6 +8,7 @@ export const TUI_PROVIDER_OPTIONS: Array<{ value: AdeCodeProvider; label: string { value: "cursor", label: "Cursor" }, { value: "droid", label: "Droid" }, { value: "opencode", label: "OpenCode" }, + { value: "pi", label: "Pi" }, { value: "ollama", label: "Ollama" }, { value: "lmstudio", label: "LM Studio" }, ]; @@ -20,6 +21,7 @@ const PROVIDER_FAMILY_LABELS: Record = { opencode: "OpenCode", cursor: "Cursor", droid: "Droid", + pi: "Pi", ollama: "Ollama", lmstudio: "LM Studio", }; @@ -39,6 +41,7 @@ export const PROVIDER_TOKEN_LABELS: Record = { together: "Together", openrouter: "OpenRouter", opencode: "OpenCode", + pi: "Pi", droid: "Droid", factory: "Droid", cursor: "Cursor", @@ -91,7 +94,7 @@ export function titleCaseProviderName(value: string): string { } export function refreshProviderForModelPicker(provider: AdeCodeProvider): AgentChatModelCatalogRefreshProvider | null { - return provider === "opencode" || provider === "cursor" || provider === "droid" || provider === "lmstudio" || provider === "ollama" + return provider === "opencode" || provider === "pi" || provider === "cursor" || provider === "droid" || provider === "lmstudio" || provider === "ollama" ? provider : null; } diff --git a/apps/ade-cli/src/tuiClient/remoteLauncher.ts b/apps/ade-cli/src/tuiClient/remoteLauncher.ts index 2a44b97e38..47684a7097 100644 --- a/apps/ade-cli/src/tuiClient/remoteLauncher.ts +++ b/apps/ade-cli/src/tuiClient/remoteLauncher.ts @@ -990,12 +990,12 @@ function terminalToChoice(session: ChatTerminalSession): RemoteSessionChoice { }; } -const TRACKED_CLI_REMOTE_PROVIDERS = new Set(["claude", "codex", "cursor", "droid", "opencode"]); +const TRACKED_CLI_REMOTE_PROVIDERS = new Set(["claude", "codex", "cursor", "droid", "opencode", "pi"]); function isTerminalSessionLaunchable(session: ChatTerminalSession): boolean { const toolType = session.toolType ?? ""; // Chat-backed terminals surface through the chat session list instead. - if (toolType === "codex-chat" || toolType === "claude-chat" || toolType === "opencode-chat" || toolType === "cursor" || toolType === "droid-chat") { + if (toolType === "codex-chat" || toolType === "claude-chat" || toolType === "opencode-chat" || toolType === "cursor" || toolType === "droid-chat" || toolType === "pi-chat") { return false; } // Any tracked provider CLI (claude/codex/cursor-cli/droid/opencode) is @@ -1005,6 +1005,7 @@ function isTerminalSessionLaunchable(session: ChatTerminalSession): boolean { || toolType.startsWith("cursor") || toolType.startsWith("droid") || toolType.startsWith("opencode") + || toolType.startsWith("pi") || toolType.startsWith("claude") ) { return true; diff --git a/apps/ade-cli/src/tuiClient/theme.ts b/apps/ade-cli/src/tuiClient/theme.ts index d33ae2aeef..52bc9cb046 100644 --- a/apps/ade-cli/src/tuiClient/theme.ts +++ b/apps/ade-cli/src/tuiClient/theme.ts @@ -48,6 +48,7 @@ const CLAUDE = "#D97757"; const CODEX = "#F0F0F2"; const CURSOR = "#0EA5E9"; const OPENCODE = "#F0F0F2"; +const PI = "#F97316"; const DROID = "#06B6D4"; const OLLAMA = "#F0F0F2"; const LMSTUDIO = "#8B5CF6"; @@ -93,6 +94,7 @@ const PROVIDER_THEME: Record = { cursor: { glyph: "⬢", wordmark: "Cursor", color: CURSOR, label: "Cursor" }, droid: { glyph: "✺", wordmark: "Droid", color: DROID, label: "Droid" }, opencode: { glyph: "▣", wordmark: "OpenCode", color: OPENCODE, label: "OpenCode" }, + pi: { glyph: "◈", wordmark: "Pi", color: PI, label: "Pi" }, ollama: { glyph: "◕", wordmark: "Ollama", color: OLLAMA, label: "Ollama" }, lmstudio: { glyph: "≋", wordmark: "LM Studio", color: LMSTUDIO, label: "LM Studio" }, }; diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 2d78357ee4..83803199e1 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -10,6 +10,7 @@ import type { AgentChatEventHistorySnapshot, AgentChatContextUsage, AgentChatInteractionMode, + AgentChatModelCatalogRefreshProvider, AgentChatModelInfo, AgentChatOpenCodePermissionMode, AgentChatPermissionMode, @@ -89,7 +90,7 @@ export type AdeCodeConnection = { close(): Promise; }; -export type AdeCodeProvider = Extract | "ollama" | "lmstudio"; +export type AdeCodeProvider = Extract | "ollama" | "lmstudio"; /** * How a new chat draft is launched. `chat` creates an SDK chat via @@ -240,6 +241,8 @@ export type ModelPickerRightPaneContent = { settingsRows?: SetupPaneRow[]; laneId?: string | null; laneLabel?: string | null; + /** Provider whose model catalog is currently being refreshed, if any. */ + refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; }; // Serializable state carried on the feedback form's RightPaneContent. Mirrors diff --git a/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts b/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts new file mode 100644 index 0000000000..0787c3e3d3 --- /dev/null +++ b/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts @@ -0,0 +1,370 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { piModelDescriptorsFromInventory, probePiProfileInventory, resolvePiInstallation } from "../ai/piInstallation"; +import { + acquirePiSdkConnection, + releasePiSdkConnection, + type PiSdkPooled, +} from "../chat/piSdkPool"; + +const runInstalledPi = process.env.ADE_TEST_PI_SDK_INTEGRATION === "1"; +const describeInstalledPi = runInstalledPi ? describe : describe.skip; +const ONE_PIXEL_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; +const PI_PACKAGE_ROOT = process.env.ADE_PI_PACKAGE_ROOT?.trim() || undefined; + +type CompletionRequest = { + body: Record; + response: http.ServerResponse; +}; + +type Fixture = { + root: string; + cwd: string; + agentDir: string; + sessionDir: string; + modelPath: string; +}; + +const tempRoots: string[] = []; +const activeConnections: Array<{ poolKey: string; generation: number; pooled: PiSdkPooled }> = []; +let server: http.Server; +let serverBaseUrl = ""; +let requestQueue: CompletionRequest[] = []; +let requestWaiters: Array<(request: CompletionRequest) => void> = []; +let rejectRequests = false; + +function writeCompletion(response: http.ServerResponse, model: string, text = "Pi says hello"): void { + if (response.writableEnded) return; + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const emit = (payload: Record) => { + response.write(`data: ${JSON.stringify({ + id: "ade-pi-test-completion", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + ...payload, + })}\n\n`); + }; + emit({ choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] }); + emit({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 3, completion_tokens: 3, total_tokens: 6 } }); + response.end("data: [DONE]\n\n"); +} + +function queueRequest(request: CompletionRequest): void { + const waiter = requestWaiters.shift(); + if (waiter) waiter(request); + else requestQueue.push(request); +} + +async function nextRequest(): Promise { + const queued = requestQueue.shift(); + if (queued) return queued; + return await new Promise((resolve) => requestWaiters.push(resolve)); +} + +function textFromMessages(body: Record): string { + const messages = Array.isArray(body.messages) ? body.messages : []; + const latestUser = [...messages].reverse().find((message) => { + return message && typeof message === "object" && (message as Record).role === "user"; + }); + return JSON.stringify(latestUser ?? ""); +} + +function createFixture(options?: { configured?: boolean; modelId?: string }): Fixture { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-sdk-integration-")); + tempRoots.push(root); + const cwd = path.join(root, "worktree"); + const agentDir = path.join(root, "agent"); + const sessionDir = path.join(root, "sessions"); + fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + fs.mkdirSync(sessionDir, { recursive: true }); + const modelId = options?.modelId ?? "test-model"; + const provider: Record = { + baseUrl: `${serverBaseUrl}/v1`, + api: "openai-completions", + models: [{ + id: modelId, + name: "ADE Pi Integration Model", + reasoning: true, + input: ["text", "image"], + thinkingLevelMap: { xhigh: "xhigh", max: "max" }, + contextWindow: 128_000, + maxTokens: 4_096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: { supportsDeveloperRole: false, supportsReasoningEffort: false }, + }], + }; + if (options?.configured !== false) provider.apiKey = "ade-local-test-key"; + const modelPath = path.join(agentDir, "models.json"); + fs.writeFileSync(modelPath, JSON.stringify({ providers: { "ade-local": provider } })); + return { root, cwd, agentDir, sessionDir, modelPath }; +} + +function installedPiArgs(fixture: Fixture, poolKey: string, options?: { + modelId?: string; + tools?: string[]; + session?: { sessionFile?: string; sessionId?: string }; +}): { + poolKey: string; + packageRoot: string; + packageEntry: string; + cwd: string; + agentDir: string; + sessionDir: string; + modelRef: { provider: string; id: string }; + thinkingLevel: string; + systemPrompt: string; + tools?: string[]; + session?: { sessionFile?: string; sessionId?: string }; + baseEnv: NodeJS.ProcessEnv; +} { + const installation = resolvePiInstallation({ + ...process.env, + ...(PI_PACKAGE_ROOT ? { ADE_PI_PACKAGE_ROOT: PI_PACKAGE_ROOT } : {}), + PI_CODING_AGENT_DIR: fixture.agentDir, + }); + if (!installation.packageRoot || !installation.packageEntry) { + throw new Error(installation.blocker ?? "Installed Pi SDK package was not found."); + } + const args = { + poolKey, + packageRoot: installation.packageRoot, + packageEntry: installation.packageEntry, + cwd: fixture.cwd, + agentDir: fixture.agentDir, + sessionDir: fixture.sessionDir, + modelRef: { provider: "ade-local", id: options?.modelId ?? "test-model" }, + thinkingLevel: "off", + systemPrompt: "You are the isolated ADE Pi integration test model.", + ...(options?.tools ? { tools: options.tools } : {}), + ...(options?.session ? { session: options.session } : {}), + baseEnv: { + PATH: process.env.PATH ?? "", + HOME: fixture.root, + USERPROFILE: fixture.root, + TMPDIR: process.env.TMPDIR ?? os.tmpdir(), + PI_OFFLINE: "1", + }, + }; + return args; +} + +async function acquireTracked(fixture: Fixture, poolKey: string, options?: Parameters[2]) { + const connection = await acquirePiSdkConnection(installedPiArgs(fixture, poolKey, options)); + const tracked = { ...connection, poolKey }; + activeConnections.push(tracked); + return tracked; +} + +function sessionFiles(fixture: Fixture): string[] { + return fs.readdirSync(fixture.sessionDir) + .filter((name) => name.endsWith(".jsonl")) + .map((name) => path.join(fixture.sessionDir, name)) + .filter((filePath) => fs.lstatSync(filePath).isFile()); +} + +async function disposeConnection(connection: { poolKey: string; generation: number; pooled: PiSdkPooled }): Promise { + releasePiSdkConnection(connection.poolKey, connection.generation); + await connection.pooled.waitForExit(); +} + +describeInstalledPi("installed Pi SDK worker", () => { + beforeAll(async () => { + server = http.createServer((request, response) => { + if (request.method !== "POST" || request.url !== "/v1/chat/completions") { + response.writeHead(404).end(); + return; + } + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record; + const entry = { body, response }; + queueRequest(entry); + if (rejectRequests || textFromMessages(body).includes("auth-failure")) { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "invalid local test credential" } })); + return; + } + if (!textFromMessages(body).includes("abort-me")) { + writeCompletion(response, typeof body.model === "string" ? body.model : "test-model"); + } + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Pi integration server did not expose a TCP port."); + serverBaseUrl = `http://127.0.0.1:${address.port}`; + }); + + beforeEach(() => { + requestQueue = []; + requestWaiters = []; + rejectRequests = false; + }); + + afterEach(async () => { + for (const connection of activeConnections.splice(0).reverse()) { + try { + releasePiSdkConnection(connection.poolKey, connection.generation); + await connection.pooled.waitForExit(); + } catch { + // The worker may already have invalidated its pool generation. + } + } + for (const root of tempRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); + }); + + afterAll(async () => { + await new Promise((resolve) => server?.close(() => resolve())); + }); + + it("inventories the installed Pi SDK in an isolated worker without creating a session", async () => { + const fixture = createFixture(); + const installation = resolvePiInstallation({ + ...process.env, + ...(PI_PACKAGE_ROOT ? { ADE_PI_PACKAGE_ROOT: PI_PACKAGE_ROOT } : {}), + PI_CODING_AGENT_DIR: fixture.agentDir, + }); + const inventory = await probePiProfileInventory(installation); + expect(inventory.stale).toBe(false); + expect(inventory.availableModelIds).toContain("pi/default/ade-local/test-model"); + expect(piModelDescriptorsFromInventory(inventory)[0]).toMatchObject({ + providerRoute: "pi-sdk", + piProviderId: "ade-local", + piModelId: "test-model", + }); + expect(sessionFiles(fixture)).toEqual([]); + }); + + it("initializes an isolated profile, prompts with an image, reports auth, and preserves thinking levels", async () => { + const fixture = createFixture(); + const connection = await acquireTracked(fixture, `integration:${Date.now()}`); + expect(connection.pooled.ready?.version).toBeTruthy(); + expect(connection.pooled.availableModels.some((model) => JSON.stringify(model).includes("ade-local"))).toBe(true); + + const auth = await connection.pooled.requestAuth(); + expect(JSON.stringify(auth)).toContain("ade-local"); + expect(JSON.stringify(auth)).toContain("configured"); + expect(JSON.stringify(auth)).toContain("models_json_key"); + + await connection.pooled.sendPrompt({ + prompt: "hello with an image", + images: [{ data: ONE_PIXEL_PNG, mimeType: "image/png" }], + }); + const request = await nextRequest(); + expect(JSON.stringify(request.body.messages)).toContain(ONE_PIXEL_PNG); + const toolNames = ((request.body.tools as Array<{ function?: { name?: string } }> | undefined) ?? []) + .map((tool) => tool.function?.name) + .filter((name): name is string => Boolean(name)); + expect(toolNames).toEqual(["read"]); + + for (const level of ["off", "minimal", "low", "medium", "high", "xhigh", "max"]) { + const ready = await connection.pooled.setThinking(level); + expect(ready.thinkingLevel).toBe(level); + } + + const sessionFile = connection.pooled.sessionFile; + const sessionId = connection.pooled.sessionId; + expect(sessionFile && fs.existsSync(sessionFile)).toBe(true); + expect(sessionId).toBeTruthy(); + const header = JSON.parse(fs.readFileSync(sessionFile!, "utf8").split(/\r?\n/u, 1)[0]!) as { type: string; id: string }; + expect(header).toMatchObject({ type: "session", id: sessionId }); + }); + + it("resumes by header-authoritative id and explicit file, including a stale file pointer", async () => { + const fixture = createFixture(); + const firstKey = `resume:first:${Date.now()}`; + const first = await acquireTracked(fixture, firstKey); + await first.pooled.sendPrompt({ prompt: "persist this native session" }); + await nextRequest(); + const original = { sessionFile: first.pooled.sessionFile!, sessionId: first.pooled.sessionId! }; + expect(fs.existsSync(original.sessionFile)).toBe(true); + await disposeConnection(first); + expect(fs.existsSync(original.sessionFile)).toBe(true); + + const byFile = await acquireTracked(fixture, `resume:file:${Date.now()}`, { session: original }); + expect(fs.realpathSync(byFile.pooled.sessionFile!)).toBe(fs.realpathSync(original.sessionFile)); + expect(byFile.pooled.sessionId).toBe(original.sessionId); + await disposeConnection(byFile); + + const stalePointer = await acquireTracked(fixture, `resume:stale:${Date.now()}`, { + session: { sessionFile: path.join(fixture.sessionDir, "missing.jsonl"), sessionId: original.sessionId }, + }); + expect(fs.realpathSync(stalePointer.pooled.sessionFile!)).toBe(fs.realpathSync(original.sessionFile)); + expect(stalePointer.pooled.sessionId).toBe(original.sessionId); + }); + + it("rejects invalid and unauthorized resume pointers without silently creating a new session", async () => { + const fixture = createFixture(); + const outside = path.join(fixture.root, "outside.jsonl"); + fs.writeFileSync(outside, `${JSON.stringify({ type: "session", id: "outside-id", cwd: fixture.cwd })}\n`); + const before = sessionFiles(fixture); + await expect(acquirePiSdkConnection(installedPiArgs(fixture, `invalid:outside:${Date.now()}`, { + session: { sessionFile: outside }, + }))).rejects.toThrow(/missing|outside|invalid/iu); + await expect(acquirePiSdkConnection(installedPiArgs(fixture, `invalid:relative:${Date.now()}`, { + session: { sessionFile: "relative.jsonl" }, + }))).rejects.toThrow(/absolute/iu); + expect(sessionFiles(fixture)).toEqual(before); + + if (process.platform !== "win32") { + const symlink = path.join(fixture.sessionDir, "linked.jsonl"); + fs.symlinkSync(outside, symlink); + await expect(acquirePiSdkConnection(installedPiArgs(fixture, `invalid:symlink:${Date.now()}`, { + session: { sessionFile: symlink }, + }))).rejects.toThrow(/missing|outside|invalid/iu); + expect(sessionFiles(fixture)).toEqual(before); + } + }); + + it("allows explicit tools while keeping the default worker read-only", async () => { + const fixture = createFixture(); + const readOnly = await acquireTracked(fixture, `tools:read:${Date.now()}`); + await readOnly.pooled.sendPrompt({ prompt: "read-only" }); + const readOnlyRequest = await nextRequest(); + const defaultTools = ((readOnlyRequest.body.tools as Array<{ function?: { name?: string } }> | undefined) ?? []) + .map((tool) => tool.function?.name) + .filter((name): name is string => Boolean(name)); + expect(defaultTools).toEqual(["read"]); + await disposeConnection(readOnly); + + const explicit = await acquireTracked(fixture, `tools:all:${Date.now()}`, { + tools: ["read", "bash", "edit", "write"], + }); + await explicit.pooled.sendPrompt({ prompt: "explicit tools" }); + const explicitRequest = await nextRequest(); + const explicitTools = ((explicitRequest.body.tools as Array<{ function?: { name?: string } }> | undefined) ?? []) + .map((tool) => tool.function?.name) + .filter((name): name is string => Boolean(name)); + expect(explicitTools).toEqual(expect.arrayContaining(["read", "bash", "edit", "write"])); + }); + + it("reports provider auth failures, aborts a hanging prompt, and cleans up the worker", async () => { + const fixture = createFixture(); + const connection = await acquireTracked(fixture, `failure:${Date.now()}`); + rejectRequests = true; + await expect(connection.pooled.sendPrompt({ prompt: "auth-failure" })).rejects.toThrow(/invalid local test credential|401|unauthorized/iu); + await nextRequest(); + rejectRequests = false; + + const hangingPrompt = connection.pooled.sendPrompt({ prompt: "abort-me" }); + const hangingRequest = await nextRequest(); + expect(hangingRequest.body).toBeTruthy(); + await connection.pooled.abort(); + await expect(hangingPrompt).resolves.toMatchObject({ sessionFile: expect.any(String), sessionId: expect.any(String) }); + await connection.pooled.sendPrompt({ prompt: "after abort" }); + await nextRequest(); + }); +}); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index b4fb76ad93..6755a3148a 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -2904,6 +2904,7 @@ async function buildAiSettingsStatus( opencodeBinarySource: status.opencodeBinarySource, opencodeInventoryError: status.opencodeInventoryError, opencodeProviders: status.opencodeProviders, + piInstallation: status.piInstallation, apiKeyStore: status.apiKeyStore, features: AI_SETTINGS_FEATURE_KEYS.map((feature) => ({ feature, diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index e7ab4af2f9..0429514ff5 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -11,6 +11,7 @@ import type { AiProviderConnections, AiRuntimeConnections, AiRuntimeConnectionStatus, + AiPiInstallationStatus, CursorCloudAgentSummary, CursorCloudCreateRunRequest, CursorCloudCreateRunResult, @@ -21,6 +22,7 @@ import type { } from "../../../shared/types"; import { decodeOpenCodeRegistryId, + replaceDynamicPiModelDescriptors, getDefaultModelDescriptor, getModelById, getAvailableModels, @@ -76,6 +78,7 @@ import { import { discoverDroidCliModelDescriptors, markDroidModelCachesStale } from "../chat/droidModelsDiscovery"; import { resolveDroidExecutable } from "./droidExecutable"; import { buildProviderConnections } from "./providerConnectionStatus"; +import { piModelDescriptorsFromInventory, probePiProfileInventory, resolvePiInstallation } from "./piInstallation"; import { getProviderRuntimeHealthVersion, resetProviderRuntimeHealth } from "./providerRuntimeHealth"; import { resetClaudeRuntimeProbeCache } from "./claudeRuntimeProbe"; import { runProviderTask } from "./providerTaskRunner"; @@ -124,10 +127,10 @@ export type AiIntegrationStatus = { droid: AgentModelDescriptor[]; }; detectedAuth?: Array<{ - type: "cli-subscription" | "api-key" | "openrouter" | "local"; + type: "cli-subscription" | "api-key" | "oauth" | "openrouter" | "local"; cli?: "claude" | "codex" | "cursor" | "droid"; provider?: string; - source?: "config" | "env" | "store"; + source?: "config" | "env" | "store" | "file"; endpointSource?: "auto" | "config"; path?: string; endpoint?: string; @@ -154,6 +157,7 @@ export type AiIntegrationStatus = { customProviders?: AiCustomProviderConfig[]; /** Effective ai.customModelSlugs — surfaced so the settings UI can do authoritative full-list writes. */ customModelSlugs?: string[]; + piInstallation?: AiPiInstallationStatus; apiKeyStore?: { secureStorageAvailable: boolean; macosKeychainAvailable?: boolean; @@ -594,6 +598,39 @@ function redactDetectedAuth( return redacted; } +function redactPiDetectedAuth( + piInstallation: AiPiInstallationStatus | null | undefined, +): NonNullable { + if (!piInstallation?.providers?.length) return []; + return piInstallation.providers.flatMap((provider) => { + if (!provider.configured || !provider.authType) return []; + const source = provider.authSource === "environment" + ? "env" as const + : provider.authSource === "stored" + ? "file" as const + : provider.authSource === "models_json_key" || provider.authSource === "models_json_command" + ? "config" as const + : undefined; + const path = provider.authSource === "stored" + ? piInstallation.authPath + : provider.authSource === "models_json_key" || provider.authSource === "models_json_command" + ? piInstallation.modelsPath + : undefined; + return [{ + type: provider.authType === "oauth" + ? "oauth" as const + : provider.authType === "local" + ? "local" as const + : "api-key" as const, + provider: provider.id, + ...(source ? { source } : {}), + ...(path ? { path } : {}), + authenticated: true, + verified: true, + }]; + }); +} + function apiProviderLabel(provider: string): string { const labels: Record = { anthropic: "Anthropic", @@ -611,7 +648,7 @@ function apiProviderLabel(provider: string): string { return labels[provider] ?? provider; } -function toCliRuntimeConnection(status: NonNullable[keyof AiProviderConnections]): AiRuntimeConnectionStatus { +function toCliRuntimeConnection(status: NonNullable[keyof AiProviderConnections]>): AiRuntimeConnectionStatus { const source = status.sources.find((entry) => entry.detected && entry.kind === "local-credentials")?.source; return { provider: status.provider, @@ -800,6 +837,7 @@ async function buildRuntimeConnections(args: { claude: toCliRuntimeConnection(args.providerConnections.claude), codex: toCliRuntimeConnection(args.providerConnections.codex), cursor: toCliRuntimeConnection(args.providerConnections.cursor), + ...(args.providerConnections.pi ? { pi: toCliRuntimeConnection(args.providerConnections.pi) } : {}), }; for (const authEntry of args.auth) { @@ -937,7 +975,8 @@ export function createAiIntegrationService(args: { && (args.providerConnections.claude.authAvailable || args.providerConnections.codex.authAvailable || args.providerConnections.cursor.authAvailable - || args.providerConnections.droid.authAvailable) + || args.providerConnections.droid.authAvailable + || Boolean(args.providerConnections.pi?.authAvailable)) ) { return "subscription"; } @@ -1635,6 +1674,7 @@ export function createAiIntegrationService(args: { clearOpenCodeBinaryCache(); clearOpenCodeInventoryCache(); replaceDynamicOpenCodeModelDescriptors([]); + replaceDynamicPiModelDescriptors([]); }; const executeReadOnlyOneShotTask = async (args: { @@ -1749,11 +1789,17 @@ export function createAiIntegrationService(args: { // detectAuth -> detectAllAuth already called detectCliAuthStatuses() and // populated the cache, so this reads instantly from cache: const cliStatuses = timeSyncPhase("read_cli_auth_cache", () => getCachedCliAuthStatuses()); + const piInstallation = timeSyncPhase("resolve_pi_installation", () => resolvePiInstallation()); + const piProfileInventory = await timePhase("pi_inventory", () => probePiProfileInventory(piInstallation)); + replaceDynamicPiModelDescriptors(piModelDescriptorsFromInventory(piProfileInventory)); // Keep AI status refresh non-interactive. Starting a throwaway Claude // Agent SDK runtime here can trigger Claude's OAuth/API-key bootstrap // in the browser, even though the user only asked to refresh status. // Real Claude chat sessions report runtime health when they start. - const providerConnections = await timePhase("build_provider_connections", () => buildProviderConnections(cliStatuses)); + const providerConnections = await timePhase("build_provider_connections", () => buildProviderConnections(cliStatuses, { + piInstallation, + piInventory: piProfileInventory, + })); const configuredLocalProviders = timeSyncPhase( "read_local_provider_config", () => extractConfiguredLocalProviders(projectConfigService.get()), @@ -1848,7 +1894,11 @@ export function createAiIntegrationService(args: { const baseAvailableIds = runtimeFilteredAvailable .map((descriptor) => descriptor.id) .filter((id) => !opencodeLocalModelIds.has(id)); - return [...new Set([...baseAvailableIds, ...opencodeInventory.modelIds])]; + return [...new Set([ + ...baseAvailableIds, + ...opencodeInventory.modelIds, + ...piProfileInventory.availableModelIds, + ])]; }); const models = timeSyncPhase("build_model_lists", () => buildStatusModelLists(runtimeFilteredAvailable, availability)); @@ -1856,7 +1906,10 @@ export function createAiIntegrationService(args: { mode: timeSyncPhase("derive_mode", () => deriveMode({ snapshot: projectConfigService.get(), auth, providerConnections })), availableProviders: availability, models, - detectedAuth: timeSyncPhase("redact_auth", () => redactDetectedAuth(auth, cliStatuses)), + detectedAuth: timeSyncPhase("redact_auth", () => [ + ...redactDetectedAuth(auth, cliStatuses), + ...redactPiDetectedAuth(piProfileInventory), + ]), providerConnections, runtimeConnections, availableModelIds: mergedAvailableIds, @@ -1868,6 +1921,7 @@ export function createAiIntegrationService(args: { modelsDevLastFetchedAt: getModelsDevLastFetchedAt(), customProviders: effectiveConfig?.ai?.customProviders, customModelSlugs: effectiveConfig?.ai?.customModelSlugs, + piInstallation: piProfileInventory, apiKeyStore: timeSyncPhase("api_key_store_status", () => getApiKeyStoreStatus()), }; if (requestGeneration === providerReadinessCacheGeneration) { diff --git a/apps/desktop/src/main/services/ai/aiSettingsStatus.ts b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts index 4a2b6a6ffd..f8d0b8ac9b 100644 --- a/apps/desktop/src/main/services/ai/aiSettingsStatus.ts +++ b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts @@ -111,6 +111,7 @@ export function getUnavailableAiStatus(): AiSettingsStatus { opencodeProviders: [], opencodeProvidersStale: false, modelsDevLastFetchedAt: null, + piInstallation: undefined, }; } @@ -140,6 +141,7 @@ export async function buildAiSettingsStatus( opencodeProviders: status.opencodeProviders, opencodeProvidersStale: status.opencodeProvidersStale, modelsDevLastFetchedAt: status.modelsDevLastFetchedAt, + piInstallation: status.piInstallation, customProviders: status.customProviders, customModelSlugs: status.customModelSlugs, apiKeyStore: status.apiKeyStore, diff --git a/apps/desktop/src/main/services/ai/piInstallation.ts b/apps/desktop/src/main/services/ai/piInstallation.ts new file mode 100644 index 0000000000..da8561c8ec --- /dev/null +++ b/apps/desktop/src/main/services/ai/piInstallation.ts @@ -0,0 +1,475 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { PI_SDK_MIN_NODE } from "../chat/piSdkProtocol"; +import { acquirePiSdkConnection, releasePiSdkConnection } from "../chat/piSdkPool"; +import { + createDynamicPiModelDescriptor, + decodePiRegistryId, + encodePiRegistryId, +} from "../../../shared/modelRegistry"; +import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver"; + +export { PI_SDK_MIN_NODE } from "../chat/piSdkProtocol"; +export const PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent" as const; + +export type PiInstallation = { + cliPath: string | null; + packageRoot: string | null; + packageEntry: string | null; + version: string | null; + nodeVersion: string; + sdkAvailable: boolean; + cliAvailable: boolean; + agentDir: string; + settingsPath: string; + authPath: string; + modelsPath: string; + modelsStorePath: string; + blocker: string | null; +}; + +export type PiProviderAuthSource = + | "stored" + | "runtime" + | "environment" + | "fallback" + | "models_json_key" + | "models_json_command" + | null; + +export type PiProfileProvider = { + id: string; + name: string; + modelCount: number; + availableModelCount: number; + configured: boolean; + authType: "api-key" | "oauth" | "local" | "unknown" | null; + authMethods: Array<"api-key" | "oauth" | "local">; + authSource?: PiProviderAuthSource; + authLabel?: string | null; + subscription?: boolean; + loginLabel?: string | null; + authExpiresAt?: number | null; +}; + +export type PiProfileInventory = { + installed: boolean; + sdkAvailable: boolean; + cliAvailable: boolean; + cliPath: string | null; + packageRoot: string | null; + version: string | null; + agentDir: string; + settingsPath: string; + authPath: string; + modelsPath: string; + modelsStorePath: string; + blocker: string | null; + providers: PiProfileProvider[]; + availableModelIds: string[]; + stale: boolean; + authFileDetected: boolean; + modelsFileDetected: boolean; + settingsFileDetected: boolean; + error?: string | null; +}; + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value.trim() : null; +} + +function homeDir(env: NodeJS.ProcessEnv): string { + return nonEmpty(env.USERPROFILE) ?? nonEmpty(env.HOME) ?? os.homedir(); +} + +function packageRootFrom(start: string): string | null { + let current = path.resolve(start); + try { + if (fs.statSync(current).isFile()) current = path.dirname(current); + } catch { + current = path.dirname(current); + } + for (;;) { + const packageJson = path.join(current, "package.json"); + if (fs.existsSync(packageJson)) { + try { + const parsed = JSON.parse(fs.readFileSync(packageJson, "utf8")) as Record; + if (parsed.name === PI_PACKAGE_NAME) return current; + } catch { + // Keep walking. A broken package.json should not hide another install. + } + } + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +function packageLocationFromRoot(root: string | null): { root: string | null; entry: string | null; version: string | null } { + if (!root) return { root: null, entry: null, version: null }; + try { + const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")) as Record; + const exportsRoot = packageJson.exports as Record | undefined; + const dotExport = exportsRoot?.["."] as Record | undefined; + const entryValue = dotExport?.import ?? packageJson.module ?? packageJson.main ?? "dist/index.js"; + const entry = typeof entryValue === "string" ? path.resolve(root, entryValue) : null; + return { + root, + entry: entry && fs.existsSync(entry) ? entry : null, + version: nonEmpty(packageJson.version), + }; + } catch { + return { root, entry: null, version: null }; + } +} + +function candidatePackageRoots(env: NodeJS.ProcessEnv, cliPath: string | null): string[] { + const home = homeDir(env); + const roots = [ + nonEmpty(env.ADE_PI_PACKAGE_ROOT), + nonEmpty(env.PI_CODING_AGENT_PACKAGE_ROOT), + cliPath, + path.join(home, ".pi", "agent", "node_modules", PI_PACKAGE_NAME), + path.join(home, ".npm-global", "lib", "node_modules", PI_PACKAGE_NAME), + path.join(home, ".local", "lib", "node_modules", PI_PACKAGE_NAME), + path.join(home, ".volta", "tools", "image", "packages", "node", "lib", "node_modules", PI_PACKAGE_NAME), + ...(process.platform === "win32" ? [ + path.join(nonEmpty(env.APPDATA) ?? path.join(home, "AppData", "Roaming"), "npm", "node_modules", PI_PACKAGE_NAME), + path.join(nonEmpty(env.LOCALAPPDATA) ?? path.join(home, "AppData", "Local"), "npm", "node_modules", PI_PACKAGE_NAME), + path.join(home, "AppData", "Roaming", "npm", "node_modules", PI_PACKAGE_NAME), + path.join(home, "AppData", "Local", "npm", "node_modules", PI_PACKAGE_NAME), + ] : []), + path.join("/opt", "homebrew", "lib", "node_modules", PI_PACKAGE_NAME), + path.join("/usr", "local", "lib", "node_modules", PI_PACKAGE_NAME), + path.join("/usr", "lib", "node_modules", PI_PACKAGE_NAME), + ]; + return roots.filter((value): value is string => Boolean(value)).map((value) => { + try { + return fs.realpathSync(value); + } catch { + return value; + } + }); +} + +function nodeVersionAtLeast(actual: string, minimum = PI_SDK_MIN_NODE): boolean { + const parse = (value: string): [number, number, number] => { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [0, 0, 0]; + }; + const a = parse(actual); + const b = parse(minimum); + return a[0] > b[0] || (a[0] === b[0] && (a[1] > b[1] || (a[1] === b[1] && a[2] >= b[2]))); +} + +function safeJson(filePath: string): T | null { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as T; + } catch { + return null; + } +} + +/** Resolve the user's Pi installation without importing or installing Pi. */ +export function resolvePiInstallation(env: NodeJS.ProcessEnv = process.env): PiInstallation { + const cli = resolveExecutableFromKnownLocations("pi", env)?.path ?? null; + let location: { root: string | null; entry: string | null; version: string | null } = { + root: null, + entry: null, + version: null, + }; + for (const candidate of candidatePackageRoots(env, cli)) { + const root = packageRootFrom(candidate) ?? (fs.existsSync(path.join(candidate, "package.json")) ? candidate : null); + const next = packageLocationFromRoot(root); + if (next.root && next.entry) { + location = next; + break; + } + } + const agentDir = path.resolve(nonEmpty(env.PI_CODING_AGENT_DIR) ?? path.join(homeDir(env), ".pi", "agent")); + const nodeVersion = process.versions.node; + const sdkAvailable = Boolean(location.root && location.entry && nodeVersionAtLeast(nodeVersion)); + let blocker: string | null = null; + if (!location.root || !location.entry) { + blocker = cli + ? "Pi CLI was found, but its SDK package could not be located. Set ADE_PI_PACKAGE_ROOT to the installed @earendil-works/pi-coding-agent directory." + : "Pi is not installed. Install @earendil-works/pi-coding-agent or add the Pi CLI to PATH."; + } else if (!nodeVersionAtLeast(nodeVersion)) { + blocker = `Pi SDK requires Node >= ${PI_SDK_MIN_NODE}; ADE is running Node ${nodeVersion}. Pi CLI remains available.`; + } + return { + cliPath: cli, + packageRoot: location.root, + packageEntry: location.entry, + version: location.version, + nodeVersion, + sdkAvailable, + cliAvailable: Boolean(cli), + agentDir, + settingsPath: path.join(agentDir, "settings.json"), + authPath: path.join(agentDir, "auth.json"), + modelsPath: path.join(agentDir, "models.json"), + modelsStorePath: path.join(agentDir, "models-store.json"), + blocker, + }; +} + +function authSummary(value: unknown): { type: PiProfileProvider["authType"]; expiresAt?: number | null } { + const record = value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; + if (!record) return { type: null }; + const type = nonEmpty(record.type)?.toLowerCase(); + const expires = typeof record.expires === "number" ? record.expires : null; + if (type === "oauth" || type === "token") return { type: "oauth", expiresAt: expires }; + if (type === "api-key" || type === "apikey" || type === "api_key") return { type: "api-key", expiresAt: expires }; + if (record.apiKey != null || record.api_key != null) return { type: "api-key", expiresAt: expires }; + if (Object.keys(record).some((key) => /key|token|access|refresh/i.test(key))) return { type: "oauth", expiresAt: expires }; + return { type: "unknown", expiresAt: expires }; +} + +/** Read only provider/model metadata. Secret values are never returned. */ +export function readPiProfileInventory(installation = resolvePiInstallation()): PiProfileInventory { + const auth = safeJson>(installation.authPath) ?? {}; + const models = safeJson>(installation.modelsPath) ?? {}; + const settingsFileDetected = fs.existsSync(installation.settingsPath); + const authFileDetected = fs.existsSync(installation.authPath); + const modelsFileDetected = fs.existsSync(installation.modelsPath); + const modelProviders = models.providers && typeof models.providers === "object" && !Array.isArray(models.providers) + ? models.providers as Record + : {}; + const ids = new Set(); + const providers = new Map(); + for (const [providerId, raw] of Object.entries(modelProviders)) { + const provider = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record : {}; + const list = Array.isArray(provider.models) ? provider.models : []; + for (const model of list) { + const id = nonEmpty((model as Record | null)?.id); + if (id) ids.add(encodePiRegistryId("default", providerId, id)); + } + const authInfo = authSummary(auth[providerId]); + const authType = authInfo.type + ?? (provider.apiKey ? "api-key" as const : provider.baseUrl ? "local" as const : null); + providers.set(providerId, { + id: providerId, + name: nonEmpty(provider.name) ?? providerId, + modelCount: list.length, + availableModelCount: 0, + configured: Boolean(auth[providerId]) || Boolean(provider.apiKey) || Boolean(provider.baseUrl), + authType, + authMethods: authType === "api-key" || authType === "oauth" || authType === "local" ? [authType] : [], + ...(provider.apiKey ? { authSource: "models_json_key" as const } : {}), + ...(authInfo.expiresAt !== undefined ? { authExpiresAt: authInfo.expiresAt } : {}), + }); + } + for (const [providerId, value] of Object.entries(auth)) { + if (providers.has(providerId)) continue; + const authInfo = authSummary(value); + providers.set(providerId, { + id: providerId, + name: providerId, + modelCount: 0, + availableModelCount: 0, + configured: true, + authType: authInfo.type, + authMethods: authInfo.type === "api-key" || authInfo.type === "oauth" || authInfo.type === "local" ? [authInfo.type] : [], + authSource: "stored", + ...(authInfo.expiresAt !== undefined ? { authExpiresAt: authInfo.expiresAt } : {}), + }); + } + return { + installed: Boolean(installation.packageRoot || installation.cliPath), + sdkAvailable: installation.sdkAvailable, + cliAvailable: installation.cliAvailable, + cliPath: installation.cliPath, + packageRoot: installation.packageRoot, + version: installation.version, + agentDir: installation.agentDir, + settingsPath: installation.settingsPath, + authPath: installation.authPath, + modelsPath: installation.modelsPath, + modelsStorePath: installation.modelsStorePath, + blocker: installation.blocker, + providers: [...providers.values()].sort((a, b) => a.name.localeCompare(b.name)), + availableModelIds: [...ids].sort(), + stale: false, + authFileDetected, + modelsFileDetected, + settingsFileDetected, + }; +} + +function providerAuthSourcePath( + source: PiProviderAuthSource | undefined, + installation: PiInstallation, +): string | undefined { + if (source === "stored") return installation.authPath; + if (source === "models_json_key" || source === "models_json_command") return installation.modelsPath; + return undefined; +} + +function runtimeRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function runtimeAuthType( + value: Record | null, + fallback: PiProfileProvider | undefined, +): "api-key" | "oauth" | "local" | null { + const type = nonEmpty(value?.type)?.toLowerCase(); + if (type === "oauth" || type === "token") return "oauth"; + if (type === "api_key" || type === "api-key" || type === "apikey") return "api-key"; + return fallback?.authType === "unknown" ? null : fallback?.authType ?? null; +} + +function runtimeAuthSource( + value: Record | null, + fallback: PiProfileProvider | undefined, +): PiProviderAuthSource { + const source = nonEmpty(value?.source); + if (source === "stored" || source === "runtime" || source === "environment" + || source === "fallback" || source === "models_json_key" || source === "models_json_command") { + return source; + } + return fallback?.authSource ?? null; +} + +function runtimeConfigured(value: Record | null): boolean { + return value?.configured === true || value?.authenticated === true || value?.isAuthenticated === true; +} + +/** Build one canonical set of picker descriptors from the Pi inventory. */ +export function piModelDescriptorsFromInventory(inventory: PiProfileInventory) { + const providersById = new Map(inventory.providers.map((provider) => [provider.id, provider] as const)); + return inventory.availableModelIds.flatMap((modelId) => { + const decoded = decodePiRegistryId(modelId); + if (!decoded) return []; + const provider = providersById.get(decoded.providerId); + return [createDynamicPiModelDescriptor(decoded.providerId, decoded.modelId, { + profileId: decoded.profileId, + displayName: `${provider?.name ?? decoded.providerId} / ${decoded.modelId}`, + ...(provider?.authMethods.length ? { authTypes: provider.authMethods } : {}), + color: "#F97316", + })]; + }); +} + +export async function probePiProfileInventory( + installation = resolvePiInstallation(), +): Promise { + const fallback = readPiProfileInventory(installation); + if (!installation.sdkAvailable || !installation.packageRoot || !installation.packageEntry) return fallback; + + let acquired: Awaited> | null = null; + const poolKey = `pi-inventory:${installation.agentDir}:${randomUUID()}`; + try { + // Inventory is deliberately served by the same isolated worker boundary + // as chat. The Electron main process must never import user-installed Pi + // code because package top-level code can execute arbitrary extensions. + acquired = await acquirePiSdkConnection({ + poolKey, + packageRoot: installation.packageRoot, + packageEntry: installation.packageEntry, + cwd: path.resolve(process.cwd()), + agentDir: installation.agentDir, + inventoryOnly: true, + baseEnv: process.env, + }); + const availableModels = acquired.pooled.ready?.availableModels ?? []; + const availableCounts = new Map(); + const availableModelIds = availableModels.flatMap((model) => { + const record = runtimeRecord(model); + const providerId = nonEmpty(record?.provider); + const modelId = nonEmpty(record?.id); + if (!providerId || !modelId) return []; + availableCounts.set(providerId, (availableCounts.get(providerId) ?? 0) + 1); + return [encodePiRegistryId("default", providerId, modelId)]; + }); + const runtimeAuth = await acquired.pooled.requestAuth().catch(() => []); + const fallbackProvidersById = new Map(fallback.providers.map((provider) => [provider.id, provider] as const)); + const providersById = new Map(); + + for (const item of Array.isArray(runtimeAuth) ? runtimeAuth : []) { + const runtime = runtimeRecord(item); + const providerId = nonEmpty(runtime?.id); + if (!providerId) continue; + const fallbackProvider = fallbackProvidersById.get(providerId); + const authType = runtimeAuthType(runtime, fallbackProvider); + const authMethods: Array<"api-key" | "oauth" | "local"> = fallbackProvider?.authMethods + ?? (authType ? [authType] : []); + providersById.set(providerId, { + id: providerId, + name: nonEmpty(runtime?.name) ?? fallbackProvider?.name ?? providerId, + modelCount: Math.max(fallbackProvider?.modelCount ?? 0, availableCounts.get(providerId) ?? 0), + availableModelCount: availableCounts.get(providerId) ?? 0, + configured: Boolean(runtimeConfigured(runtime) || fallbackProvider?.configured || (availableCounts.get(providerId) ?? 0) > 0), + authType, + authMethods, + authSource: runtimeAuthSource(runtime, fallbackProvider), + authLabel: nonEmpty(runtime?.label) ?? fallbackProvider?.authLabel ?? null, + subscription: fallbackProvider?.subscription === true, + loginLabel: fallbackProvider?.loginLabel ?? null, + authExpiresAt: fallbackProvider?.authExpiresAt ?? null, + }); + fallbackProvidersById.delete(providerId); + } + + for (const providerId of availableCounts.keys()) { + if (providersById.has(providerId)) continue; + const fallbackProvider = fallbackProvidersById.get(providerId); + providersById.set(providerId, fallbackProvider ?? { + id: providerId, + name: providerId, + modelCount: availableCounts.get(providerId) ?? 0, + availableModelCount: availableCounts.get(providerId) ?? 0, + configured: true, + authType: null, + authMethods: [], + }); + fallbackProvidersById.delete(providerId); + } + + for (const [providerId, fallbackProvider] of fallbackProvidersById.entries()) { + providersById.set(providerId, fallbackProvider); + } + + return { + installed: true, + sdkAvailable: installation.sdkAvailable, + cliAvailable: installation.cliAvailable, + cliPath: installation.cliPath, + packageRoot: installation.packageRoot, + version: installation.version, + agentDir: installation.agentDir, + settingsPath: installation.settingsPath, + authPath: installation.authPath, + modelsPath: installation.modelsPath, + modelsStorePath: installation.modelsStorePath, + blocker: installation.blocker, + providers: [...providersById.values()].sort((a, b) => a.name.localeCompare(b.name)), + availableModelIds: [...new Set(availableModelIds)].sort(), + stale: false, + authFileDetected: fallback.authFileDetected, + modelsFileDetected: fallback.modelsFileDetected, + settingsFileDetected: fallback.settingsFileDetected, + }; + } catch (error) { + return { + ...fallback, + stale: true, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + if (acquired) { + releasePiSdkConnection(poolKey, acquired.generation); + await acquired.pooled.waitForExit().catch(() => undefined); + } + } +} + +export function providerPathForPiAuthSource( + source: PiProviderAuthSource | undefined, + installation: PiInstallation, +): string | undefined { + return providerAuthSourcePath(source, installation); +} diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts index f9e3f21f48..9f4b8b41df 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AiProviderConnections } from "../../../shared/types"; +import type { PiInstallation, PiProfileInventory } from "./piInstallation"; import type { CliAuthStatus } from "./authDetector"; const mockState = vi.hoisted(() => ({ @@ -7,6 +8,8 @@ const mockState = vi.hoisted(() => ({ readCodexCredentials: vi.fn(), isCodexTokenStale: vi.fn(), getProviderRuntimeHealth: vi.fn(), + resolvePiInstallation: vi.fn(), + probePiProfileInventory: vi.fn(), })); vi.mock("./providerCredentialSources", () => ({ @@ -19,7 +22,15 @@ vi.mock("./providerRuntimeHealth", () => ({ getProviderRuntimeHealth: (...args: unknown[]) => mockState.getProviderRuntimeHealth(...args), })); -let buildProviderConnections: (cliStatuses: CliAuthStatus[]) => Promise; +vi.mock("./piInstallation", () => ({ + resolvePiInstallation: (...args: unknown[]) => mockState.resolvePiInstallation(...args), + probePiProfileInventory: (...args: unknown[]) => mockState.probePiProfileInventory(...args), +})); + +let buildProviderConnections: ( + cliStatuses: CliAuthStatus[], + options?: { piInstallation?: PiInstallation | null; piInventory?: PiProfileInventory | null }, +) => Promise; /** buildProviderConnections expects all CLIs; tests historically passed only claude/codex. */ function mergeCliStatuses(overrides: CliAuthStatus[]): CliAuthStatus[] { @@ -41,11 +52,15 @@ beforeEach(async () => { mockState.readCodexCredentials.mockReset(); mockState.isCodexTokenStale.mockReset(); mockState.getProviderRuntimeHealth.mockReset(); + mockState.resolvePiInstallation.mockReset(); + mockState.probePiProfileInventory.mockReset(); mockState.readClaudeCredentials.mockResolvedValue(null); mockState.readCodexCredentials.mockResolvedValue(null); mockState.isCodexTokenStale.mockReturnValue(false); mockState.getProviderRuntimeHealth.mockReturnValue(null); + mockState.resolvePiInstallation.mockReturnValue(null); + mockState.probePiProfileInventory.mockResolvedValue(null); ({ buildProviderConnections } = await import("./providerConnectionStatus")); }); @@ -367,6 +382,116 @@ describe("buildProviderConnections", () => { else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; } }); + + it("surfaces Pi as a first-class provider when the user package is installed and models are available", async () => { + const result = await buildProviderConnections(mergeCliStatuses([]), { + piInstallation: { + cliPath: "/Users/example/.local/bin/pi", + packageRoot: "/Users/example/.pi/agent/node_modules/@earendil-works/pi-coding-agent", + packageEntry: "/Users/example/.pi/agent/node_modules/@earendil-works/pi-coding-agent/dist/index.js", + version: "0.84.0", + nodeVersion: process.versions.node, + sdkAvailable: true, + cliAvailable: true, + agentDir: "/Users/example/.pi/agent", + settingsPath: "/Users/example/.pi/agent/settings.json", + authPath: "/Users/example/.pi/agent/auth.json", + modelsPath: "/Users/example/.pi/agent/models.json", + modelsStorePath: "/Users/example/.pi/agent/models-store.json", + blocker: null, + }, + piInventory: { + installed: true, + sdkAvailable: true, + cliAvailable: true, + cliPath: "/Users/example/.local/bin/pi", + packageRoot: "/Users/example/.pi/agent/node_modules/@earendil-works/pi-coding-agent", + version: "0.84.0", + agentDir: "/Users/example/.pi/agent", + settingsPath: "/Users/example/.pi/agent/settings.json", + authPath: "/Users/example/.pi/agent/auth.json", + modelsPath: "/Users/example/.pi/agent/models.json", + modelsStorePath: "/Users/example/.pi/agent/models-store.json", + blocker: null, + providers: [ + { + id: "openai-codex", + name: "OpenAI Codex", + modelCount: 6, + availableModelCount: 6, + configured: true, + authType: "oauth", + authMethods: ["oauth"], + authSource: "stored", + authLabel: "OAuth", + subscription: true, + }, + ], + availableModelIds: ["pi/openai-codex/gpt-5.4"], + stale: false, + authFileDetected: true, + modelsFileDetected: false, + settingsFileDetected: true, + }, + }); + + expect(result.pi).toBeDefined(); + expect(result.pi?.authAvailable).toBe(true); + expect(result.pi?.runtimeDetected).toBe(true); + expect(result.pi?.runtimeAvailable).toBe(true); + expect(result.pi?.path).toBe("/Users/example/.local/bin/pi"); + expect(result.pi?.sources[0]).toMatchObject({ + kind: "local-credentials", + detected: true, + source: "pi-auth-file", + path: "/Users/example/.pi/agent/auth.json", + }); + }); + + it("reports a Pi configuration blocker when the package is installed but no providers are configured", async () => { + const result = await buildProviderConnections(mergeCliStatuses([]), { + piInstallation: { + cliPath: "/Users/example/.local/bin/pi", + packageRoot: "/Users/example/.pi/agent/node_modules/@earendil-works/pi-coding-agent", + packageEntry: "/Users/example/.pi/agent/node_modules/@earendil-works/pi-coding-agent/dist/index.js", + version: "0.84.0", + nodeVersion: process.versions.node, + sdkAvailable: true, + cliAvailable: true, + agentDir: "/Users/example/.pi/agent", + settingsPath: "/Users/example/.pi/agent/settings.json", + authPath: "/Users/example/.pi/agent/auth.json", + modelsPath: "/Users/example/.pi/agent/models.json", + modelsStorePath: "/Users/example/.pi/agent/models-store.json", + blocker: null, + }, + piInventory: { + installed: true, + sdkAvailable: true, + cliAvailable: true, + cliPath: "/Users/example/.local/bin/pi", + packageRoot: "/Users/example/.pi/agent/node_modules/@earendil-works/pi-coding-agent", + version: "0.84.0", + agentDir: "/Users/example/.pi/agent", + settingsPath: "/Users/example/.pi/agent/settings.json", + authPath: "/Users/example/.pi/agent/auth.json", + modelsPath: "/Users/example/.pi/agent/models.json", + modelsStorePath: "/Users/example/.pi/agent/models-store.json", + blocker: null, + providers: [], + availableModelIds: [], + stale: false, + authFileDetected: false, + modelsFileDetected: false, + settingsFileDetected: true, + }, + }); + + expect(result.pi?.authAvailable).toBe(false); + expect(result.pi?.runtimeDetected).toBe(true); + expect(result.pi?.runtimeAvailable).toBe(false); + expect(result.pi?.blocker).toContain("No Pi providers are configured yet"); + }); // Cursor is gated out of Windows on ARM because @cursor/sdk publishes no // win32-arm64 runtime. Platform/arch are forced here rather than read from the // host, so these assertions run identically on every CI runner — no platform diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 11bfa32645..735855a231 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -12,10 +12,11 @@ import { CURSOR_WINDOWS_ARM_BLOCKER, isCursorProviderSupported, } from "../../../shared/providerPlatformSupport"; +import type { PiInstallation, PiProfileInventory } from "./piInstallation"; import { nowIso } from "../shared/utils"; function createUnavailableStatus( - provider: "claude" | "codex" | "cursor" | "droid", + provider: "claude" | "codex" | "cursor" | "droid" | "pi", checkedAt: string, ): AiProviderConnectionStatus { return { @@ -33,6 +34,10 @@ function createUnavailableStatus( export async function buildProviderConnections( cliStatuses: CliAuthStatus[], + options?: { + piInstallation?: PiInstallation | null; + piInventory?: PiProfileInventory | null; + }, ): Promise { const checkedAt = nowIso(); const claudeCli = cliStatuses.find((entry) => entry.cli === "claude") ?? null; @@ -44,6 +49,7 @@ export async function buildProviderConnections( const claudeRuntimeHealth = getProviderRuntimeHealth("claude"); const codexRuntimeHealth = getProviderRuntimeHealth("codex"); const cursorRuntimeHealth = getProviderRuntimeHealth("cursor"); + const piRuntimeHealth = getProviderRuntimeHealth("pi"); const deriveProviderFlags = ( cli: CliAuthStatus | null, @@ -328,5 +334,56 @@ export async function buildProviderConnections( blocker: droidBlocker, }; - return { claude, codex, cursor, droid }; + const piInstallation = options?.piInstallation ?? null; + const piInventory = options?.piInventory ?? null; + const piConfiguredProviderCount = piInventory?.providers.filter((provider) => provider.configured).length ?? 0; + const piAvailableModelCount = piInventory?.availableModelIds.length ?? 0; + const piAuthAvailable = piConfiguredProviderCount > 0; + const piRuntimeDetected = Boolean(piInstallation?.sdkAvailable || piInstallation?.cliAvailable); + const piRuntimeAvailable = Boolean(piInstallation?.sdkAvailable && piAvailableModelCount > 0); + let piBlocker = piInstallation?.blocker ?? null; + if (!piBlocker) { + if (!piRuntimeDetected) { + piBlocker = "Pi is not installed."; + } else if (!piAuthAvailable) { + piBlocker = "No Pi providers are configured yet. Use Pi /login or edit auth.json/models.json."; + } else if (!piRuntimeAvailable) { + piBlocker = "Pi is configured, but no Pi models are currently available. Check auth.json or models.json."; + } + } + const pi: AiProviderConnectionStatus | undefined = piInstallation || piInventory + ? { + ...createUnavailableStatus("pi", checkedAt), + authAvailable: piAuthAvailable, + runtimeDetected: piRuntimeDetected, + runtimeAvailable: piRuntimeAvailable, + usageAvailable: false, + path: piInstallation?.cliPath ?? piInstallation?.packageRoot ?? null, + sources: [ + { + kind: "local-credentials", + detected: Boolean(piInventory?.authFileDetected || piInventory?.modelsFileDetected), + source: piInventory?.authFileDetected ? "pi-auth-file" : piInventory?.modelsFileDetected ? "pi-models-file" : undefined, + authenticated: piAuthAvailable, + verified: piAuthAvailable, + path: piInventory?.authFileDetected + ? piInstallation?.authPath ?? null + : piInventory?.modelsFileDetected + ? piInstallation?.modelsPath ?? null + : null, + }, + { + kind: "cli", + detected: Boolean(piInstallation?.cliPath), + authenticated: piRuntimeAvailable, + verified: Boolean(piInstallation?.sdkAvailable), + path: piInstallation?.cliPath ?? null, + }, + ], + blocker: piBlocker, + } + : undefined; + if (pi) applyRuntimeHealth(pi, piRuntimeHealth); + + return pi ? { claude, codex, cursor, droid, pi } : { claude, codex, cursor, droid }; } diff --git a/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts b/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts index cb88e1a6e0..e2f438b4b9 100644 --- a/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts +++ b/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts @@ -1,7 +1,7 @@ import { nowIso } from "../shared/utils"; export type ProviderRuntimeHealthState = "ready" | "auth-failed" | "runtime-failed"; -export type ProviderRuntimeHealthProvider = "claude" | "codex" | "cursor"; +export type ProviderRuntimeHealthProvider = "claude" | "codex" | "cursor" | "pi"; export type ProviderRuntimeHealth = { provider: ProviderRuntimeHealthProvider; diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index 83ee3f9754..2c4ef8e858 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -16,6 +16,7 @@ export type AdeRuntimeKind = | "codex-cli" | "cursor-sdk" | "droid-sdk" + | "pi-sdk" | "opencode"; const adeScheduledWorkGuidance = "**Wake-up semantics:** Autonomous wake is available via `ade chat scheduled-work create --in 12m --prompt \"\" --text` or `ade actions run chat.createScheduledWork --input-json '{\"delaySeconds\":720,\"prompt\":\"\"}' --text`; relative delays are one-shot and avoid timezone arithmetic. Absolute one-shots use `--at ` / `runAt`. Five-field cron remains available for recurring jobs but is interpreted in the ADE brain machine's local timezone, never UTC unless that machine is configured for UTC. The create result reports the computed next run time; verify it before ending the turn. The action targets your own tracked agent session automatically. List, cancel, or pause with `chat.listScheduledWork`, `chat.cancelScheduledWork`, and `chat.setScheduledWorkPaused`, or the typed `ade chat scheduled-work ...` / `ade chat schedules ...` commands. Delivery starts a new turn at the next turn boundary, resumes an ended tracked provider CLI when necessary, and survives brain restarts; recurring jobs expire after seven days. Keep shell `sleep` for short waits inside the current turn."; @@ -54,6 +55,11 @@ function describeRuntime(runtime: AdeRuntimeKind): string[] { "**Runtime:** ADE Work chat hosted on the Factory Droid SDK (`@factory/droid-sdk`) and backed by the local Droid CLI.", adeScheduledWorkGuidance, ]; + case "pi-sdk": + return [ + "**Runtime:** ADE Work chat hosted on the user's Pi SDK installation. ADE owns the chat transcript and lane boundary; Pi owns its native session file and provider credentials.", + adeScheduledWorkGuidance, + ]; case "opencode": return [ "**Runtime:** ADE Work chat wrapping an OpenCode session.", diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 2be08ead85..7e3044e2fc 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -315,6 +315,7 @@ import type { AgentChatCursorConfigValue, AgentChatCursorModeSnapshot, AgentChatOpenCodePermissionMode, + AgentChatPermissionMode, CodexPlanState, CodexModerationMetadata, AgentChatMcpToolSource, @@ -390,6 +391,8 @@ import { import { getDefaultModelDescriptor, getDynamicOpenCodeModelDescriptors, + getDynamicPiModelDescriptors, + replaceDynamicPiModelDescriptors, getModelById, getAvailableModels as getRegistryModels, getLocalProviderDefaultEndpoint, @@ -408,6 +411,7 @@ import { type ModelDescriptor, type ModelProviderGroup, } from "../../../shared/modelRegistry"; +import { piToolsForPermissionMode } from "../../../shared/cliLaunch"; import { buildProviderGroupBlocks, createModelOrderMap, @@ -533,6 +537,20 @@ import { releaseDroidSdkConnection, type DroidSdkPooled, } from "./droidSdkPool"; +import { + acquirePiSdkConnection, + isPiSdkPooledAlive, + releasePiSdkConnection, + type PiSdkPooled, +} from "./piSdkPool"; +import { + acquirePiSessionLease, + piSessionCreationLeaseTarget, + piSessionDirectoryForEnvironment, + resolvePiSessionFile, + type PiSessionLease, +} from "./piSessionLease"; +import { piModelDescriptorsFromInventory, probePiProfileInventory, resolvePiInstallation } from "../ai/piInstallation"; import { discoverCursorCliModelDescriptors, discoverCursorSdkModelDescriptors, @@ -541,6 +559,7 @@ import { resolveCursorSdkModelSelectionParams, } from "./cursorModelsDiscovery"; import { discoverDroidSdkModelDescriptors } from "./droidModelsDiscovery"; +import { mapPiSdkEventToChatEvents } from "./piSdkEventMapper"; import { AUTO_LANE_IDENTITY_JSON_SCHEMA, AUTO_TITLE_SYSTEM_PROMPT, @@ -952,6 +971,12 @@ type PersistedChatState = { importedFrom?: AgentChatImportedFrom; /** Factory Droid SDK session id for Droid resume across app restarts (best-effort). */ droidSdkSessionId?: string; + /** Pi-native JSONL session pointer for SDK resume and CLI handoff. */ + piSessionId?: string; + piSessionFile?: string; + piProfileId?: string; + piProviderId?: string; + piModelId?: string; sdkSessionId?: string; forkFromSdkSessionId?: string; providerSessionId?: string; @@ -1114,7 +1139,7 @@ function normalizedPersistedPointer(value: unknown): string | null { } function persistedPointerState(state: Pick): { provider: ThreadPointerLedgerEntry["provider"]; pointer: string | null } { switch (state.provider) { case "codex": @@ -1127,6 +1152,8 @@ function persistedPointerState(state: Pick void }; }; -type ChatRuntime = CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime; +type PiRuntime = { + kind: "pi"; + poolKey: string; + poolGeneration: number; + sdk: PiSdkPooled; + activeTurnId: string | null; + busy: boolean; + interrupted: boolean; + workerFailed: boolean; + pendingSteers: QueuedSteer[]; + modelProviderId: string | null; + modelId: string | null; + activeCompactionId: string | null; + lease: PiSessionLease | null; +}; + +type ChatRuntime = CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime | PiRuntime; function cancelCursorPermissionWaiter(waiter: CursorPermissionWaiter, reason: string): void { waiter.resolve(denyCursorHook(reason)); @@ -2053,7 +2096,7 @@ function validateSessionReadyForTurn(managed: ManagedChatSession): { ready: true if (!managed.runtime) return { ready: false, reason: "No runtime initialized" }; if (hasLivePendingInput(managed)) return { ready: false, reason: PENDING_INPUT_SEND_BLOCKED_MESSAGE }; const rt = managed.runtime; - if ((rt.kind === "opencode" || rt.kind === "claude" || rt.kind === "cursor" || rt.kind === "droid") && rt.busy) { + if ((rt.kind === "opencode" || rt.kind === "claude" || rt.kind === "cursor" || rt.kind === "droid" || rt.kind === "pi") && rt.busy) { return { ready: false, reason: "Turn already active" }; } if (rt.kind === "opencode" && rt.pendingApprovals.size > 0) return { ready: false, reason: "Pending approvals not resolved" }; @@ -2072,6 +2115,7 @@ function hasLivePendingInput(managed: ManagedChatSession | null | undefined): bo if (runtime.kind === "claude") return runtime.approvals.size > 0; if (runtime.kind === "opencode") return runtime.pendingApprovals.size > 0; if (runtime.kind === "cursor" || runtime.kind === "droid") return runtime.permissionWaiters.size > 0; + if (runtime.kind === "pi") return false; return false; } @@ -2130,6 +2174,12 @@ function hasRuntimeActiveWorkload(runtime: ChatRuntime | null): boolean { || runtime.pendingSteers.length > 0 || runtime.permissionWaiters.size > 0 ); + case "pi": + return Boolean( + runtime.busy + || runtime.activeTurnId + || runtime.pendingSteers.length > 0 + ); default: return false; } @@ -2632,6 +2682,8 @@ type ManagedChatSession = { */ seededProviderSessionId?: string; seededDroidSdkSessionId?: string; + seededPiSessionId?: string; + seededPiSessionFile?: string; }; type HandoffArtifacts = { @@ -2767,6 +2819,7 @@ type ResolvedChatConfig = { codexSandboxMode: AgentChatCodexSandbox; claudePermissionMode: AgentChatClaudePermissionMode; opencodePermissionMode: AgentChatOpenCodePermissionMode; + piPermissionMode: AgentChatPermissionMode; sessionBudgetUsd: number | null; titleGenerationEnabled: boolean; titleModelId: string | null; @@ -3758,6 +3811,7 @@ const CHAT_SESSION_TOOL_TYPES = [ "opencode-chat", "cursor", "droid-chat", + "pi-chat", ] satisfies TerminalToolType[]; type ChatSessionToolType = (typeof CHAT_SESSION_TOOL_TYPES)[number]; @@ -3778,6 +3832,7 @@ function isSchedulableAgentSession( } function providerFromToolType(toolType: TerminalToolType | null | undefined): AgentChatProvider { + if (toolType === "pi" || toolType === "pi-chat") return "pi"; if (toolType === "opencode-chat") return "opencode"; if (toolType === "claude-chat") return "claude"; if (toolType === "cursor") return "cursor"; @@ -3786,6 +3841,7 @@ function providerFromToolType(toolType: TerminalToolType | null | undefined): Ag } function toolTypeFromProvider(provider: AgentChatProvider): TerminalToolType { + if (provider === "pi") return "pi-chat"; if (provider === "opencode") return "opencode-chat"; if (provider === "claude") return "claude-chat"; if (provider === "cursor") return "cursor"; @@ -4382,6 +4438,7 @@ function parseAutoLaneIdentity(raw: string): { laneTitle: string | null; branchF } function defaultChatSessionTitle(provider: AgentChatProvider): string { + if (provider === "pi") return "Pi Chat"; if (provider === "codex") return "Codex Chat"; if (provider === "claude") return "Claude Chat"; if (provider === "cursor") return "Cursor Chat"; @@ -4393,7 +4450,7 @@ function handoffProviderLabel(provider: AgentChatProvider): string { return providerDisplayLabel(provider, String(provider)); } -const DEFAULT_SESSION_TITLES = new Set(["Codex Chat", "Claude Chat", "AI Chat", "Cursor Chat", "Droid Chat"]); +const DEFAULT_SESSION_TITLES = new Set(["Codex Chat", "Claude Chat", "AI Chat", "Cursor Chat", "Droid Chat", "Pi Chat"]); const DEFAULT_SESSION_TITLES_NORMALIZED = new Set( [...DEFAULT_SESSION_TITLES, "OpenCode Chat", "Open Code Chat"] .map((title) => title.toLowerCase()), @@ -4484,6 +4541,7 @@ function extractRuntimeTitle(value: unknown): string | null { } function resumeCommandForProvider(provider: AgentChatProvider, sessionId: string): string { + if (provider === "pi") return `chat:pi:${sessionId}`; if (provider === "codex") return "chat:codex"; if (provider === "opencode") return `chat:opencode:${sessionId}`; if (provider === "cursor") return `chat:cursor:${sessionId}`; @@ -4557,6 +4615,11 @@ function resolveModelIdFromStoredValue( const normalized = model.trim().toLowerCase(); if (!normalized.length) return undefined; + if (providerHint === "pi") { + const piDescriptor = getModelById(model); + if (piDescriptor && resolveProviderGroupForModel(piDescriptor) === "pi") return piDescriptor.id; + } + if (providerHint === "claude") { const resolvedClaudeCliModelId = resolveClaudeCliModelIdFromRuntimeValue(normalized); if (resolvedClaudeCliModelId) return resolvedClaudeCliModelId; @@ -4719,6 +4782,7 @@ function resolveClaudeTurnModelPayload( } function fallbackModelForProvider(provider: AgentChatProvider): string { + if (provider === "pi") return getDynamicPiModelDescriptors()[0]?.id ?? "pi/default"; if (provider === "codex") return DEFAULT_CODEX_MODEL; if (provider === "claude") return DEFAULT_CLAUDE_MODEL; if (provider === "cursor") return DEFAULT_CURSOR_MODEL; @@ -5634,7 +5698,7 @@ function droidPermissionModeToLegacyPermissionMode( function syncLegacyPermissionMode(session: Pick< AgentChatSession, - "provider" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" | "droidPermissionMode" + "provider" | "permissionMode" | "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" | "droidPermissionMode" >): AgentChatSession["permissionMode"] | undefined { if (session.provider === "claude") { if (session.interactionMode === "plan") { @@ -5677,6 +5741,8 @@ function syncLegacyPermissionMode(session: Pick< ); } + if (session.provider === "pi") return session.permissionMode; + switch (session.opencodePermissionMode) { case "plan": case "edit": @@ -5717,6 +5783,8 @@ function applyLegacyPermissionModeToNativeControls( return; } + if (session.provider === "pi") return; + session.opencodePermissionMode = legacyPermissionModeToOpenCodePermissionMode(mode); } @@ -5770,6 +5838,8 @@ function hydrateNativePermissionControls( session.droidPermissionMode = session.droidPermissionMode ?? legacyPermissionModeToDroidPermissionMode(session.permissionMode) ?? legacyOpenCodePermissionModeToDroidPermissionMode(session.opencodePermissionMode); + } else if (session.provider === "pi") { + if (orchestrationMode) session.interactionMode = orchestrationMode; } else { if (orchestrationMode) session.interactionMode = orchestrationMode; session.opencodePermissionMode = session.opencodePermissionMode ?? legacyPermissionModeToOpenCodePermissionMode(session.permissionMode); @@ -5942,6 +6012,12 @@ function toHarnessPermissionMode( return "edit"; } +/** + * Pi loads a user-installed package in a separate process. Keep the worker's + * environment deliberately small: provider credentials that Pi can resolve + * from the environment, ordinary process/terminal settings, and nothing from + * ADE's capability-token or agent-control environment. + */ function enforceOrchestrationLockedPermissionMode( session: Pick< AgentChatSession, @@ -6583,6 +6659,15 @@ function normalizeSessionNativePermissionControls( delete session.codexSandbox; delete session.codexConfigSource; delete session.opencodePermissionMode; + } else if (session.provider === "pi") { + if (orchestrationMode) session.interactionMode = orchestrationMode; + else delete session.interactionMode; + delete session.claudePermissionMode; + delete session.codexApprovalPolicy; + delete session.codexSandbox; + delete session.codexConfigSource; + delete session.opencodePermissionMode; + delete session.droidPermissionMode; } else { if (orchestrationMode) session.interactionMode = orchestrationMode; else delete session.interactionMode; @@ -6679,6 +6764,7 @@ function inferCapabilityMode(provider: AgentChatProvider): CtoCapabilityMode { || provider === "cursor" || provider === "droid" || provider === "opencode" + || provider === "pi" ? "full_tooling" : "fallback"; } @@ -6712,6 +6798,7 @@ function personalChatUserPromptFallback( case "opencode": case "cursor": case "droid": + case "pi": return null; default: return PERSONAL_CHAT_SYSTEM_PROMPT; @@ -7643,6 +7730,8 @@ export function createAgentChatService(args: { /** Interrupt arrived while `ensureDroidRuntime` was still acquiring the SDK worker. */ const droidRuntimeSetupInterruptRequested = new WeakMap(); + /** Interrupt arrived while the Pi SDK worker was still being acquired. */ + const piRuntimeSetupInterruptRequested = new WeakMap(); /** Interrupt arrived while `ensureCursorSdkRuntime` was still acquiring the SDK worker. */ const cursorRuntimeSetupInterruptRequested = new WeakMap(); const sessionTurnCollectors = new Map(); @@ -11017,6 +11106,222 @@ export function createAgentChatService(args: { throw new Error(`${descriptor.displayName} is reachable, but no models are currently loaded.`); }; + const startPiRuntime = async (managed: ManagedChatSession): Promise => { + if (piRuntimeSetupInterruptRequested.get(managed)) { + piRuntimeSetupInterruptRequested.delete(managed); + throw new Error("Pi session interrupted during setup."); + } + const installation = resolvePiInstallation(buildAgentRuntimeEnv(managed)); + if (!installation.sdkAvailable || !installation.packageRoot || !installation.packageEntry) { + throw new Error(installation.blocker ?? "Pi SDK is not available. Install Pi or configure ADE_PI_PACKAGE_ROOT."); + } + const descriptor = resolveSessionModelDescriptor(managed.session); + const piProfileId = managed.session.piProfileId?.trim() || descriptor?.piProfileId?.trim() || "default"; + const piProviderId = managed.session.piProviderId?.trim() || descriptor?.piProviderId?.trim() || null; + const piModelId = managed.session.piModelId?.trim() || descriptor?.piModelId?.trim() || null; + if (piProfileId) managed.session.piProfileId = piProfileId; + if (piProviderId) managed.session.piProviderId = piProviderId; + if (piModelId) managed.session.piModelId = piModelId; + const poolKey = `pi:${projectRoot}:${path.resolve(managed.laneWorktreePath)}:${managed.session.id}:${piProfileId}`; + if (managed.runtime?.kind === "pi") { + if (managed.runtime.poolKey === poolKey && isPiSdkPooledAlive(managed.runtime.sdk)) return managed.runtime; + teardownRuntime(managed, "handle_close"); + } else if (managed.runtime) { + teardownRuntime(managed, "handle_close"); + } + let activeCount = 0; + for (const [, session] of managedSessions) if (session.runtime) activeCount++; + if (activeCount >= MAX_CONCURRENT_ACTIVE_RUNTIMES) evictLeastRecentRuntime(managed.session.id); + + const runtimeEnv = buildAgentRuntimeEnv(managed); + const skillRoots = existingAgentSkillRoots(runtimeEnv); + const persisted = readPersistedState(managed.session.id); + const sessionFile = managed.session.piSessionFile?.trim() || persisted?.piSessionFile?.trim() || null; + const sessionId = managed.session.piSessionId?.trim() || persisted?.piSessionId?.trim() || null; + const sessionDir = piSessionDirectoryForEnvironment(runtimeEnv, path.join(layout.cacheDir, "pi", "sessions")); + fs.mkdirSync(sessionDir, { recursive: true }); + const piLeaseIdentity = processRegistry + ? { + processStartedAt: processRegistry.startedAt, + isProcessIdentityLive: (pid: number, startedAt: string) => processRegistry.isProcessIdentityLive(pid, startedAt), + } + : {}; + let piLease: PiSessionLease | null = null; + let piCreationLease: PiSessionLease | null = null; + const existingPiSessionFile = resolvePiSessionFile({ + cwd: managed.laneWorktreePath, + sessionId: sessionId ?? "", + sessionFile, + sessionDir, + env: runtimeEnv, + }); + if (existingPiSessionFile) { + piLease = acquirePiSessionLease({ + sessionFile: existingPiSessionFile, + owner: "sdk", + ownerId: managed.session.id, + ...piLeaseIdentity, + }); + } else { + // A new Pi SDK session has no JSONL path until Pi creates its session. + // Serialize that first-write window so two ADE runtimes cannot both + // create implicit sessions before either one can publish a concrete + // header-authoritative file pointer. + piCreationLease = acquirePiSessionLease({ + sessionFile: piSessionCreationLeaseTarget(sessionDir), + owner: "sdk", + ownerId: managed.session.id, + ...piLeaseIdentity, + }); + } + const systemPrompt = isPersonalSession(managed.session) + ? PERSONAL_CHAT_SYSTEM_PROMPT + : buildCodingAgentSystemPrompt({ + cwd: managed.laneWorktreePath, + mode: managed.session.interactionMode === "plan" || managed.session.permissionMode === "plan" ? "planning" : "coding", + permissionMode: toHarnessPermissionMode(managed.session.permissionMode), + interactive: true, + runtime: "pi-sdk", + adeSkillRoots: getAdeAgentSkillRootsForPrompt({ cwd: managed.laneWorktreePath }), + orchestrationRole: managed.session.orchestrationRole, + orchestrationRunId: managed.session.orchestrationRunId, + orchestrationBundlePath: managed.session.orchestrationBundlePath, + orchestrationTag: managed.session.orchestrationTag, + orchestrationParentSessionId: managed.session.orchestrationParentSessionId, + orchestrationStepId: managed.session.orchestrationStepId, + }); + const piPermissionMode = toHarnessPermissionMode(managed.session.permissionMode); + // Pi's built-in tool registry only contains read, bash, edit, and write. + // Passing ADE's generic grep/find/ls names would make the SDK launch fail. + const piTools = piToolsForPermissionMode(piPermissionMode); + let acquired: Awaited>; + try { + acquired = await acquirePiSdkConnection({ + poolKey, + packageRoot: installation.packageRoot, + packageEntry: installation.packageEntry, + cwd: managed.laneWorktreePath, + agentDir: installation.agentDir, + sessionDir, + tools: piTools, + ...(piProviderId && piModelId ? { modelRef: { provider: piProviderId, id: piModelId } } : {}), + thinkingLevel: managed.session.reasoningEffort ?? null, + systemPrompt, + skillsEnv: skillRoots.length ? { ADE_AGENT_SKILLS_DIRS: skillRoots.join(path.delimiter) } : {}, + ...(sessionFile || sessionId + ? { + session: { + ...(sessionFile ? { sessionFile } : {}), + ...(sessionId ? { sessionId } : {}), + }, + } + : {}), + baseEnv: runtimeEnv, + logger, + }); + } catch (error) { + piLease?.release(); + piCreationLease?.release(); + throw error; + } + const workerSessionFile = resolvePiSessionFile({ + cwd: managed.laneWorktreePath, + sessionId: acquired.pooled.sessionId ?? "", + sessionFile: acquired.pooled.sessionFile, + sessionDir, + env: runtimeEnv, + }); + if (!workerSessionFile) { + releasePiSdkConnection(poolKey, acquired.generation, () => { + piCreationLease?.release(); + piLease?.release(); + }); + throw new Error("Pi SDK worker returned a session outside the authorized native session directory."); + } + acquired.pooled.sessionFile = workerSessionFile; + if (!piLease) { + try { + piLease = acquirePiSessionLease({ + sessionFile: workerSessionFile, + owner: "sdk", + ownerId: managed.session.id, + ...piLeaseIdentity, + }); + } catch (error) { + releasePiSdkConnection(poolKey, acquired.generation, () => piCreationLease?.release()); + throw error; + } + } + if (piCreationLease) { + piCreationLease.release(); + piCreationLease = null; + } + if (piRuntimeSetupInterruptRequested.get(managed)) { + piRuntimeSetupInterruptRequested.delete(managed); + releasePiSdkConnection(poolKey, acquired.generation, () => { + piLease?.release(); + }); + throw new Error("Pi session interrupted during setup."); + } + const runtime: PiRuntime = { + kind: "pi", + poolKey, + poolGeneration: acquired.generation, + sdk: acquired.pooled, + activeTurnId: null, + busy: false, + interrupted: false, + workerFailed: false, + pendingSteers: [], + modelProviderId: piProviderId, + modelId: piModelId, + activeCompactionId: null, + lease: piLease, + }; + managed.runtime = runtime; + managed.runtimeInvalidated = false; + acquired.pooled.bridge.onEvent = (event) => { + if (managed.runtime !== runtime) return; + const eventRecord = asRecord(event); + if (eventRecord?.type === "compaction_start" && !runtime.activeCompactionId) { + runtime.activeCompactionId = randomUUID(); + } + const turnId = runtime.activeTurnId ?? undefined; + for (const mapped of mapPiSdkEventToChatEvents(event, turnId, runtime.activeCompactionId)) emitChatEvent(managed, mapped); + if (eventRecord?.type === "compaction_end") runtime.activeCompactionId = null; + if (eventRecord?.type === "session_info_changed") adoptRuntimeSessionTitle(managed, eventRecord, "pi_session_info"); + }; + acquired.pooled.bridge.onLifecycle = (event) => { + if (managed.runtime !== runtime) return; + if (event === "ready") { + reportProviderRuntimeReady("pi"); + persistChatState(managed); + } + }; + acquired.pooled.bridge.onError = (error, operation) => { + if (managed.runtime !== runtime) return; + logger.warn("agent_chat.pi_sdk_error", { sessionId: managed.session.id, operation: operation ?? null, error: error.message }); + if (operation === "worker") { + managed.runtimeInvalidated = true; + runtime.interrupted = true; + runtime.workerFailed = true; + if (runtime.activeTurnId) markSessionIdleWithFreshCache(managed); + teardownRuntime(managed, "project_close"); + persistChatState(managed); + } + }; + persistChatState(managed); + sessionService.setResumeCommand(managed.session.id, `chat:pi:${managed.session.id}`); + logger.info("agent_chat.pi_sdk_runtime_ready", { + sessionId: managed.session.id, + version: acquired.pooled.version, + profileId: piProfileId, + provider: piProviderId, + model: piModelId, + }); + return runtime; + }; + const startOpenCodeSessionRuntime = async (managed: ManagedChatSession): Promise<"handled" | "fallthrough"> => { const modelId = managed.session.modelId; if (!modelId) return "fallthrough"; @@ -11211,6 +11516,11 @@ export function createAgentChatService(args: { return "edit" as const; })(); + const piPermissionMode = permissions.providers?.pi + ?? (inProcessMode === "plan" || inProcessMode === "edit" || inProcessMode === "full-auto" || inProcessMode === "config-toml" + ? inProcessMode + : "edit"); + const budget = Number(chat.sessionBudgetUsd ?? permissions.cli?.maxBudgetUsd ?? NaN); const sessionBudgetUsd = Number.isFinite(budget) && budget > 0 ? budget : null; @@ -11250,6 +11560,7 @@ export function createAgentChatService(args: { codexSandboxMode: sandboxMode, claudePermissionMode, opencodePermissionMode, + piPermissionMode, sessionBudgetUsd, titleGenerationEnabled, titleModelId, @@ -11609,7 +11920,8 @@ export function createAgentChatService(args: { || managed.runtime?.kind === "codex" || managed.runtime?.kind === "opencode" || managed.runtime?.kind === "cursor" - || managed.runtime?.kind === "droid") + || managed.runtime?.kind === "droid" + || managed.runtime?.kind === "pi") ) { teardownRuntime(managed, "project_close"); refreshReconstructionContext(managed); @@ -11896,6 +12208,23 @@ export function createAgentChatService(args: { : !managed.runtimeInvalidated && (managed.seededDroidSdkSessionId || prevPersisted?.droidSdkSessionId) ? { droidSdkSessionId: managed.seededDroidSdkSessionId ?? prevPersisted?.droidSdkSessionId } : {}), + ...(managed.runtime?.kind === "pi" + ? { + ...(managed.runtime.sdk.sessionId ? { piSessionId: managed.runtime.sdk.sessionId } : {}), + ...(managed.runtime.sdk.sessionFile ? { piSessionFile: managed.runtime.sdk.sessionFile } : {}), + ...(managed.session.piProfileId ? { piProfileId: managed.session.piProfileId } : {}), + ...(managed.session.piProviderId ? { piProviderId: managed.session.piProviderId } : {}), + ...(managed.session.piModelId ? { piModelId: managed.session.piModelId } : {}), + } + : !managed.runtimeInvalidated && (managed.seededPiSessionId || prevPersisted?.piSessionId || prevPersisted?.piSessionFile) + ? { + ...(managed.seededPiSessionId || prevPersisted?.piSessionId ? { piSessionId: managed.seededPiSessionId ?? prevPersisted?.piSessionId } : {}), + ...(managed.seededPiSessionFile || prevPersisted?.piSessionFile ? { piSessionFile: managed.seededPiSessionFile ?? prevPersisted?.piSessionFile } : {}), + ...(prevPersisted?.piProfileId ? { piProfileId: prevPersisted.piProfileId } : {}), + ...(prevPersisted?.piProviderId ? { piProviderId: prevPersisted.piProviderId } : {}), + ...(prevPersisted?.piModelId ? { piModelId: prevPersisted.piModelId } : {}), + } + : {}), ...(managed.session.provider === "claude" && claudePersistedSdkSessionId ? { sdkSessionId: claudePersistedSdkSessionId } : managed.runtime?.kind === "claude" @@ -12097,7 +12426,7 @@ export function createAgentChatService(args: { const record = recovered.value as Partial; let provider = record.provider; if (provider === "unified") provider = "opencode"; - if (provider !== "codex" && provider !== "claude" && provider !== "opencode" && provider !== "cursor" && provider !== "droid") { + if (provider !== "codex" && provider !== "claude" && provider !== "opencode" && provider !== "cursor" && provider !== "droid" && provider !== "pi") { return null; } const laneId = String(record.laneId ?? "").trim(); @@ -12192,6 +12521,21 @@ export function createAgentChatService(args: { const providerSessionId = typeof record.providerSessionId === "string" && record.providerSessionId.trim().length ? record.providerSessionId.trim() : undefined; + const piSessionId = typeof record.piSessionId === "string" && record.piSessionId.trim().length + ? record.piSessionId.trim() + : undefined; + const piSessionFile = typeof record.piSessionFile === "string" && record.piSessionFile.trim().length + ? record.piSessionFile.trim() + : undefined; + const piProfileId = typeof record.piProfileId === "string" && record.piProfileId.trim().length + ? record.piProfileId.trim() + : undefined; + const piProviderId = typeof record.piProviderId === "string" && record.piProviderId.trim().length + ? record.piProviderId.trim() + : undefined; + const piModelId = typeof record.piModelId === "string" && record.piModelId.trim().length + ? record.piModelId.trim() + : undefined; const claudeBackgroundJobShort = provider === "claude" ? normalizeClaudeBackgroundShort(record.claudeBackgroundJobShort) : null; @@ -12302,6 +12646,11 @@ export function createAgentChatService(args: { ...(sdkSessionId ? { sdkSessionId } : {}), ...(forkFromSdkSessionId ? { forkFromSdkSessionId } : {}), ...(providerSessionId ? { providerSessionId } : {}), + ...(piSessionId ? { piSessionId } : {}), + ...(piSessionFile ? { piSessionFile } : {}), + ...(piProfileId ? { piProfileId } : {}), + ...(piProviderId ? { piProviderId } : {}), + ...(piModelId ? { piModelId } : {}), ...(claudeBackgroundJobShort ? { claudeBackgroundJobShort } : {}), ...(claudeBackgroundResumeSessionId ? { claudeBackgroundResumeSessionId } : {}), ...(claudeBackgroundLogText ? { claudeBackgroundLogText } : {}), @@ -12420,6 +12769,7 @@ export function createAgentChatService(args: { case "codex": return { threadId: candidate.pointer }; case "claude": return { sdkSessionId: candidate.pointer }; case "droid": return { droidSdkSessionId: candidate.pointer }; + case "pi": return { piSessionId: candidate.pointer }; case "cursor": return { cursorSdkAgentId: candidate.pointer }; case "opencode": return { providerSessionId: candidate.pointer }; default: return {}; @@ -16074,7 +16424,7 @@ export function createAgentChatService(args: { } const preserveProviderResumeState = - (managed.runtime.kind === "claude" || managed.runtime.kind === "cursor") && reasonAllowsPreservation; + (managed.runtime.kind === "claude" || managed.runtime.kind === "cursor" || managed.runtime.kind === "pi") && reasonAllowsPreservation; if (managed.runtime.kind === "codex") { const runtime = managed.runtime; const interruptedTurnId = runtime.activeTurnId ?? runtime.startedTurnId ?? null; @@ -16234,6 +16584,18 @@ export function createAgentChatService(args: { releaseDroidSdkConnection(rt.poolKey, rt.poolGeneration); managed.runtime = null; } + if (managed.runtime?.kind === "pi") { + const rt = managed.runtime; + rt.interrupted = true; + cancelQueuedSteers(managed, rt, "interrupted"); + if (preserveProviderResumeState) persistChatState(managed); + if (isPiSdkPooledAlive(rt.sdk)) { + void rt.sdk.abort().catch(() => {}); + } + const lease = rt.lease; + releasePiSdkConnection(rt.poolKey, rt.poolGeneration, () => lease?.release()); + managed.runtime = null; + } managed.runtimeInvalidated = !preserveProviderResumeState; if (!preserveProviderResumeState) { clearLaneDirectiveKey(managed); @@ -16492,14 +16854,14 @@ export function createAgentChatService(args: { const fallbackModel = persisted?.model ?? fallbackModelForProvider(provider); const hydratedModelId = persisted?.modelId ?? resolveModelIdFromStoredValue(fallbackModel, provider) - ?? (provider === "opencode" - ? DEFAULT_OPENCODE_MODEL_ID + ?? (provider === "opencode" || provider === "pi" + ? (provider === "pi" ? getDynamicPiModelDescriptors()[0]?.id : DEFAULT_OPENCODE_MODEL_ID) : provider === "cursor" ? DEFAULT_CURSOR_DESCRIPTOR?.id : provider === "droid" ? DEFAULT_DROID_DESCRIPTOR?.id : undefined); - const model = provider === "opencode" ? (hydratedModelId ?? fallbackModel) : fallbackModel; + const model = provider === "opencode" || provider === "pi" ? (hydratedModelId ?? fallbackModel) : fallbackModel; const lane = laneService.getLaneBaseAndBranch(row.laneId); const rowGoal = typeof row.goal === "string" && row.goal.trim().length ? row.goal.trim() @@ -16524,6 +16886,9 @@ export function createAgentChatService(args: { ...(persisted?.codexSandbox ? { codexSandbox: persisted.codexSandbox } : {}), ...(persisted?.codexConfigSource ? { codexConfigSource: persisted.codexConfigSource } : {}), ...(persisted?.opencodePermissionMode ? { opencodePermissionMode: persisted.opencodePermissionMode } : {}), + ...(persisted?.piProfileId ? { piProfileId: persisted.piProfileId } : {}), + ...(persisted?.piProviderId ? { piProviderId: persisted.piProviderId } : {}), + ...(persisted?.piModelId ? { piModelId: persisted.piModelId } : {}), ...(persisted?.cursorModeSnapshot ? { cursorModeSnapshot: persisted.cursorModeSnapshot } : {}), ...(persisted?.cursorModeId !== undefined ? { cursorModeId: persisted.cursorModeId } : {}), ...(persisted?.cursorConfigValues ? { cursorConfigValues: persisted.cursorConfigValues } : {}), @@ -21672,6 +22037,135 @@ export function createAgentChatService(args: { } }; + const runPiTurn = async ( + managed: ManagedChatSession, + args: { + promptText: string; + userText?: string; + displayText?: string; + attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; + resolvedAttachments?: ResolvedAgentChatFileRef[]; + metadata?: AgentChatEventMetadata | null | undefined; + laneDirectiveKey?: string | null; + onDispatched?: () => void; + onBackendDispatched?: () => void; + }, + ): Promise => { + const setupTurnId = randomUUID(); + let runtime: PiRuntime; + try { + runtime = await startPiRuntime(managed); + const validation = validateSessionReadyForTurn(managed); + if (!validation.ready) throw new Error(validation.reason); + } catch (error) { + markSessionIdleWithFreshCache(managed); + const message = error instanceof Error ? error.message : String(error); + reportProviderRuntimeFailure("pi", message); + emitChatEvent(managed, { type: "error", message, turnId: setupTurnId }); + emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId: setupTurnId }); + emitChatEvent(managed, { + type: "done", + turnId: setupTurnId, + status: "failed", + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); + appendCtoTurnJournal(managed, { failureNote: `Turn failed: ${message}` }); + persistChatState(managed); + return; + } + const turnId = setupTurnId; + runtime.busy = true; + runtime.activeTurnId = turnId; + runtime.interrupted = false; + setSessionActive(managed); + const attachments = args.attachments ?? []; + const displayText = args.displayText?.trim() || args.promptText; + emitPreparedUserMessage(managed, { + text: args.userText?.trim() || displayText, + displayText, + attachments, + contextAttachments: args.contextAttachments ?? [], + metadata: args.metadata, + turnId, + laneDirectiveKey: args.laneDirectiveKey, + onDispatched: args.onDispatched, + }); + emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); + captureTurnBeforeSha(managed); + emitChatEvent(managed, { type: "activity", ...initialTurnActivity(managed.session), turnId }); + try { + let prompt = args.promptText; + if (managed.pendingReconstructionContext?.trim()) { + prompt = `System context (ADE continuity, do not echo verbatim):\n${managed.pendingReconstructionContext.trim()}\n\n${prompt}`; + managed.pendingReconstructionContext = null; + } + if (!isPersonalSession(managed.session) && managed.lastLaneDirectiveKey !== args.laneDirectiveKey) { + const guidance = buildAdeGuidanceForLane(managed.laneWorktreePath, managed.session); + if (guidance.trim()) prompt = `${guidance}\n\n${prompt}`; + } + const promptBlocks = await buildAgentPromptBlocks(prompt, args.resolvedAttachments ?? []); + const promptText = promptBlocks + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join("\n\n"); + const images = promptBlocks + .filter((block): block is { type: "image"; data: string; mimeType: string } => block.type === "image") + .map(({ data, mimeType }) => ({ data, mimeType })); + args.onDispatched?.(); + const accepted = runtime.sdk.sendPrompt({ + prompt: promptText, + ...(images.length ? { images } : {}), + }); + args.onBackendDispatched?.(); + const result = await accepted; + const resultRecord = asRecord(result); + if (typeof resultRecord?.sessionFile === "string") runtime.sdk.sessionFile = resultRecord.sessionFile; + if (typeof resultRecord?.sessionId === "string") runtime.sdk.sessionId = resultRecord.sessionId; + persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); + markSessionIdleWithFreshCache(managed); + reportProviderRuntimeReady("pi"); + emitChatEvent(managed, { type: "status", turnStatus: runtime.interrupted ? "interrupted" : "completed", turnId }); + emitChatEvent(managed, { + type: "done", + turnId, + status: runtime.interrupted ? "interrupted" : "completed", + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); + persistChatState(managed); + } catch (error) { + markSessionIdleWithFreshCache(managed); + const message = error instanceof Error ? error.message : String(error); + if (!runtime.workerFailed && (runtime.interrupted || isAbortRelatedError(error))) { + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + emitChatEvent(managed, { type: "done", turnId, status: "interrupted", model: managed.session.model }); + } else { + reportProviderRuntimeFailure("pi", message); + emitChatEvent(managed, { type: "error", message, turnId }); + emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); + emitChatEvent(managed, { type: "done", turnId, status: "failed", model: managed.session.model }); + appendCtoTurnJournal(managed, { failureNote: `Turn failed: ${message}` }); + } + persistChatState(managed); + } finally { + if (managed.runtime === runtime) { + runtime.busy = false; + runtime.activeTurnId = null; + runtime.interrupted = false; + } + } + if (!managed.closed && managed.runtime === runtime && runtime.pendingSteers.length) { + await deliverNextQueuedSteer(managed, runtime).catch((error) => { + logger.warn("agent_chat.pi_deliver_queued_steer_failed", { + sessionId: managed.session.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + }; + // ── Streaming turn for OpenCode runtime ── const runTurn = async ( @@ -21696,6 +22190,9 @@ export function createAgentChatService(args: { ensureClaudeSessionRuntime(managed); return runClaudeTurn(managed, args); } + if (runtimeKind === "pi" || managed.session.provider === "pi") { + return runPiTurn(managed, args); + } if (runtimeKind !== "opencode") { throw new Error(`Streaming runtime is not available for session '${managed.session.id}'.`); } @@ -28186,7 +28683,7 @@ export function createAgentChatService(args: { const deliverNextQueuedSteer = async ( managed: ManagedChatSession, - runtime: CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime, + runtime: CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime | PiRuntime, ): Promise => { if (managed.closed) return false; // A user-selected priority dispatch owns the staged queue while its SDK @@ -28302,6 +28799,17 @@ export function createAgentChatService(args: { metadata: nextSteer.metadata, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); + } else if (runtime.kind === "pi") { + await runPiTurn(managed, { + promptText, + userText: trimmed, + displayText, + attachments: nextSteer.attachments, + contextAttachments: nextSteer.contextAttachments, + resolvedAttachments: nextSteer.resolvedAttachments, + metadata: nextSteer.metadata, + laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, + }); } else { await runTurn(managed, { promptText, @@ -29242,6 +29750,11 @@ export function createAgentChatService(args: { codexSandbox: requestedCodexSandbox, codexConfigSource: requestedCodexConfigSource, opencodePermissionMode: requestedOpenCodePermissionModeArg, + piProfileId: requestedPiProfileId, + piProviderId: requestedPiProviderId, + piModelId: requestedPiModelId, + piSessionId: requestedPiSessionId, + piSessionFile: requestedPiSessionFile, droidPermissionMode: requestedDroidPermissionModeArg, cursorModeId: requestedCursorModeId, cursorConfigValues: requestedCursorConfigValues, @@ -29311,7 +29824,9 @@ export function createAgentChatService(args: { ? DEFAULT_CURSOR_MODEL : provider === "droid" ? DEFAULT_DROID_MODEL - : ""); + : provider === "pi" + ? getDynamicPiModelDescriptors()[0]?.id ?? "pi/default" + : ""); const resolvedModelId = requestedModelDescriptor?.id ?? resolveModelIdFromStoredValue(normalizedInputModel, provider); @@ -29325,6 +29840,9 @@ export function createAgentChatService(args: { if (provider === "droid" && !resolvedModelId) { throw new Error("Droid chat requires a known model. Pick a Droid model from the model list."); } + if (provider === "pi" && !resolvedModelId) { + throw new Error("Pi chat requires a known model. Refresh the Pi model list and select a model."); + } const resolvedDescriptor = requestedModelDescriptor ?? (resolvedModelId ? getModelById(resolvedModelId) : undefined); if (resolvedModelId && !resolvedDescriptor) { @@ -29371,7 +29889,7 @@ export function createAgentChatService(args: { ?? resolvedDescriptor?.defaultReasoningEffort ?? DEFAULT_REASONING_EFFORT : normalizeReasoningEffort(reasoningEffort); - const normalizedReasoningEffort = effectiveProvider === "opencode" || effectiveProvider === "cursor" || effectiveProvider === "droid" + const normalizedReasoningEffort = effectiveProvider === "opencode" || effectiveProvider === "cursor" || effectiveProvider === "droid" || effectiveProvider === "pi" ? validateRuntimeReasoningEffortForDescriptor(rawEffort, resolvedDescriptor) : validateReasoningEffortForDescriptor( effectiveProvider === "claude" ? "claude" : "codex", @@ -29478,6 +29996,11 @@ export function createAgentChatService(args: { ?? "auto-low", }; } + if (effectiveProvider === "pi") { + return { + permissionMode: effectivePermissionMode ?? chatConfig.piPermissionMode, + }; + } return { opencodePermissionMode: requestedOpenCodePermissionMode ?? legacyPermissionModeToOpenCodePermissionMode(effectivePermissionMode) @@ -29523,6 +30046,21 @@ export function createAgentChatService(args: { provider: effectiveProvider, model: normalizedModel, ...(resolvedModelId ? { modelId: resolvedModelId } : {}), + ...(effectiveProvider === "pi" && (requestedPiProfileId ?? resolvedDescriptor?.piProfileId) + ? { piProfileId: requestedPiProfileId ?? resolvedDescriptor?.piProfileId } + : {}), + ...(effectiveProvider === "pi" && (requestedPiProviderId ?? resolvedDescriptor?.piProviderId) + ? { piProviderId: requestedPiProviderId ?? resolvedDescriptor?.piProviderId } + : {}), + ...(effectiveProvider === "pi" && (requestedPiModelId ?? resolvedDescriptor?.piModelId) + ? { piModelId: requestedPiModelId ?? resolvedDescriptor?.piModelId } + : {}), + ...(effectiveProvider === "pi" && requestedPiSessionId + ? { piSessionId: requestedPiSessionId } + : {}), + ...(effectiveProvider === "pi" && requestedPiSessionFile + ? { piSessionFile: requestedPiSessionFile } + : {}), sessionProfile: sessionProfile ?? "workflow", ...(normalizedReasoningEffort ? { reasoningEffort: normalizedReasoningEffort } : {}), ...(initialFastMode ? { fastMode: true } : {}), @@ -35370,6 +35908,49 @@ export function createAgentChatService(args: { return; } + if (managed.session.provider === "pi") { + if (reasoningEffort !== undefined) { + managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); + } + const compactMatch = submittedText.match(/^\/compact(?:\s+([\s\S]*))?$/i); + if (compactMatch) { + const runtime = await startPiRuntime(managed); + const validation = validateSessionReadyForTurn(managed); + if (!validation.ready) throw new Error(validation.reason); + runtime.busy = true; + runtime.interrupted = false; + onDispatched?.(); + try { + await runtime.sdk.compact({ + ...(compactMatch[1]?.trim() ? { customInstructions: compactMatch[1].trim() } : {}), + }); + onBackendDispatched?.(); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Pi context compaction completed.", + }); + persistChatState(managed); + } finally { + if (managed.runtime === runtime) runtime.busy = false; + } + return; + } + await runPiTurn(managed, { + promptText, + userText: submittedText, + displayText: visibleText, + attachments, + contextAttachments, + resolvedAttachments, + metadata, + laneDirectiveKey, + onDispatched, + onBackendDispatched, + }); + return; + } + if (managed.session.provider === "codex") { const runtime = await ensureCodexSessionRuntime(managed); const nextReasoningEffort = validateReasoningEffortForDescriptor( @@ -36020,6 +36601,60 @@ export function createAgentChatService(args: { return { steerId, queued: false }; } + if (managed.session.provider === "pi") { + if (managed.runtime?.kind === "pi" && (managed.runtime.busy || managed.session.status === "active")) { + const runtime = managed.runtime; + const preparedSteer = prepareSendMessage({ + sessionId, + text: trimmed, + displayText: displayText ?? trimmed, + attachments, + contextAttachments, + metadata, + allowPendingInput: options?.allowPendingInput, + allowActiveSession: true, + }); + if (!preparedSteer) return { steerId, queued: false }; + const promptBlocks = await buildAgentPromptBlocks(preparedSteer.submittedText, preparedSteer.resolvedAttachments); + const promptText = promptBlocks + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join("\n\n"); + const images = promptBlocks + .filter((block): block is { type: "image"; data: string; mimeType: string } => block.type === "image") + .map(({ data, mimeType }) => ({ data, mimeType })); + await runtime.sdk.steer(promptText, images); + preparedSteer.onDispatched?.(); + options?.onAcceptedDispatch?.(); + emitChatEvent(managed, { + type: "user_message", + text: preparedSteer.visibleText, + ...(preparedSteer.attachments.length ? { attachments: preparedSteer.attachments } : {}), + ...(preparedSteer.contextAttachments.length ? { contextAttachments: preparedSteer.contextAttachments } : {}), + ...(preparedSteer.metadata ? { metadata: preparedSteer.metadata } : {}), + steerId, + turnId: runtime.activeTurnId ?? undefined, + deliveryState: "inline", + }); + persistDeliveredLaneDirectiveKey(managed, preparedSteer.laneDirectiveKey); + persistChatState(managed); + return { steerId, queued: false }; + } + const preparedSteer = prepareSendMessage({ + sessionId, + text: trimmed, + displayText: displayText ?? trimmed, + attachments, + contextAttachments, + metadata, + allowPendingInput: options?.allowPendingInput, + }); + if (!preparedSteer) return { steerId, queued: false }; + preparedSteer.onBackendDispatched = options?.onAcceptedDispatch; + await executePreparedSendMessage(preparedSteer); + return { steerId, queued: false }; + } + if (managed.session.provider === "cursor") { if (managed.runtime?.kind === "cursor" && managed.runtime.busy) { const rt = managed.runtime; @@ -36363,6 +36998,7 @@ export function createAgentChatService(args: { managed.session.provider === "opencode" || managed.session.provider === "cursor" || managed.session.provider === "droid" + || managed.session.provider === "pi" ) && !canRouteActiveSendToSteer(managed); let markersCleared = false; @@ -36872,6 +37508,28 @@ export function createAgentChatService(args: { return result; } + if (managed.runtime?.kind === "pi") { + const rt = managed.runtime; + rt.interrupted = true; + rt.pendingSteers.length = 0; + try { + await rt.sdk.abort(); + } catch { + // ignore + } + cancelQueuedSteers(managed, rt, "interrupted"); + persistChatState(managed); + return result; + } + + if (managed.session.provider === "pi") { + piRuntimeSetupInterruptRequested.set(managed, true); + cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted"); + setSessionIdle(managed); + persistChatState(managed); + return result; + } + if (managed.runtime?.kind === "droid") { const rt = managed.runtime; rt.interrupted = true; @@ -37223,6 +37881,16 @@ export function createAgentChatService(args: { } managed.session.codexConfigSource = persisted?.codexConfigSource ?? managed.session.codexConfigSource; managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; + } else if (managed.session.provider === "pi") { + await startPiRuntime(managed); + managed.session.piSessionId = managed.runtime?.kind === "pi" ? managed.runtime.sdk.sessionId ?? managed.session.piSessionId : managed.session.piSessionId; + managed.session.piSessionFile = managed.runtime?.kind === "pi" ? managed.runtime.sdk.sessionFile ?? managed.session.piSessionFile : managed.session.piSessionFile; + managed.session.piProfileId = persisted?.piProfileId ?? managed.session.piProfileId; + managed.session.piProviderId = persisted?.piProviderId ?? managed.session.piProviderId; + managed.session.piModelId = persisted?.piModelId ?? managed.session.piModelId; + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; + enforceManagedLocalHarnessPermissionMode(managed); + sessionService.setResumeCommand(sessionId, `chat:pi:${sessionId}`); } else if (managed.session.provider === "cursor") { await ensureCursorRuntime(managed); managed.session.opencodePermissionMode = persisted?.opencodePermissionMode ?? managed.session.opencodePermissionMode; @@ -38004,6 +38672,8 @@ export function createAgentChatService(args: { const permission = runtime.permissionWaiters.keys().next().value; return typeof permission === "string" && permission.trim().length ? permission : null; } + case "pi": + return null; } }; @@ -38104,14 +38774,14 @@ export function createAgentChatService(args: { const hydratedModelId = liveSession?.modelId ?? persisted?.modelId ?? resolveModelIdFromStoredValue(fallbackModel, provider) - ?? (provider === "opencode" - ? DEFAULT_OPENCODE_MODEL_ID + ?? (provider === "opencode" || provider === "pi" + ? (provider === "pi" ? getDynamicPiModelDescriptors()[0]?.id : DEFAULT_OPENCODE_MODEL_ID) : provider === "cursor" ? DEFAULT_CURSOR_DESCRIPTOR?.id : provider === "droid" ? DEFAULT_DROID_DESCRIPTOR?.id : undefined); - const model = provider === "opencode" ? (hydratedModelId ?? fallbackModel) : fallbackModel; + const model = provider === "opencode" || provider === "pi" ? (hydratedModelId ?? fallbackModel) : fallbackModel; const claudeBackgroundJobShort = provider === "claude" ? liveManaged?.claudeBackgroundJobShort ?? persisted?.claudeBackgroundJobShort ?? null : null; @@ -38196,6 +38866,11 @@ export function createAgentChatService(args: { ...(liveSession?.opencodePermissionMode || persisted?.opencodePermissionMode ? { opencodePermissionMode: liveSession?.opencodePermissionMode ?? persisted?.opencodePermissionMode } : {}), + ...((liveSession?.piProfileId ?? persisted?.piProfileId) ? { piProfileId: liveSession?.piProfileId ?? persisted?.piProfileId } : {}), + ...((liveSession?.piProviderId ?? persisted?.piProviderId) ? { piProviderId: liveSession?.piProviderId ?? persisted?.piProviderId } : {}), + ...((liveSession?.piModelId ?? persisted?.piModelId) ? { piModelId: liveSession?.piModelId ?? persisted?.piModelId } : {}), + ...((liveSession?.piSessionId ?? persisted?.piSessionId) ? { piSessionId: liveSession?.piSessionId ?? persisted?.piSessionId } : {}), + ...((liveSession?.piSessionFile ?? persisted?.piSessionFile) ? { piSessionFile: liveSession?.piSessionFile ?? persisted?.piSessionFile } : {}), ...(liveSession?.droidPermissionMode || persisted?.droidPermissionMode ? { droidPermissionMode: liveSession?.droidPermissionMode ?? persisted?.droidPermissionMode } : {}), @@ -39607,6 +40282,7 @@ export function createAgentChatService(args: { const MODEL_CATALOG_LOCAL_REFRESH_TTL_MS = 30_000; const MODEL_CATALOG_REFRESH_PROVIDERS: AgentChatModelCatalogRefreshProvider[] = [ "opencode", + "pi", "cursor", "droid", "lmstudio", @@ -39858,6 +40534,31 @@ export function createAgentChatService(args: { } } + if (provider === "pi") { + try { + if (args.activateRuntime) { + const inventory = await probePiProfileInventory(resolvePiInstallation()); + replaceDynamicPiModelDescriptors(piModelDescriptorsFromInventory(inventory)); + } + const models = getDynamicPiModelDescriptors(); + const preferred = models[0]?.id; + return models.map((descriptor) => ({ + id: descriptor.id, + displayName: descriptor.displayName, + description: `${descriptor.displayName} (Pi SDK)`, + isDefault: descriptor.id === preferred, + reasoningEfforts: descriptor.reasoningTiers?.map((tier) => ({ effort: tier, description: `${tier} reasoning` })) ?? [], + modelId: descriptor.id, + family: "pi", + supportsReasoning: descriptor.capabilities.reasoning, + supportsTools: descriptor.capabilities.tools, + color: descriptor.color, + })); + } catch { + return []; + } + } + if (provider === "opencode") { try { const effectiveConfig = projectConfigService.get().effective; @@ -39972,6 +40673,7 @@ export function createAgentChatService(args: { "cursor", "droid", "opencode", + "pi", ] as const satisfies readonly AgentChatProvider[]; const getAvailableModels = async ({ @@ -40031,8 +40733,9 @@ export function createAgentChatService(args: { shouldRefreshProvider("opencode") || shouldRefreshProvider("lmstudio") || shouldRefreshProvider("ollama"); + const shouldRefreshPi = shouldRefreshProvider("pi") || (mode === "cached" && !modelCatalogCache); - const catalogProviders: ModelProviderGroup[] = ["claude", "codex", "cursor", "droid"]; + const catalogProviders: ModelProviderGroup[] = ["claude", "codex", "cursor", "droid", "pi"]; const modelsByProvider = await Promise.all( catalogProviders.map(async (provider) => { try { @@ -40042,7 +40745,8 @@ export function createAgentChatService(args: { provider, activateRuntime: (provider === "cursor" && shouldRefreshProvider("cursor")) - || (provider === "droid" && shouldRefreshProvider("droid")), + || (provider === "droid" && shouldRefreshProvider("droid")) + || (provider === "pi" && shouldRefreshPi), ...(provider === "cursor" && catalogArgs?.cursorSource ? { cursorSource: catalogArgs.cursorSource } : {}), @@ -40113,7 +40817,8 @@ export function createAgentChatService(args: { ...(info.cursorCliVariants?.length ? { cursorCliVariants: info.cursorCliVariants } : descriptor.cursorCliVariants?.length ? { cursorCliVariants: descriptor.cursorCliVariants } : {}), }; descriptors.push(patched); - descriptorInfo.set(catalogDescriptorInfoKey(provider, patched.family, patched.id), { provider, info }); + const providerKey = provider === "pi" ? patched.piProviderId ?? patched.family : patched.family; + descriptorInfo.set(catalogDescriptorInfoKey(provider, providerKey, patched.id), { provider, info }); } } @@ -40169,7 +40874,9 @@ export function createAgentChatService(args: { models: subsection.models.map((descriptor) => { const entry = descriptorInfo.get(catalogDescriptorInfoKey(group.key, provider.key, descriptor.id)); const runtimeProvider = entry?.provider ?? resolveProviderGroupForModel(descriptor); - const runtimeModelId = entry?.info.id ?? getRuntimeModelRefForDescriptor(descriptor, runtimeProvider); + const runtimeModelId = entry?.provider === "pi" + ? getRuntimeModelRefForDescriptor(descriptor, "pi") + : entry?.info.id ?? getRuntimeModelRefForDescriptor(descriptor, runtimeProvider); const providerMeta = descriptor.openCodeProviderId ? opencodeProviderById.get(descriptor.openCodeProviderId) : group.key === "opencode" || group.key === "ollama" || group.key === "lmstudio" @@ -40715,6 +41422,17 @@ export function createAgentChatService(args: { managed.session.provider = nextProvider; managed.session.modelId = descriptor.id; managed.session.model = nextModel; + if (nextProvider === "pi") { + managed.session.piProfileId = descriptor.piProfileId ?? "default"; + managed.session.piProviderId = descriptor.piProviderId ?? null; + managed.session.piModelId = descriptor.piModelId ?? (descriptor.providerModelId.split("/").slice(1).join("/") || null); + } else { + delete managed.session.piProfileId; + delete managed.session.piProviderId; + delete managed.session.piModelId; + delete managed.session.piSessionId; + delete managed.session.piSessionFile; + } if (nextProvider === "claude" && !modelSupportsFastMode(descriptor)) { delete managed.session.fastMode; } @@ -40794,6 +41512,15 @@ export function createAgentChatService(args: { managed.runtime.threadResumed = false; managed.runtime.canAttachResumedTurnStart = false; } + if (reasoningEffort !== undefined && managed.runtime?.kind === "pi" && managed.session.reasoningEffort) { + await managed.runtime.sdk.setThinking(managed.session.reasoningEffort).catch((error) => { + logger.warn("agent_chat.pi_set_thinking_failed", { + sessionId, + thinkingLevel: managed.session.reasoningEffort, + error: error instanceof Error ? error.message : String(error), + }); + }); + } } else if (reasoningEffort !== undefined) { const prev = managed.session.reasoningEffort ?? null; const requested = normalizeReasoningEffort(reasoningEffort); @@ -40821,6 +41548,15 @@ export function createAgentChatService(args: { managed.runtime.threadResumed = false; managed.runtime.canAttachResumedTurnStart = false; } + if (prev !== next && managed.runtime?.kind === "pi" && next) { + await managed.runtime.sdk.setThinking(next).catch((error) => { + logger.warn("agent_chat.pi_set_thinking_failed", { + sessionId, + thinkingLevel: next, + error: error instanceof Error ? error.message : String(error), + }); + }); + } // A reasoning-only change on the CTO thread must also land in identity // modelPreferences, or the next ensured session resurrects the old tier. if (managed.session.identityKey === "cto" && prev !== next) { @@ -41192,7 +41928,10 @@ export function createAgentChatService(args: { const localCommands: AgentChatSlashCommand[] = provider === "claude" || provider === "codex" ? [] - : [{ name: "/clear", description: "Clear chat history", source: "local" }]; + : [ + { name: "/clear", description: "Clear chat history", source: "local" }, + ...(provider === "pi" ? [{ name: "/compact", description: "Summarize older context to free tokens.", source: "local" as const, argumentHint: "[instructions]" }] : []), + ]; const mergeSlashCommands = (groups: AgentChatSlashCommand[][]): AgentChatSlashCommand[] => { const merged = new Map(); diff --git a/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts b/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts index 4f979747f3..26919c3e69 100644 --- a/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts +++ b/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts @@ -29,6 +29,8 @@ function resolveCompactionProvider(session: AgentChatSession): ContextCompactPro return "cursor"; case "droid": return "droid"; + case "pi": + return "pi"; default: return undefined; } diff --git a/apps/desktop/src/main/services/chat/piSdkEnvironment.test.ts b/apps/desktop/src/main/services/chat/piSdkEnvironment.test.ts new file mode 100644 index 0000000000..f90c1ccb2f --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkEnvironment.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildPiWorkerEnvironment } from "./piSdkEnvironment"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("buildPiWorkerEnvironment", () => { + it("passes declared custom provider variables without inheriting ADE control variables", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-env-")); + roots.push(root); + fs.writeFileSync(path.join(root, "models.json"), JSON.stringify({ + providers: { + custom: { + apiKey: "$CUSTOM_PI_KEY", + headers: { + "X-Endpoint-Token": "${CUSTOM_PI_HEADER}", + "X-ADE-Token": "$ADE_BROWSER_ACTOR_TOKEN", + }, + }, + }, + })); + + const env = buildPiWorkerEnvironment({ + PATH: "/bin", + CUSTOM_PI_KEY: "key", + CUSTOM_PI_HEADER: "header", + ADE_BROWSER_ACTOR_TOKEN: "must-not-cross", + ADE_CHAT_SESSION_ID: "chat-1", + }, root); + + expect(env).toMatchObject({ PATH: "/bin", CUSTOM_PI_KEY: "key", CUSTOM_PI_HEADER: "header" }); + expect(env).not.toHaveProperty("ADE_BROWSER_ACTOR_TOKEN"); + expect(env).not.toHaveProperty("ADE_CHAT_SESSION_ID"); + }); + + it("does not treat escaped dollar references as environment requirements", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-env-")); + roots.push(root); + fs.writeFileSync(path.join(root, "models.json"), JSON.stringify({ + providers: { custom: { apiKey: "$$NOT_AN_ENV" } }, + })); + + const env = buildPiWorkerEnvironment({ NOT_AN_ENV: "secret" }, root); + + expect(env).not.toHaveProperty("NOT_AN_ENV"); + }); +}); diff --git a/apps/desktop/src/main/services/chat/piSdkEnvironment.ts b/apps/desktop/src/main/services/chat/piSdkEnvironment.ts new file mode 100644 index 0000000000..617f70dec8 --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkEnvironment.ts @@ -0,0 +1,110 @@ +import fs from "node:fs"; +import path from "node:path"; + +const PI_STANDARD_ENVIRONMENT_KEYS = [ + "PATH", "Path", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA", + "TEMP", "TMP", "TMPDIR", "SystemRoot", "ComSpec", "COMSPEC", "OS", "PATHEXT", + "LANG", "LC_ALL", "LC_CTYPE", "TERM", "COLORTERM", + "PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR", "PI_OFFLINE", + "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", + "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID", + "GOOGLE_API_KEY", "GEMINI_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS", + "MISTRAL_API_KEY", "DEEPSEEK_API_KEY", "XAI_API_KEY", "GROQ_API_KEY", + "TOGETHER_API_KEY", "OPENROUTER_API_KEY", "CEREBRAS_API_KEY", "PERPLEXITY_API_KEY", + "COHERE_API_KEY", "MINIMAX_API_KEY", "MOONSHOT_API_KEY", "ZAI_API_KEY", + "OLLAMA_API_KEY", "LM_STUDIO_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", +] as const; + +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u; + +function isBlockedPiEnvironmentName(name: string): boolean { + return name.toUpperCase().startsWith("ADE_"); +} + +function configEnvironmentNames(value: string): string[] { + const names: string[] = []; + for (let index = 0; index < value.length;) { + const dollar = value.indexOf("$", index); + if (dollar < 0) break; + const next = value[dollar + 1]; + if (next === "$" || next === "!") { + index = dollar + 2; + continue; + } + if (next === "{") { + const end = value.indexOf("}", dollar + 2); + if (end >= 0) { + const name = value.slice(dollar + 2, end); + if (ENV_NAME.test(name) && !names.includes(name)) names.push(name); + index = end + 1; + continue; + } + } + const match = /^([A-Za-z_][A-Za-z0-9_]*)/u.exec(value.slice(dollar + 1)); + if (match) { + if (!names.includes(match[1]!)) names.push(match[1]!); + index = dollar + 1 + match[1]!.length; + continue; + } + index = dollar + 1; + } + return names; +} + +function declaredPiEnvironmentNames(agentDir: string | undefined): Set { + if (!agentDir) return new Set(); + try { + const parsed = JSON.parse(fs.readFileSync(path.join(agentDir, "models.json"), "utf8")) as unknown; + const names = new Set(); + const collect = (value: unknown): void => { + if (typeof value === "string") { + for (const name of configEnvironmentNames(value)) { + if (!isBlockedPiEnvironmentName(name)) names.add(name); + } + return; + } + if (Array.isArray(value)) { + for (const item of value) collect(item); + return; + } + if (value && typeof value === "object") { + for (const child of Object.values(value)) collect(child); + } + }; + const walk = (value: unknown): void => { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) walk(item); + return; + } + for (const [key, child] of Object.entries(value)) { + if (key === "apiKey" || key === "headers") collect(child); + else walk(child); + } + }; + walk(parsed); + return names; + } catch { + return new Set(); + } +} + +/** + * Pass Pi only the environment it needs. Custom providers may declare + * `$ENV_NAME` references in models.json; those names are approved by the + * user's Pi config and are copied without exposing unrelated ADE variables. + */ +export function buildPiWorkerEnvironment( + source: NodeJS.ProcessEnv, + agentDir?: string | null, +): NodeJS.ProcessEnv { + const allowed = new Set(PI_STANDARD_ENVIRONMENT_KEYS); + for (const name of declaredPiEnvironmentNames(agentDir ?? source.PI_CODING_AGENT_DIR)) { + if (!isBlockedPiEnvironmentName(name)) allowed.add(name); + } + const result: NodeJS.ProcessEnv = {}; + for (const key of allowed) { + if (typeof source[key] === "string") result[key] = source[key]; + } + return result; +} diff --git a/apps/desktop/src/main/services/chat/piSdkEventMapper.ts b/apps/desktop/src/main/services/chat/piSdkEventMapper.ts new file mode 100644 index 0000000000..fc6b550723 --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkEventMapper.ts @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; +import type { AgentChatEvent } from "../../../shared/types/chat"; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +/** Translate untrusted Pi SDK events into ADE's durable chat event contract. */ +export function mapPiSdkEventToChatEvents( + event: unknown, + turnId?: string, + compactionId?: string | null, +): AgentChatEvent[] { + const record = asRecord(event); + if (!record) return []; + const type = typeof record.type === "string" ? record.type : ""; + if (type === "message_update") { + const assistant = asRecord(record.assistantMessageEvent); + if (!assistant) return []; + if (assistant.type === "text_delta" && typeof assistant.delta === "string" && assistant.delta.length) { + return [{ type: "text", text: assistant.delta, turnId }]; + } + if (assistant.type === "thinking_delta" && typeof assistant.delta === "string" && assistant.delta.length) { + return [{ type: "reasoning", text: assistant.delta, turnId }]; + } + if (assistant.type === "error") { + const message = typeof assistant.errorMessage === "string" && assistant.errorMessage.trim() + ? assistant.errorMessage.trim() + : "Pi reported an assistant error."; + return [{ type: "error", message, turnId }]; + } + return []; + } + if (type === "tool_execution_start") { + return [{ + type: "tool_call", + tool: typeof record.toolName === "string" ? record.toolName : "pi_tool", + args: record.args ?? {}, + itemId: typeof record.toolCallId === "string" && record.toolCallId.length ? record.toolCallId : randomUUID(), + turnId, + }]; + } + if (type === "tool_execution_end") { + const toolId = typeof record.toolCallId === "string" && record.toolCallId.length ? record.toolCallId : randomUUID(); + const failed = record.isError === true; + return [{ + type: "tool_result", + tool: typeof record.toolName === "string" ? record.toolName : "pi_tool", + result: record.result ?? (failed ? "Pi tool failed." : ""), + itemId: toolId, + status: failed ? "failed" : "completed", + turnId, + }]; + } + if (type === "bash_execution_update" && typeof record.delta === "string" && record.delta.length) { + return [{ type: "activity", activity: "running_command", detail: record.delta, turnId }]; + } + if (type === "compaction_start" || type === "compaction_end") { + return [{ + type: "context_compact", + trigger: record.reason === "manual" ? "manual" : "auto", + provider: "pi", + state: type === "compaction_start" ? "started" : "completed", + ...(compactionId ? { compactionId } : {}), + ...(turnId ? { turnId } : {}), + }]; + } + if (type === "auto_retry_start") { + return [{ + type: "activity", + activity: "working", + detail: typeof record.errorMessage === "string" ? record.errorMessage : "Pi is retrying the provider request.", + ...(turnId ? { turnId } : {}), + }]; + } + if (type === "session_info_changed") { + const name = typeof record.name === "string" ? record.name.trim() : ""; + return name ? [{ type: "system_notice", noticeKind: "info", message: `Pi session renamed to ${name}.`, turnId }] : []; + } + return []; +} diff --git a/apps/desktop/src/main/services/chat/piSdkPool.ts b/apps/desktop/src/main/services/chat/piSdkPool.ts new file mode 100644 index 0000000000..c005b5fb12 --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkPool.ts @@ -0,0 +1,488 @@ +import { fork, type ChildProcess, type ForkOptions } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Logger } from "../logging/logger"; +import { terminateChildProcessTree } from "../shared/utils"; +import { + PI_SDK_PROTOCOL_VERSION, + parsePiSdkWorkerResponse, + parsePiSdkWorkerRequest, + validatePiSdkWorkerRequest, + validatePiSdkWorkerResult, + type JsonValue, + type PiSdkCompactPayload, + type PiSdkModelRef, + type PiSdkPackageLocation, + type PiSdkPromptPayload, + type PiSdkImage, + type PiSdkReady, + type PiSdkWorkerInit, + type PiSdkWorkerRequest, +} from "./piSdkProtocol"; +import { buildPiWorkerEnvironment } from "./piSdkEnvironment"; + +export type PiSdkBridge = { + onEvent: ((event: JsonValue) => void) | null; + onLifecycle: ((event: string, requestId?: string, detail?: JsonValue) => void) | null; + onError: ((error: Error, operation?: string, requestId?: string) => void) | null; + onReady: ((ready: PiSdkReady) => void) | null; +}; + +type PiSdkRequestType = PiSdkWorkerRequest["type"]; +type PiSdkRequestPayload = + Extract extends { payload?: infer P } ? P : never; +type PiSdkRequestArgs = K extends + | "init" + | "send" + | "steer" + | "follow_up" + | "set_model" + | "set_thinking" + ? [payload: PiSdkRequestPayload] + : K extends "compact" + ? [payload?: PiSdkRequestPayload] + : []; +type PiSdkRequestResult = K extends "init" | "set_model" | "set_thinking" + ? PiSdkReady + : K extends "models" + ? JsonValue[] + : K extends "auth" + ? JsonValue + : JsonValue | undefined; + +export type PiSdkPooled = { + process: ChildProcess; + bridge: PiSdkBridge; + ready: PiSdkReady | null; + sessionFile: string | null; + sessionId: string | null; + currentModel: JsonValue | null; + version: string | null; + availableModels: JsonValue[]; + request: (type: K, ...args: PiSdkRequestArgs) => Promise>; + sendPrompt: (payload: PiSdkPromptPayload) => Promise; + steer: (prompt: string, images?: PiSdkImage[]) => Promise; + followUp: (prompt: string, images?: PiSdkImage[]) => Promise; + abort: () => Promise; + setModel: (modelRef: PiSdkModelRef) => Promise; + setThinking: (thinkingLevel: string) => Promise; + compact: (payload?: PiSdkCompactPayload) => Promise; + requestModels: () => Promise; + requestAuth: () => Promise; + dispose: () => void; + /** Resolves only after the worker process has actually exited. */ + waitForExit: () => Promise; +}; + +export type AcquirePiSdkConnectionArgs = PiSdkPackageLocation & { + /** Stable for the lifetime of the ADE chat session. */ + poolKey: string; + cwd: string; + agentDir: string; + sessionDir?: string | null; + modelRef?: PiSdkModelRef | null; + thinkingLevel?: string | null; + systemPrompt?: string | null; + skillsEnv?: Record; + inventoryOnly?: boolean; + session?: PiSdkWorkerInit["session"]; + tools?: string[]; + noTools?: PiSdkWorkerInit["noTools"]; + /** Usually process.env; never put auth.json or API keys in this payload. */ + baseEnv?: NodeJS.ProcessEnv; + logger?: Logger; +}; + +type PendingRpc = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + type: PiSdkWorkerRequest["type"]; + timer: NodeJS.Timeout | null; +}; + +type PoolEntry = { ref: number; generation: number; pooled: PiSdkPooled }; + +let generationCounter = 0; +const pools = new Map(); +const pendingInits = new Map>(); +const STALE_INIT_RETRY_LIMIT = 2; +const DISPOSE_GRACE_MS = 1_500; +const REQUEST_TIMEOUT_MS: Partial> = { + init: 30_000, + models: 30_000, + auth: 15_000, + set_model: 30_000, + set_thinking: 15_000, + abort: 10_000, +}; +const moduleDir = typeof __dirname === "string" ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + +function resolveWorkerPath(): string { + const candidates = [ + path.join(moduleDir, "piSdkWorker.cjs"), + path.join(process.cwd(), "dist", "main", "piSdkWorker.cjs"), + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0]!; +} + +function isAlive(pooled: PiSdkPooled): boolean { + return pooled.process.exitCode == null && !pooled.process.killed && pooled.process.connected !== false; +} + +export function isPiSdkPooledAlive(pooled: PiSdkPooled): boolean { + return isAlive(pooled); +} + +function workerError(operation: string, message: string, detail?: JsonValue): Error { + const error = new Error(`Pi SDK ${operation} failed: ${message || "unknown error"}`) as Error & { piSdkDetail?: JsonValue }; + if (detail !== undefined) error.piSdkDetail = detail; + return error; +} + +function applyReady(pooled: PiSdkPooled, value: PiSdkReady): void { + pooled.ready = value; + pooled.sessionFile = value.sessionFile; + pooled.sessionId = value.sessionId; + pooled.currentModel = value.currentModel; + pooled.version = value.version; + pooled.availableModels = value.availableModels; +} + +export async function acquirePiSdkConnection( + args: AcquirePiSdkConnectionArgs, +): Promise<{ pooled: PiSdkPooled; generation: number }> { + if (!args.poolKey.trim()) throw new Error("Pi SDK poolKey must be non-empty."); + for (let retries = 0; ; retries += 1) { + const existing = pools.get(args.poolKey); + if (existing && isAlive(existing.pooled)) { + existing.ref += 1; + return { pooled: existing.pooled, generation: existing.generation }; + } + if (existing) { + pools.delete(args.poolKey); + existing.pooled.dispose(); + } + + let owner = false; + let init = pendingInits.get(args.poolKey); + if (!init) { + owner = true; + init = createPiSdkConnection(args).finally(() => pendingInits.delete(args.poolKey)); + pendingInits.set(args.poolKey, init); + } + const pooled = await init; + const entry = pools.get(args.poolKey); + if (!entry || entry.pooled !== pooled || !isAlive(pooled)) { + if (owner) throw new Error("Pi SDK worker was disposed during initialization."); + if (retries >= STALE_INIT_RETRY_LIMIT) throw new Error("Pi SDK worker initialization did not settle after retries."); + continue; + } + if (!owner) entry.ref += 1; + return { pooled: entry.pooled, generation: entry.generation }; + } +} + +export const acquirePiSdkWorker = acquirePiSdkConnection; + +function createPiSdkConnection(args: AcquirePiSdkConnectionArgs): Promise { + const initPayload: PiSdkWorkerInit = { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + ...(args.packageDir ? { packageDir: args.packageDir } : {}), + ...(args.packageRoot ? { packageRoot: args.packageRoot } : {}), + ...(args.packageEntry ? { packageEntry: args.packageEntry } : {}), + cwd: args.cwd, + agentDir: args.agentDir, + sessionDir: args.sessionDir ?? null, + modelRef: args.modelRef ?? null, + thinkingLevel: args.thinkingLevel ?? null, + systemPrompt: args.systemPrompt ?? null, + ...(args.skillsEnv ? { skillsEnv: args.skillsEnv } : {}), + ...(args.inventoryOnly ? { inventoryOnly: true } : {}), + ...(args.session ? { session: args.session } : {}), + ...(args.tools ? { tools: args.tools } : {}), + ...(args.noTools ? { noTools: args.noTools } : {}), + }; + const initMessage: PiSdkWorkerRequest = { protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "init", requestId: randomUUID(), payload: initPayload }; + const validationError = validatePiSdkWorkerRequest(initMessage); + if (validationError) return Promise.reject(new Error(validationError)); + + // fork() forwards windowsHide to spawn(), although the older @types/node + // bundled by ADE does not include it in ForkOptions. + const child = fork(resolveWorkerPath(), [], { + cwd: args.cwd, + // Environment inheritance preserves Pi's normal provider credential + // resolution. Credentials themselves never cross the ADE IPC payload. + // Enforce the Pi environment boundary at the process boundary as well as + // at production call sites. Tests and future inventory callers cannot + // accidentally inherit ADE capability/session variables. + env: buildPiWorkerEnvironment(args.baseEnv ?? process.env, args.agentDir), + stdio: ["ignore", "pipe", "pipe", "ipc"], + execArgv: [], + windowsHide: true, + } as ForkOptions & { windowsHide: boolean }); + const pending = new Map(); + let disposeTimer: NodeJS.Timeout | null = null; + let killTimer: NodeJS.Timeout | null = null; + let disposing = false; + let lastStderr = ""; + let resolveExit!: () => void; + const exitPromise = new Promise((resolve) => { + resolveExit = resolve; + }); + const bridge: PiSdkBridge = { onEvent: null, onLifecycle: null, onError: null, onReady: null }; + let terminalFailure: ((error: Error) => void) | null = null; + + const ipcClosed = (): Error => new Error("Pi SDK worker IPC channel is closed."); + const normalizeError = (error: unknown): Error => error instanceof Error ? error : new Error(String(error)); + const rememberStderr = (chunk: unknown): void => { + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + if (text.trim()) lastStderr = `${lastStderr}\n${text.trim()}`.slice(-4_000); + }; + const exitedError = (code: number | null, signal: NodeJS.Signals | null): Error => { + const detail = lastStderr.trim().replace(/\s+/g, " "); + return new Error(`Pi SDK worker exited (${code ?? signal ?? "unknown"}).${detail ? ` ${detail}` : ""}`); + }; + const send = (message: PiSdkWorkerRequest, onError?: (error: Error) => void): boolean => { + if (child.exitCode != null || child.killed || child.connected === false) { + onError?.(ipcClosed()); + return false; + } + try { + child.send(message, (error) => { if (error) onError?.(normalizeError(error)); }); + return true; + } catch (error) { + onError?.(normalizeError(error)); + return false; + } + }; + const clearPendingTimer = (waiter: PendingRpc): void => { + if (!waiter.timer) return; + clearTimeout(waiter.timer); + waiter.timer = null; + }; + const rejectPending = (error: Error): void => { + for (const waiter of pending.values()) { + clearPendingTimer(waiter); + waiter.reject(error); + } + pending.clear(); + }; + const removeFromPools = (): void => { + for (const [key, entry] of pools) if (entry.pooled === pooled) pools.delete(key); + }; + const worker: PiSdkPooled = { + process: child, + bridge, + ready: null, + sessionFile: null, + sessionId: null, + currentModel: null, + version: null, + availableModels: [], + request: (type: K, ...args: PiSdkRequestArgs) => { + const requestId = randomUUID(); + const payload = args[0]; + return new Promise>((resolve, reject) => { + const waiter: PendingRpc = { + resolve: (value) => resolve(value as PiSdkRequestResult), + reject, + type, + timer: null, + }; + pending.set(requestId, waiter); + const timeoutMs = REQUEST_TIMEOUT_MS[type]; + if (timeoutMs) { + waiter.timer = setTimeout(() => { + if (pending.get(requestId) !== waiter) return; + pending.delete(requestId); + const error = new Error(`Pi SDK ${type} request timed out after ${timeoutMs}ms.`); + clearPendingTimer(waiter); + waiter.reject(error); + terminalFailure?.(error); + }, timeoutMs); + waiter.timer.unref(); + } + const message = { protocolVersion: PI_SDK_PROTOCOL_VERSION, type, requestId, ...(payload === undefined ? {} : { payload }) } as PiSdkWorkerRequest; + const sent = send(message, (error) => { + const waiter = pending.get(requestId); + if (!waiter) return; + pending.delete(requestId); + clearPendingTimer(waiter); + waiter.reject(error); + }); + if (!sent && pending.delete(requestId)) { + clearPendingTimer(waiter); + reject(ipcClosed()); + } + }); + }, + sendPrompt: (payload) => worker.request("send", payload), + steer: (prompt, images) => worker.request("steer", { prompt, ...(images?.length ? { images } : {}) }), + followUp: (prompt, images) => worker.request("follow_up", { prompt, ...(images?.length ? { images } : {}) }), + abort: () => worker.request("abort").then(() => undefined), + setModel: async (modelRef) => { + const value = await worker.request("set_model", { modelRef }); + applyReady(worker, value); + return value; + }, + setThinking: async (thinkingLevel) => { + const value = await worker.request("set_thinking", { thinkingLevel }); + applyReady(worker, value); + return value; + }, + compact: (payload) => worker.request("compact", payload), + requestModels: async () => { + const value = await worker.request("models"); + worker.availableModels = value; + if (worker.ready) worker.ready = { ...worker.ready, availableModels: value }; + return value; + }, + requestAuth: () => worker.request("auth"), + waitForExit: () => exitPromise, + dispose: () => { + disposing = true; + if (disposeTimer) clearTimeout(disposeTimer); + if (killTimer) clearTimeout(killTimer); + for (const waiter of pending.values()) { + clearPendingTimer(waiter); + waiter.reject(new Error("Pi SDK worker disposed.")); + } + pending.clear(); + const escalate = (): void => { + if (child.exitCode != null || child.killed) return; + killTimer = terminateChildProcessTree(child, killTimer); + }; + if (!send({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "dispose", requestId: randomUUID() })) { + escalate(); + return; + } + disposeTimer = setTimeout(escalate, DISPOSE_GRACE_MS); + disposeTimer.unref(); + }, + }; + + child.stdout?.on("data", (chunk) => args.logger?.debug("agent_chat.pi_sdk_worker_stdout", { text: String(chunk).trim() })); + child.stderr?.on("data", (chunk) => { + rememberStderr(chunk); + args.logger?.warn("agent_chat.pi_sdk_worker_stderr", { text: String(chunk).trim() }); + }); + child.on("message", (raw: unknown) => { + const message = parsePiSdkWorkerResponse(raw); + if (!message) { + args.logger?.warn("agent_chat.pi_sdk_worker_invalid_message", { message: "Rejected malformed worker response." }); + terminalFailure?.(new Error("Pi SDK worker sent a malformed response.")); + return; + } + if (message.type === "response") { + const waiter = pending.get(message.requestId); + if (!waiter) return; + pending.delete(message.requestId); + clearPendingTimer(waiter); + if (message.ok) { + const resultError = validatePiSdkWorkerResult(waiter.type, message.result); + if (resultError) { + const error = workerError(waiter.type, resultError); + waiter.reject(error); + terminalFailure?.(error); + } else { + waiter.resolve(message.result); + } + } else { + const error = workerError(waiter.type, message.error, message.detail); + waiter.reject(error); + // A provider/request error is recoverable; the worker remains usable + // for auth failures and model changes. Only malformed protocol data + // or a timeout invalidates the process. + } + return; + } + if (message.type === "ready") { + const initPending = [...pending.values()].some((waiter) => waiter.type === "init"); + if (worker.ready !== null || !initPending) { + terminalFailure?.(new Error("Pi SDK worker sent an unsolicited ready message.")); + return; + } + applyReady(worker, message.ready); + bridge.onReady?.(message.ready); + return; + } + if (message.type === "sdk_event") { + bridge.onEvent?.(message.event); + return; + } + if (message.type === "lifecycle") { + bridge.onLifecycle?.(message.event, message.requestId, message.detail); + return; + } + if (message.type === "error") { + bridge.onError?.(workerError(message.operation, message.error, message.detail), message.operation, message.requestId); + return; + } + if (message.type === "log") { + const level = message.level === "error" ? "warn" : message.level; + args.logger?.[level]?.("agent_chat.pi_sdk_worker_log", { message: message.message, detail: message.detail }); + } + }); + child.on("error", (error) => { + rejectPending(normalizeError(error)); + removeFromPools(); + }); + child.on("exit", (code, signal) => { + resolveExit(); + if (disposeTimer) clearTimeout(disposeTimer); + if (killTimer) clearTimeout(killTimer); + disposeTimer = null; + killTimer = null; + const error = exitedError(code, signal); + rejectPending(error); + removeFromPools(); + if (!disposing) bridge.onError?.(error, "worker"); + }); + + const pooled = worker; + let failed = false; + terminalFailure = (error: Error): void => { + if (failed || disposing) return; + failed = true; + disposing = true; + rejectPending(error); + removeFromPools(); + bridge.onError?.(error, "worker"); + killTimer = terminateChildProcessTree(child, killTimer); + }; + return (async () => { + try { + const result = await pooled.request("init", initPayload); + applyReady(pooled, result); + const generation = ++generationCounter; + pools.set(args.poolKey, { ref: 1, generation, pooled }); + return pooled; + } catch (error) { + pooled.dispose(); + await pooled.waitForExit(); + throw error; + } + })(); +} + +export function releasePiSdkConnection(poolKey: string, generation?: number, onDisposed?: () => void): void { + const entry = pools.get(poolKey); + if (!entry) { + onDisposed?.(); + return; + } + if (generation !== undefined && generation !== entry.generation) return; + entry.ref = Math.max(0, entry.ref - 1); + if (entry.ref === 0) { + pools.delete(poolKey); + entry.pooled.dispose(); + void entry.pooled.waitForExit().finally(() => onDisposed?.()); + } +} + +export const releasePiSdkWorker = releasePiSdkConnection; + +/** Useful to callers that need to validate an IPC-shaped request in tests. */ +export { parsePiSdkWorkerRequest }; diff --git a/apps/desktop/src/main/services/chat/piSdkProtocol.test.ts b/apps/desktop/src/main/services/chat/piSdkProtocol.test.ts new file mode 100644 index 0000000000..1f499a62f5 --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkProtocol.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + PI_SDK_PROTOCOL_VERSION, + normalizePiSdkModelRef, + validatePiSdkWorkerResponse, + validatePiSdkWorkerResult, + toPiSdkJson, + validatePiSdkWorkerRequest, +} from "./piSdkProtocol"; + +describe("Pi SDK protocol", () => { + it("rejects malformed and unsupported worker messages without throwing", () => { + expect(validatePiSdkWorkerRequest(null)).toContain("object"); + expect(validatePiSdkWorkerRequest({ type: "send", requestId: "x" })).toContain("protocol version"); + expect(validatePiSdkWorkerRequest({ + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "send", + requestId: "x", + payload: { prompt: "" }, + })).toContain("non-empty prompt"); + }); + + it("accepts an init message without requiring Pi types", () => { + expect(validatePiSdkWorkerRequest({ + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "init", + requestId: "init-1", + payload: { + packageRoot: "/Users/example/.npm/pi", + cwd: "/Users/example/project", + agentDir: "/Users/example/.pi/agent", + modelRef: "anthropic/claude-sonnet", + thinkingLevel: "medium", + }, + })).toBeNull(); + }); + + it("rejects malformed worker responses before they reach the pool", () => { + expect(validatePiSdkWorkerResponse({ + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "response", + requestId: "request-1", + ok: false, + })).toContain("missing error"); + expect(validatePiSdkWorkerResponse({ + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "ready", + ready: { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + packageRoot: "/pi", + packageEntry: "/pi/index.js", + version: null, + sessionFile: null, + sessionId: null, + currentModel: null, + thinkingLevel: null, + availableModels: "bad", + }, + })).toContain("availableModels"); + expect(validatePiSdkWorkerResponse({ + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "lifecycle", + event: "not-a-lifecycle", + })).toContain("lifecycle event"); + }); + + it("validates successful results against the request that produced them", () => { + expect(validatePiSdkWorkerResult("models", {})).toContain("array"); + expect(validatePiSdkWorkerResult("auth", [{ id: "openai" }])).toBeNull(); + expect(validatePiSdkWorkerResult("set_thinking", { protocolVersion: PI_SDK_PROTOCOL_VERSION })).toContain("package paths"); + expect(validatePiSdkWorkerResult("init", { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + packageRoot: "/pi", + packageEntry: "/pi/index.js", + version: null, + sessionFile: null, + sessionId: null, + currentModel: null, + thinkingLevel: null, + availableModels: [], + })).toBeNull(); + }); + + it("normalizes model references and makes hostile SDK values JSON-safe", () => { + expect(normalizePiSdkModelRef("openai/gpt-5")).toEqual({ provider: "openai", id: "gpt-5" }); + expect(normalizePiSdkModelRef({ provider: "anthropic", modelId: "claude" })).toEqual({ provider: "anthropic", id: "claude" }); + const circular: Record = { value: 1 }; + circular.self = circular; + expect(toPiSdkJson({ circular, nan: Number.NaN, bigint: BigInt(3) })).toEqual({ + circular: { value: 1, self: "[circular]" }, + nan: null, + bigint: "3", + }); + }); +}); diff --git a/apps/desktop/src/main/services/chat/piSdkProtocol.ts b/apps/desktop/src/main/services/chat/piSdkProtocol.ts new file mode 100644 index 0000000000..aa1a86612f --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkProtocol.ts @@ -0,0 +1,393 @@ +/** + * Versioned, dependency-free IPC contract for the Pi SDK worker. + * + * Keep this file free of Pi imports. The desktop process must be able to load + * the bridge even when the user has not installed Pi. + */ + +export const PI_SDK_PROTOCOL_VERSION = 1 as const; +export const PI_SDK_MIN_NODE = "22.19.0" as const; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export type PiSdkModelRef = + | string + | { provider: string; id?: string; modelId?: string }; + +export type PiSdkPackageLocation = { + /** The package directory. `packageRoot` is accepted as an alias. */ + packageDir?: string; + packageRoot?: string; + /** Absolute package entry, normally `/dist/index.js`. */ + packageEntry?: string; +}; + +export type PiSdkSessionTarget = { + /** Open this JSONL session file. */ + sessionFile?: string | null; + /** Resolve this Pi session id in sessionDir and open its file. */ + sessionId?: string | null; + /** Continue the most recent session when true. */ + resume?: boolean | { sessionFile?: string | null; sessionId?: string | null }; +}; + +export type PiSdkWorkerInit = PiSdkPackageLocation & { + protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; + cwd: string; + /** Passed to Pi as PI_CODING_AGENT_DIR. */ + agentDir: string; + /** Passed to Pi as PI_CODING_AGENT_SESSION_DIR. */ + sessionDir?: string | null; + modelRef?: PiSdkModelRef | null; + thinkingLevel?: string | null; + systemPrompt?: string | null; + /** Environment supplied to Pi skill/resource discovery in the worker. */ + skillsEnv?: Record; + /** Load the installed SDK for model/auth inventory without creating a session. */ + inventoryOnly?: boolean; + session?: PiSdkSessionTarget; + /** Restrict built-in tools when an integration needs a read-only session. */ + tools?: string[]; + noTools?: "all" | "builtin"; +}; + +export type PiSdkImage = { + data: string; + mimeType: string; +}; + +export type PiSdkPromptPayload = { + prompt: string; + images?: PiSdkImage[]; + /** Used only when prompt is sent while Pi is streaming. */ + streamingBehavior?: "steer" | "followUp"; +}; + +export type PiSdkModelUpdate = { + modelRef: PiSdkModelRef; +}; + +export type PiSdkThinkingUpdate = { + thinkingLevel: string; +}; + +export type PiSdkCompactPayload = { + customInstructions?: string | null; +}; + +export type PiSdkWorkerRequest = + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "init"; requestId: string; payload: PiSdkWorkerInit } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "send"; requestId: string; payload: PiSdkPromptPayload } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "steer"; requestId: string; payload: { prompt: string; images?: PiSdkImage[] } } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "follow_up"; requestId: string; payload: { prompt: string; images?: PiSdkImage[] } } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "abort"; requestId: string } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "dispose"; requestId: string } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "set_model"; requestId: string; payload: PiSdkModelUpdate } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "set_thinking"; requestId: string; payload: PiSdkThinkingUpdate } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "compact"; requestId: string; payload?: PiSdkCompactPayload } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "models"; requestId: string } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "auth"; requestId: string }; + +export type PiSdkReady = { + protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; + packageRoot: string; + packageEntry: string; + version: string | null; + sessionFile: string | null; + sessionId: string | null; + currentModel: JsonValue | null; + thinkingLevel: string | null; + availableModels: JsonValue[]; +}; + +export type PiSdkLifecycleName = + | "worker_started" + | "package_loaded" + | "initializing" + | "ready" + | "prompt_started" + | "prompt_finished" + | "prompt_failed" + | "aborted" + | "disposed"; + +export type PiSdkWorkerResponse = + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "response"; requestId: string; ok: true; result?: JsonValue } + | { + protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; + type: "response"; + requestId: string; + ok: false; + error: string; + errorCode?: string; + detail?: JsonValue; + } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "ready"; ready: PiSdkReady } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "sdk_event"; event: JsonValue; requestId?: string } + | { + protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; + type: "lifecycle"; + event: PiSdkLifecycleName; + requestId?: string; + detail?: JsonValue; + } + | { + protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; + type: "error"; + operation: string; + error: string; + requestId?: string; + detail?: JsonValue; + } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "log"; level: "debug" | "info" | "warn" | "error"; message: string; detail?: JsonValue }; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isModelRef(value: unknown): value is PiSdkModelRef { + if (nonEmptyString(value)) return true; + if (!isRecord(value) || !nonEmptyString(value.provider)) return false; + return nonEmptyString(value.id) || nonEmptyString(value.modelId); +} + +function isJsonValue(value: unknown, depth = 0): value is JsonValue { + if (value === null || typeof value === "string" || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value); + if (depth >= 12) return false; + if (Array.isArray(value)) return value.every((item) => isJsonValue(item, depth + 1)); + if (!isRecord(value)) return false; + return Object.values(value).every((item) => isJsonValue(item, depth + 1)); +} + +function isPackageLocation(value: Record): boolean { + const paths = [value.packageDir, value.packageRoot, value.packageEntry].filter((item) => item !== undefined && item !== null); + return paths.length > 0 && paths.every((item) => typeof item === "string" && item.trim().length > 0); +} + +function isSessionTarget(value: unknown): boolean { + if (!isRecord(value)) return false; + if (value.sessionFile != null && typeof value.sessionFile !== "string") return false; + if (value.sessionId != null && typeof value.sessionId !== "string") return false; + return value.resume == null || typeof value.resume === "boolean" || (isRecord(value.resume) + && (value.resume.sessionFile == null || typeof value.resume.sessionFile === "string") + && (value.resume.sessionId == null || typeof value.resume.sessionId === "string")); +} + +/** Returns a human-readable validation failure without throwing. */ +export function validatePiSdkWorkerRequest(raw: unknown): string | null { + if (!isRecord(raw)) return "Pi SDK worker message must be an object."; + if (raw.protocolVersion !== PI_SDK_PROTOCOL_VERSION) return `Unsupported Pi SDK protocol version: ${String(raw.protocolVersion)}.`; + if (!nonEmptyString(raw.requestId)) return "Pi SDK worker message is missing requestId."; + if (!nonEmptyString(raw.type)) return "Pi SDK worker message is missing type."; + + const type = raw.type; + const payload = raw.payload; + if (type === "init") { + if (!isRecord(payload)) return "Pi SDK init payload must be an object."; + if (!isPackageLocation(payload)) return "Pi SDK init requires packageRoot/packageDir or packageEntry."; + if (!nonEmptyString(payload.cwd)) return "Pi SDK init requires an absolute cwd."; + if (!nonEmptyString(payload.agentDir)) return "Pi SDK init requires an absolute agentDir."; + if (payload.sessionDir != null && typeof payload.sessionDir !== "string") return "Pi SDK sessionDir must be a string or null."; + if (payload.modelRef != null && !isModelRef(payload.modelRef)) return "Pi SDK modelRef must be a model string or {provider,id}."; + if (payload.thinkingLevel != null && !nonEmptyString(payload.thinkingLevel)) return "Pi SDK thinkingLevel cannot be empty."; + if (payload.tools != null && (!Array.isArray(payload.tools) + || payload.tools.some((tool) => !nonEmptyString(tool)))) { + return "Pi SDK tools must be an array of non-empty strings."; + } + if (payload.noTools != null && payload.noTools !== "all" && payload.noTools !== "builtin") { + return "Pi SDK noTools must be all or builtin."; + } + if (payload.skillsEnv != null && (!isRecord(payload.skillsEnv) + || Object.entries(payload.skillsEnv).some(([key, value]) => !nonEmptyString(key) || typeof value !== "string"))) { + return "Pi SDK skillsEnv must be a string-to-string object."; + } + if (payload.inventoryOnly != null && typeof payload.inventoryOnly !== "boolean") { + return "Pi SDK inventoryOnly must be a boolean."; + } + if (payload.session != null && !isSessionTarget(payload.session)) return "Pi SDK session target is invalid."; + return null; + } + if (["send", "steer", "follow_up"].includes(type)) { + if (!isRecord(payload) || !nonEmptyString(payload.prompt)) return `Pi SDK ${type} requires a non-empty prompt.`; + if (payload.images != null && (!Array.isArray(payload.images) + || payload.images.some((image) => !isRecord(image) || typeof image.data !== "string" || typeof image.mimeType !== "string"))) { + return "Pi SDK send images must contain data and mimeType strings."; + } + if (type === "send" && payload.streamingBehavior != null + && payload.streamingBehavior !== "steer" && payload.streamingBehavior !== "followUp") { + return "Pi SDK send streamingBehavior must be steer or followUp."; + } + } else if (type === "set_model") { + if (!isRecord(payload) || !isModelRef(payload.modelRef)) return "Pi SDK set_model requires a valid modelRef."; + } else if (type === "set_thinking") { + if (!isRecord(payload) || !nonEmptyString(payload.thinkingLevel)) return "Pi SDK set_thinking requires a thinkingLevel."; + } else if (type === "compact" && payload != null && (!isRecord(payload) + || (payload.customInstructions != null && typeof payload.customInstructions !== "string"))) { + return "Pi SDK compact payload is invalid."; + } else if (!["init", "abort", "dispose", "models", "auth", "compact"].includes(type)) { + return `Unsupported Pi SDK worker request: ${String(type)}.`; + } + return null; +} + +/** Narrow a validated IPC message without importing any Pi types. */ +export function parsePiSdkWorkerRequest(raw: unknown): PiSdkWorkerRequest | null { + return validatePiSdkWorkerRequest(raw) === null ? raw as PiSdkWorkerRequest : null; +} + +/** Returns a human-readable validation failure for an untrusted worker response. */ +export function validatePiSdkWorkerResponse(raw: unknown): string | null { + if (!isRecord(raw)) return "Pi SDK worker response must be an object."; + if (raw.protocolVersion !== PI_SDK_PROTOCOL_VERSION) return `Unsupported Pi SDK protocol version: ${String(raw.protocolVersion)}.`; + if (!nonEmptyString(raw.type)) return "Pi SDK worker response is missing type."; + + if (raw.type === "response") { + if (!nonEmptyString(raw.requestId)) return "Pi SDK response is missing requestId."; + if (typeof raw.ok !== "boolean") return "Pi SDK response is missing ok."; + if (raw.ok) { + if (raw.result !== undefined && !isJsonValue(raw.result)) return "Pi SDK response result must be JSON-safe."; + } else { + if (!nonEmptyString(raw.error)) return "Pi SDK error response is missing error."; + if (raw.errorCode !== undefined && !nonEmptyString(raw.errorCode)) return "Pi SDK errorCode must be a non-empty string."; + if (raw.detail !== undefined && !isJsonValue(raw.detail)) return "Pi SDK response detail must be JSON-safe."; + } + return null; + } + + if (raw.type === "ready") { + if (!isRecord(raw.ready)) return "Pi SDK ready response is missing ready data."; + const ready = raw.ready; + if (ready.protocolVersion !== PI_SDK_PROTOCOL_VERSION) return "Pi SDK ready response has an invalid protocol version."; + if (!nonEmptyString(ready.packageRoot) || !nonEmptyString(ready.packageEntry)) return "Pi SDK ready response is missing package paths."; + if (!("version" in ready) || (ready.version !== null && typeof ready.version !== "string")) return "Pi SDK ready version must be a string or null."; + if (!("sessionFile" in ready) || (ready.sessionFile !== null && typeof ready.sessionFile !== "string")) return "Pi SDK ready sessionFile must be a string or null."; + if (!("sessionId" in ready) || (ready.sessionId !== null && typeof ready.sessionId !== "string")) return "Pi SDK ready sessionId must be a string or null."; + if (!("currentModel" in ready) || (ready.currentModel !== null && !isJsonValue(ready.currentModel))) return "Pi SDK ready currentModel must be JSON-safe."; + if (!("thinkingLevel" in ready) || (ready.thinkingLevel !== null && typeof ready.thinkingLevel !== "string")) return "Pi SDK ready thinkingLevel must be a string or null."; + if (!Array.isArray(ready.availableModels) || !ready.availableModels.every((model) => isJsonValue(model))) return "Pi SDK ready availableModels must be JSON-safe."; + return null; + } + + if (raw.type === "sdk_event") { + return isJsonValue(raw.event) ? null : "Pi SDK event must be JSON-safe."; + } + + if (raw.type === "lifecycle") { + const lifecycleNames: PiSdkLifecycleName[] = [ + "worker_started", + "package_loaded", + "initializing", + "ready", + "prompt_started", + "prompt_finished", + "prompt_failed", + "aborted", + "disposed", + ]; + if (!lifecycleNames.includes(raw.event as PiSdkLifecycleName)) return "Pi SDK lifecycle event is invalid."; + if (raw.requestId !== undefined && !nonEmptyString(raw.requestId)) return "Pi SDK lifecycle requestId must be a non-empty string."; + if (raw.detail !== undefined && !isJsonValue(raw.detail)) return "Pi SDK lifecycle detail must be JSON-safe."; + return null; + } + + if (raw.type === "error") { + if (!nonEmptyString(raw.operation) || !nonEmptyString(raw.error)) return "Pi SDK error event is missing operation or error."; + if (raw.requestId !== undefined && !nonEmptyString(raw.requestId)) return "Pi SDK error requestId must be a non-empty string."; + if (raw.detail !== undefined && !isJsonValue(raw.detail)) return "Pi SDK error detail must be JSON-safe."; + return null; + } + + if (raw.type === "log") { + if (raw.level !== "debug" && raw.level !== "info" && raw.level !== "warn" && raw.level !== "error") return "Pi SDK log level is invalid."; + if (typeof raw.message !== "string") return "Pi SDK log message must be a string."; + if (raw.detail !== undefined && !isJsonValue(raw.detail)) return "Pi SDK log detail must be JSON-safe."; + return null; + } + + return `Unsupported Pi SDK worker response: ${String(raw.type)}.`; +} + +/** Validate the success payload for the request that produced it. */ +export function validatePiSdkWorkerResult( + type: PiSdkWorkerRequest["type"], + result: unknown, +): string | null { + if (type === "init" || type === "set_model" || type === "set_thinking") { + if (!isRecord(result)) return `Pi SDK ${type} result must contain ready session data.`; + if (result.protocolVersion !== PI_SDK_PROTOCOL_VERSION) return `Pi SDK ${type} result has an invalid protocol version.`; + if (!nonEmptyString(result.packageRoot) || !nonEmptyString(result.packageEntry)) return `Pi SDK ${type} result is missing package paths.`; + if (!("version" in result) || (result.version !== null && typeof result.version !== "string")) return `Pi SDK ${type} version must be a string or null.`; + if (!("sessionFile" in result) || (result.sessionFile !== null && typeof result.sessionFile !== "string")) return `Pi SDK ${type} sessionFile must be a string or null.`; + if (!("sessionId" in result) || (result.sessionId !== null && typeof result.sessionId !== "string")) return `Pi SDK ${type} sessionId must be a string or null.`; + if (!("currentModel" in result) || (result.currentModel !== null && !isJsonValue(result.currentModel))) return `Pi SDK ${type} currentModel must be JSON-safe.`; + if (!("thinkingLevel" in result) || (result.thinkingLevel !== null && typeof result.thinkingLevel !== "string")) return `Pi SDK ${type} thinkingLevel must be a string or null.`; + if (!Array.isArray(result.availableModels) || !result.availableModels.every((model) => isJsonValue(model))) return `Pi SDK ${type} availableModels must be JSON-safe.`; + return null; + } + if (type === "models") { + return Array.isArray(result) && result.every((model) => isJsonValue(model)) + ? null + : "Pi SDK models result must be a JSON-safe array."; + } + if (type === "auth") { + return Array.isArray(result) && result.every((provider) => isJsonValue(provider)) + ? null + : "Pi SDK auth result must be a JSON-safe array."; + } + if (result !== undefined && !isJsonValue(result)) return `Pi SDK ${type} result must be JSON-safe.`; + return null; +} + +/** Narrow a validated child-process response without importing any Pi types. */ +export function parsePiSdkWorkerResponse(raw: unknown): PiSdkWorkerResponse | null { + return validatePiSdkWorkerResponse(raw) === null ? raw as PiSdkWorkerResponse : null; +} + +/** Convert SDK values to a bounded JSON-safe value before crossing IPC. */ +export function toPiSdkJson(value: unknown, depth = 0, seen = new WeakSet()): JsonValue { + if (value == null) return null; + if (typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value === "bigint") return String(value); + if (typeof value === "function" || typeof value === "symbol") return null; + if (depth >= 8) return "[truncated]"; + if (value instanceof Date) return value.toISOString(); + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + ...(value.stack ? { stack: value.stack } : {}), + }; + } + if (typeof value !== "object") return String(value); + if (seen.has(value)) return "[circular]"; + seen.add(value); + if (Array.isArray(value)) return value.map((item) => toPiSdkJson(item, depth + 1, seen)); + const output: { [key: string]: JsonValue } = {}; + for (const [key, item] of Object.entries(value)) { + if (item !== undefined) output[key] = toPiSdkJson(item, depth + 1, seen); + } + return output; +} + +export function normalizePiSdkModelRef(ref: PiSdkModelRef): { provider: string; id: string } { + if (typeof ref === "string") { + const trimmed = ref.trim(); + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash === trimmed.length - 1) { + throw new Error(`Invalid Pi model "${trimmed}". Use provider/model-id.`); + } + return { provider: trimmed.slice(0, slash), id: trimmed.slice(slash + 1) }; + } + const provider = typeof ref.provider === "string" ? ref.provider.trim() : ""; + const idValue = typeof ref.id === "string" && ref.id.trim() ? ref.id : ref.modelId; + const id = typeof idValue === "string" ? idValue.trim() : ""; + if (!provider || !id) throw new Error("Invalid Pi model. Both provider and model id are required."); + return { provider, id }; +} diff --git a/apps/desktop/src/main/services/chat/piSdkWorker.ts b/apps/desktop/src/main/services/chat/piSdkWorker.ts new file mode 100644 index 0000000000..f216c9dc4d --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkWorker.ts @@ -0,0 +1,619 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + PI_SDK_MIN_NODE, + PI_SDK_PROTOCOL_VERSION, + normalizePiSdkModelRef, + parsePiSdkWorkerRequest, + toPiSdkJson, + type JsonValue, + type PiSdkModelRef, + type PiSdkReady, + type PiSdkSessionTarget, + type PiSdkWorkerInit, + type PiSdkWorkerRequest, + type PiSdkWorkerResponse, +} from "./piSdkProtocol"; +import { + piSessionHeaderMatchesCwd, + readPiSessionHeader, +} from "./piSessionLease"; + +// Deliberately no static import (or type import) from Pi. This process is +// started by ADE and loads the user's installation only after init validation. +type PiModule = Record; +type Callable = (...args: unknown[]) => unknown; +type PiSession = Record; +type PiRuntime = Record; +type PiSessionManager = Record; + +let pi: PiModule | null = null; +let piRoot: string | null = null; +let piEntry: string | null = null; +let piVersion: string | null = null; +let initState: PiSdkWorkerInit | null = null; +let modelRuntime: PiRuntime | null = null; +let sessionManager: PiSessionManager | null = null; +let session: PiSession | null = null; +let modelInventory: JsonValue[] = []; +let lastAssistantError: string | null = null; +const VALID_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); +const PI_BUILTIN_TOOLS = new Set(["read", "bash", "edit", "write"]); +let unsubscribe: (() => void) | null = null; +let disposed = false; + +function post(message: PiSdkWorkerResponse): void { + if (!process.send) return; + // SDK events are untrusted values. Normalize them before they reach the + // desktop process so circular values, BigInts, and Error instances cannot + // break Node's child-process serializer. + try { + process.send(toPiSdkJson(message) as unknown as PiSdkWorkerResponse); + } catch (error) { + try { + process.send({ + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "error", + operation: "ipc.send", + error: errorMessage(error), + }); + } catch { + // The parent may have disconnected while an SDK event was being emitted. + } + } +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + const message = error.message.trim(); + return message && message !== "Error" ? message : error.name || "Unknown Pi SDK error"; + } + if (typeof error === "string" && error.trim()) return error.trim(); + try { + return JSON.stringify(toPiSdkJson(error)); + } catch { + return String(error); + } +} + +function errorDetail(error: unknown): JsonValue { + if (error instanceof Error) { + const record = error as Error & { code?: unknown; status?: unknown; cause?: unknown }; + return toPiSdkJson({ + name: error.name, + ...(record.code != null ? { code: record.code } : {}), + ...(record.status != null ? { status: record.status } : {}), + ...(record.cause != null ? { cause: errorMessage(record.cause) } : {}), + }); + } + return toPiSdkJson(error); +} + +function callable(value: unknown, name: string): Callable { + if (typeof value !== "function") throw new Error(`Pi SDK export ${name} is unavailable in ${piEntry ?? "the selected package"}.`); + return value as Callable; +} + +function method(target: unknown, name: string): Callable { + if (!target || (typeof target !== "object" && typeof target !== "function")) { + throw new Error(`Pi SDK object is missing ${name}().`); + } + return callable((target as Record)[name], name); +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function parseNodeVersion(value: string): [number, number, number] { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [0, 0, 0]; +} + +function isNodeSupported(): boolean { + const actual = parseNodeVersion(process.versions.node); + const minimum = parseNodeVersion(PI_SDK_MIN_NODE); + return actual[0] > minimum[0] + || (actual[0] === minimum[0] && (actual[1] > minimum[1] + || (actual[1] === minimum[1] && actual[2] >= minimum[2]))); +} + +function assertAbsolute(label: string, value: string): void { + if (!path.isAbsolute(value)) throw new Error(`Pi SDK ${label} must be an absolute path; received "${value}".`); +} + +function findPackageRoot(start: string): string | null { + let current = path.resolve(start); + if (!fs.existsSync(current)) current = path.dirname(current); + while (true) { + if (fs.existsSync(path.join(current, "package.json"))) return current; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +function resolvePackageLocation(init: PiSdkPackageLocationLike): { root: string; entry: string; version: string | null } { + const candidateRoot = init.packageRoot ?? init.packageDir; + if (candidateRoot) assertAbsolute("packageRoot", candidateRoot); + if (init.packageEntry) assertAbsolute("packageEntry", init.packageEntry); + const root = candidateRoot ? path.resolve(candidateRoot) : findPackageRoot(path.resolve(init.packageEntry!)); + if (!root) { + throw new Error("Pi SDK package is missing. Provide an absolute packageRoot/packageDir or packageEntry for the user's @earendil-works/pi-coding-agent installation."); + } + const packageJsonPath = path.join(root, "package.json"); + if (!fs.existsSync(packageJsonPath)) throw new Error(`Pi SDK package is missing package.json at ${packageJsonPath}.`); + let packageJson: Record; + try { + packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as Record; + } catch (error) { + throw new Error(`Pi SDK package.json at ${packageJsonPath} is unreadable: ${errorMessage(error)}`); + } + + let entry = init.packageEntry ? path.resolve(init.packageEntry) : ""; + if (!entry) { + const main = typeof packageJson.main === "string" ? packageJson.main : "dist/index.js"; + entry = path.resolve(root, main); + } + if (!fs.existsSync(entry)) { + throw new Error(`Pi SDK package entry is missing at ${entry}. Install @earendil-works/pi-coding-agent or provide its absolute dist/index.js path.`); + } + return { + root, + entry, + version: typeof packageJson.version === "string" ? packageJson.version : null, + }; +} + +type PiSdkPackageLocationLike = Pick; + +function modelDescriptor(value: unknown): JsonValue | null { + const item = record(value); + if (!item) return null; + const provider = nonEmpty(item.provider); + const id = nonEmpty(item.id); + if (!provider || !id) return null; + const out: Record = { provider, id }; + for (const key of ["name", "reasoning", "input", "contextWindow", "maxTokens", "cost"] as const) { + if (item[key] !== undefined) out[key] = toPiSdkJson(item[key]); + } + return out; +} + +function currentModelDescriptor(): JsonValue | null { + return modelDescriptor(session?.model); +} + +async function availableModels(): Promise { + if (!modelRuntime) throw new Error("Pi SDK model runtime is not initialized."); + const getAvailable = (modelRuntime as Record).getAvailable; + if (typeof getAvailable !== "function") return []; + const values = await (getAvailable as Callable).call(modelRuntime); + modelInventory = (Array.isArray(values) ? values : []).map(modelDescriptor).filter((value): value is JsonValue => value !== null); + return modelInventory; +} + +async function authInventory(): Promise { + if (!modelRuntime) throw new Error("Pi SDK model runtime is not initialized."); + const runtime = modelRuntime as Record; + const providersValue = typeof runtime.getProviders === "function" + ? await (runtime.getProviders as Callable).call(modelRuntime) + : []; + const providers = Array.isArray(providersValue) ? providersValue : []; + const result: Record[] = []; + for (const providerValue of providers) { + const provider = record(providerValue); + const id = nonEmpty(provider?.id); + if (!id) continue; + const item: Record = { + id, + ...(nonEmpty(provider?.name) ? { name: nonEmpty(provider?.name)! } : {}), + }; + try { + const status = typeof runtime.getProviderAuthStatus === "function" + ? await (runtime.getProviderAuthStatus as Callable).call(modelRuntime, id) + : typeof runtime.checkAuth === "function" + ? await (runtime.checkAuth as Callable).call(modelRuntime, id) + : undefined; + const statusRecord = record(status); + if (statusRecord) { + // Pi 0.84 reports the authoritative auth state as `configured`, with + // `source`/`label` describing how the provider was resolved. Keep the + // older aliases too so ADE can read compatible Pi installations without + // manufacturing an auth result from the presence of a provider row. + for (const key of ["configured", "authenticated", "isAuthenticated", "type", "status", "source", "label", "expiresAt"] as const) { + if (statusRecord[key] !== undefined) item[key] = toPiSdkJson(statusRecord[key]); + } + } + } catch (error) { + item.error = errorMessage(error); + } + result.push(item); + } + return result; +} + +function modelText(ref: PiSdkModelRef): string { + const normalized = normalizePiSdkModelRef(ref); + return `${normalized.provider}/${normalized.id}`; +} + +async function resolveModel(ref: PiSdkModelRef): Promise { + if (!pi || !modelRuntime) throw new Error("Pi SDK is not initialized."); + const normalized = normalizePiSdkModelRef(ref); + const resolver = pi.resolveCliModel; + if (typeof resolver === "function") { + const result = await Promise.resolve((resolver as Callable).call(null, { + cliModel: `${normalized.provider}/${normalized.id}`, + modelRuntime, + })); + const resolved = record(result); + if (resolved?.error) throw new Error(`Pi model "${modelText(ref)}" is invalid: ${String(resolved.error)}`); + if (resolved?.model) return resolved.model; + } + const getModel = method(modelRuntime, "getModel"); + const model = await Promise.resolve(getModel.call(modelRuntime, normalized.provider, normalized.id)); + if (!model) throw new Error(`Pi model "${modelText(ref)}" is unavailable. Check the provider/model id and credentials in the user's Pi profile.`); + return model; +} + +function sessionTarget(init: PiSdkWorkerInit): PiSdkSessionTarget { + return init.session ?? {}; +} + +function sessionFileIsAuthorized(filePath: string, sessionDir: string | null): boolean { + if (!sessionDir) return true; + try { + const resolvedFile = fs.realpathSync(filePath); + const resolvedDir = fs.realpathSync(sessionDir); + const relative = path.relative(resolvedDir, resolvedFile); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + } catch { + return false; + } +} + +function validatedSessionFile( + filePath: string, + sessionDir: string | null, + expectedId: string | null, + expectedCwd: string, +): string | null { + let resolved: string; + try { + resolved = fs.realpathSync(path.resolve(filePath)); + } catch { + return null; + } + if (!sessionFileIsAuthorized(resolved, sessionDir)) return null; + // A native file may be inside the authorized Pi root while belonging to a + // different project. Require a non-empty, exact normalized header cwd at + // this worker boundary; blank cwd must never act as a wildcard. + const header = readPiSessionHeader(resolved); + if (!header || !piSessionHeaderMatchesCwd(header, expectedCwd)) return null; + if (expectedId && header.id !== expectedId) return null; + return resolved; +} + +async function openSessionManager(init: PiSdkWorkerInit, sdk: PiModule): Promise { + const SessionManager = sdk.SessionManager; + if (!SessionManager || typeof SessionManager !== "function") throw new Error("Pi SDK export SessionManager is unavailable."); + const manager = SessionManager as unknown as Record; + const target = sessionTarget(init); + const sessionFile = nonEmpty(target.sessionFile); + const requestedId = nonEmpty(target.sessionId); + const resume = target.resume; + const resumeRecord = record(resume); + const resumeFile = sessionFile ?? nonEmpty(resumeRecord?.sessionFile); + const resumeId = requestedId ?? nonEmpty(resumeRecord?.sessionId); + const sessionDir = nonEmpty(init.sessionDir); + + try { + if (resumeFile) { + assertAbsolute("sessionFile", resumeFile); + const authorizedFile = validatedSessionFile(resumeFile, sessionDir, resumeId, init.cwd); + // A stale path can accompany a durable session id after a project move or + // remote handoff. Prefer the id lookup in that case; never create a new + // session while a persisted native pointer is present. + if (authorizedFile) { + return method(manager, "open").call(manager, authorizedFile, sessionDir ?? path.dirname(authorizedFile), init.cwd) as PiSessionManager; + } + if (!resumeId) { + throw new Error(`Pi session file "${resumeFile}" is missing, outside the authorized session directory, or invalid.`); + } + } + if (resumeId) { + const list = await method(manager, "list").call(manager, init.cwd, sessionDir ?? undefined); + const found = (Array.isArray(list) ? list : []).find((item) => record(item)?.id === resumeId); + const foundPath = nonEmpty(record(found)?.path); + const authorizedFoundPath = foundPath + ? validatedSessionFile(foundPath, sessionDir, resumeId, init.cwd) + : null; + if (!authorizedFoundPath) { + throw new Error(`Pi session "${resumeId}" was not found in the authorized session directory.`); + } + return method(manager, "open").call(manager, authorizedFoundPath, sessionDir ?? path.dirname(authorizedFoundPath), init.cwd) as PiSessionManager; + } + if (resume === true) { + return method(manager, "continueRecent").call(manager, init.cwd, sessionDir ?? undefined) as PiSessionManager; + } + return method(manager, "create").call(manager, init.cwd, sessionDir ?? undefined) as PiSessionManager; + } catch (error) { + throw new Error(`Pi session could not be opened${resumeFile ? ` at ${resumeFile}` : ""}: ${errorMessage(error)}`); + } +} + +function makeResourceLoader(init: PiSdkWorkerInit, sdk: PiModule): unknown { + const Loader = sdk.DefaultResourceLoader; + if (typeof Loader !== "function") throw new Error("Pi SDK export DefaultResourceLoader is unavailable."); + const rawSkillRoots = init.skillsEnv?.ADE_AGENT_SKILLS_DIRS ?? ""; + const additionalSkillPaths = rawSkillRoots + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); + return new (Loader as new (options: Record) => unknown)({ + cwd: init.cwd, + agentDir: init.agentDir, + // Pi extensions can execute arbitrary code and can install custom UI + // handlers. Native ADE chat deliberately keeps them out of the worker; + // the Pi CLI remains the escape hatch for extension-owned experiences. + noExtensions: true, + // Do not inherit project/user Pi skill settings. ADE passes only its + // explicitly approved skill roots below; the CLI remains the escape hatch + // for Pi-native skill discovery. + noSkills: true, + ...(additionalSkillPaths.length ? { additionalSkillPaths } : {}), + ...(init.systemPrompt != null ? { systemPromptOverride: () => init.systemPrompt ?? "" } : {}), + }); +} + +function ready(): PiSdkReady { + if (!initState || !piRoot || !piEntry || (!session && !initState.inventoryOnly)) { + throw new Error("Pi SDK worker is not initialized."); + } + const manager = sessionManager as Record | null; + const sessionFileValue = session?.sessionFile + ?? (manager && typeof manager.getSessionFile === "function" ? (manager.getSessionFile as Callable).call(manager) : undefined); + const sessionIdValue = session?.sessionId + ?? (manager && typeof manager.getSessionId === "function" ? (manager.getSessionId as Callable).call(manager) : undefined); + return { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + packageRoot: piRoot, + packageEntry: piEntry, + version: piVersion ?? (typeof pi?.VERSION === "string" ? pi.VERSION : null), + sessionFile: typeof sessionFileValue === "string" ? sessionFileValue : null, + sessionId: typeof sessionIdValue === "string" ? sessionIdValue : null, + currentModel: currentModelDescriptor(), + thinkingLevel: typeof session?.thinkingLevel === "string" ? session.thinkingLevel : null, + availableModels: modelInventory, + }; +} + +async function initWorker(init: PiSdkWorkerInit): Promise { + if (!isNodeSupported()) { + throw new Error(`Pi SDK requires Node >= ${PI_SDK_MIN_NODE}; ADE worker is running Node ${process.versions.node}. Start ADE with a newer Node runtime.`); + } + assertAbsolute("cwd", init.cwd); + assertAbsolute("agentDir", init.agentDir); + if (init.thinkingLevel && !VALID_THINKING_LEVELS.has(init.thinkingLevel.trim())) { + throw new Error(`Invalid Pi thinking level "${init.thinkingLevel}". Use off, minimal, low, medium, high, xhigh, or max.`); + } + const requestedTools = init.tools ?? ["read"]; + const invalidTools = requestedTools.filter((tool) => !PI_BUILTIN_TOOLS.has(tool)); + if (invalidTools.length) { + throw new Error(`Pi SDK only permits the built-in tools read, bash, edit, and write; received ${invalidTools.join(", ")}.`); + } + if (init.sessionDir) assertAbsolute("sessionDir", init.sessionDir); + const location = resolvePackageLocation(init); + piRoot = location.root; + piEntry = location.entry; + piVersion = location.version; + initState = init; + process.env.PI_CODING_AGENT_DIR = init.agentDir; + if (init.sessionDir) process.env.PI_CODING_AGENT_SESSION_DIR = init.sessionDir; + for (const [key, value] of Object.entries(init.skillsEnv ?? {})) process.env[key] = value; + fs.mkdirSync(init.agentDir, { recursive: true }); + if (init.sessionDir) fs.mkdirSync(init.sessionDir, { recursive: true }); + + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "lifecycle", event: "initializing" }); + try { + const imported = await import(pathToFileURL(location.entry).href); + pi = imported as PiModule; + } catch (error) { + throw new Error(`Unable to load Pi SDK from ${location.entry}: ${errorMessage(error)}`); + } + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "lifecycle", event: "package_loaded", detail: toPiSdkJson({ version: piVersion, entry: location.entry }) }); + + const Runtime = pi.ModelRuntime; + if (!Runtime || typeof Runtime !== "function") throw new Error("The selected Pi package does not export ModelRuntime."); + const createRuntime = method(Runtime, "create"); + modelRuntime = await createRuntime.call(Runtime, { + authPath: path.join(init.agentDir, "auth.json"), + modelsPath: path.join(init.agentDir, "models.json"), + modelsStorePath: path.join(init.agentDir, "models-store.json"), + }) as PiRuntime; + + if (init.inventoryOnly) { + const result = ready(); + result.availableModels = await availableModels(); + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "ready", ready: result }); + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "lifecycle", event: "ready" }); + return result; + } + + sessionManager = await openSessionManager(init, pi); + const selectedModel = init.modelRef == null ? undefined : await resolveModel(init.modelRef); + const resourceLoader = makeResourceLoader(init, pi); + if (resourceLoader && typeof (resourceLoader as Record).reload === "function") { + await method(resourceLoader, "reload").call(resourceLoader); + } + const createSession = callable(pi.createAgentSession, "createAgentSession"); + const options: Record = { + cwd: init.cwd, + agentDir: init.agentDir, + modelRuntime, + sessionManager, + ...(selectedModel ? { model: selectedModel } : {}), + ...(init.thinkingLevel ? { thinkingLevel: init.thinkingLevel } : {}), + ...(resourceLoader ? { resourceLoader } : {}), + tools: init.tools ?? ["read"], + ...(init.noTools ? { noTools: init.noTools } : {}), + }; + const created = record(await createSession(options)); + session = record(created?.session); + if (!session) throw new Error("Pi SDK createAgentSession returned no session."); + const subscribe = method(session, "subscribe"); + const listener = (event: unknown): void => { + const eventRecord = record(event); + if (eventRecord?.type === "message_end") { + const message = record(eventRecord.message); + if (message?.role === "assistant") { + if (message.stopReason === "error") { + lastAssistantError = nonEmpty(message.errorMessage) ?? "Pi provider returned an assistant error."; + } else { + lastAssistantError = null; + } + } + } + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "sdk_event", event: toPiSdkJson(event) }); + }; + unsubscribe = (subscribe.call(session, listener) as (() => void) | undefined) ?? null; + const result = ready(); + result.availableModels = await availableModels(); + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "ready", ready: result }); + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "lifecycle", event: "ready", detail: toPiSdkJson({ sessionId: result.sessionId, sessionFile: result.sessionFile }) }); + return result; +} + +function requireSession(): PiSession { + if (!session || !modelRuntime || disposed) throw new Error("Pi SDK worker is not initialized or has been disposed."); + return session; +} + +function imageContents(images: Array<{ data: string; mimeType: string }> | undefined): unknown[] | undefined { + return images?.length + ? images.map((image) => ({ type: "image", data: image.data, mimeType: image.mimeType })) + : undefined; +} + +async function sendPrompt(request: Extract): Promise { + const active = requireSession(); + lastAssistantError = null; + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "lifecycle", event: "prompt_started", requestId: request.requestId }); + try { + const promptOptions: Record = {}; + const images = imageContents(request.payload.images); + if (images) promptOptions.images = images; + if (request.payload.streamingBehavior) promptOptions.streamingBehavior = request.payload.streamingBehavior; + await method(active, "prompt").call(active, request.payload.prompt, promptOptions); + if (lastAssistantError) throw new Error(lastAssistantError); + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "lifecycle", event: "prompt_finished", requestId: request.requestId }); + return toPiSdkJson({ sessionFile: ready().sessionFile, sessionId: ready().sessionId }); + } catch (error) { + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "lifecycle", event: "prompt_failed", requestId: request.requestId, detail: errorDetail(error) }); + throw error; + } +} + +async function setModel(ref: PiSdkModelRef): Promise { + const active = requireSession(); + const model = await resolveModel(ref); + await method(active, "setModel").call(active, model); + const result = ready(); + result.availableModels = await availableModels(); + return result; +} + +async function setThinking(level: string): Promise { + const active = requireSession(); + const normalized = level.trim(); + if (!normalized || !VALID_THINKING_LEVELS.has(normalized)) { + throw new Error(`Invalid Pi thinking level "${level}". Use off, minimal, low, medium, high, xhigh, or max.`); + } + await method(active, "setThinkingLevel").call(active, normalized); + return ready(); +} + +async function compact(customInstructions?: string | null): Promise { + const active = requireSession(); + const compactMethod = (active as Record).compact; + if (typeof compactMethod !== "function") throw new Error("This Pi SDK build does not expose session.compact()."); + return toPiSdkJson(await (compactMethod as Callable).call(active, customInstructions ?? undefined)); +} + +async function disposeWorker(): Promise { + if (disposed) return; + disposed = true; + unsubscribe?.(); + unsubscribe = null; + try { + if (session && typeof session.abort === "function") await (session.abort as Callable).call(session); + } catch { + // Disposal must continue even if a provider is already gone. + } + try { + if (session && typeof session.dispose === "function") { + await Promise.resolve((session.dispose as Callable).call(session)); + } + } catch { + // ignore + } + session = null; + sessionManager = null; + modelRuntime = null; + modelInventory = []; +} + +async function dispatch(request: PiSdkWorkerRequest): Promise { + switch (request.type) { + case "init": return toPiSdkJson(await initWorker(request.payload)); + case "send": return await sendPrompt(request); + case "steer": { + const active = requireSession(); + await method(active, "steer").call(active, request.payload.prompt, imageContents(request.payload.images)); + return null; + } + case "follow_up": { + const active = requireSession(); + await method(active, "followUp").call(active, request.payload.prompt, imageContents(request.payload.images)); + return null; + } + case "abort": await method(requireSession(), "abort").call(requireSession()); post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "lifecycle", event: "aborted", requestId: request.requestId }); return null; + case "set_model": return toPiSdkJson(await setModel(request.payload.modelRef)); + case "set_thinking": return toPiSdkJson(await setThinking(request.payload.thinkingLevel)); + case "compact": return await compact(request.payload?.customInstructions); + case "models": return toPiSdkJson(await availableModels()); + case "auth": return await authInventory(); + case "dispose": await disposeWorker(); post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "lifecycle", event: "disposed", requestId: request.requestId }); return null; + } +} + +post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "lifecycle", event: "worker_started" }); +process.on("message", (raw: unknown) => { + const validationError = raw && typeof raw === "object" && "type" in raw + ? null + : "Pi SDK worker received a malformed message."; + const request = validationError ? null : parsePiSdkWorkerRequest(raw); + if (!request) { + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "error", operation: "ipc.receive", error: validationError ?? "Invalid Pi SDK worker request." }); + return; + } + void dispatch(request) + .then((result) => { + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "response", requestId: request.requestId, ok: true, ...(result === undefined ? {} : { result }) }); + if (request.type === "dispose") setImmediate(() => process.exit(0)); + }) + .catch((error) => { + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "error", operation: request.type, requestId: request.requestId, error: errorMessage(error), detail: errorDetail(error) }); + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "response", requestId: request.requestId, ok: false, error: errorMessage(error), detail: errorDetail(error) }); + }); +}); + +process.once("disconnect", () => { + void disposeWorker().finally(() => process.exit(0)); +}); diff --git a/apps/desktop/src/main/services/chat/piSessionLease.test.ts b/apps/desktop/src/main/services/chat/piSessionLease.test.ts new file mode 100644 index 0000000000..abd18b4fb6 --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSessionLease.test.ts @@ -0,0 +1,262 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { + acquirePiSessionLease, + listPiSessionFilesForCwd, + piSessionDirectoryForEnvironment, + resolvePiSessionFile, +} from "./piSessionLease"; + +const tempRoots: string[] = []; + +afterEach(() => { + for (const root of tempRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function makeSession(): { root: string; file: string; id: string; cwd: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-lease-")); + tempRoots.push(root); + const cwd = path.join(root, "worktree"); + const sessionDir = path.join(root, "sessions"); + fs.mkdirSync(path.join(sessionDir, "encoded"), { recursive: true }); + const id = "019fd86d-f40d-76c6-a194-d5ba030cbad3"; + const file = path.join(sessionDir, "encoded", `2026-04-01T00-00-00-000Z_${id}.jsonl`); + fs.writeFileSync(file, `${JSON.stringify({ type: "session", id, cwd, timestamp: new Date().toISOString() })}\n`); + return { root, file, id, cwd }; +} + +async function waitForChildOutput(child: ChildProcess, marker: string): Promise { + await new Promise((resolve, reject) => { + let output = ""; + const timer = setTimeout(() => reject(new Error(`Timed out waiting for child marker ${marker}. Output: ${output}`)), 10_000); + const onData = (chunk: Buffer | string) => { + output += String(chunk); + if (!output.includes(marker)) return; + clearTimeout(timer); + child.stdout?.off("data", onData); + resolve(); + }; + child.stdout?.on("data", onData); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("exit", (code, signal) => { + if (output.includes(marker)) return; + clearTimeout(timer); + reject(new Error(`Lease child exited before ${marker}: ${code ?? signal ?? "unknown"}. Output: ${output}`)); + }); + }); +} + +async function waitForChildExit(child: ChildProcess): Promise { + if (child.exitCode != null || child.signalCode != null) return; + await new Promise((resolve) => child.once("exit", () => resolve())); +} + +describe("Pi native session leases", () => { + it("resolves and excludes concurrent SDK/CLI writers", () => { + const session = makeSession(); + const canonicalFile = fs.realpathSync(session.file); + expect(resolvePiSessionFile({ cwd: session.cwd, sessionId: session.id, sessionDir: path.join(session.root, "sessions") })).toBe(canonicalFile); + expect(resolvePiSessionFile({ cwd: session.cwd, sessionId: "", sessionFile: session.file })).toBe(canonicalFile); + + const sdk = acquirePiSessionLease({ sessionFile: session.file, owner: "sdk", ownerId: "chat-1" }); + expect(() => acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "pty-1" })).toThrow(/already owned/iu); + + sdk.release(); + const cli = acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "pty-1" }); + expect(fs.existsSync(`${session.file}.ade-lease`)).toBe(true); + cli.release(); + expect(fs.existsSync(`${session.file}.ade-lease`)).toBe(false); + }); + + it("honors the user-selected native Pi session directory", () => { + const session = makeSession(); + const sessionDir = path.join(session.root, "sessions"); + expect(piSessionDirectoryForEnvironment({ + HOME: session.root, + PI_CODING_AGENT_SESSION_DIR: sessionDir, + })).toBe(path.resolve(sessionDir)); + expect(resolvePiSessionFile({ + cwd: session.cwd, + sessionId: session.id, + env: { HOME: session.root, PI_CODING_AGENT_SESSION_DIR: sessionDir }, + })).toBe(fs.realpathSync(session.file)); + }); + + it("cleans a dead writer sidecar but never overwrites a live one", () => { + const session = makeSession(); + const lockPath = `${session.file}.ade-lease`; + fs.writeFileSync(lockPath, `${JSON.stringify({ + version: 1, + token: "dead", + owner: "sdk", + ownerId: "old", + pid: 999_999_999, + acquiredAt: new Date().toISOString(), + sessionFile: session.file, + })}\n`); + + const lease = acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "pty-2" }); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).not.toBe("dead"); + lease.release(); + }); + + it("uses the process start identity to distinguish a reused PID", () => { + const session = makeSession(); + const lockPath = `${session.file}.ade-lease`; + fs.writeFileSync(lockPath, `${JSON.stringify({ + version: 2, + token: "reused-pid", + owner: "sdk", + ownerId: "old-runtime", + pid: process.pid, + processStartedAt: "2026-01-01T00:00:00.000Z", + acquiredAt: new Date().toISOString(), + sessionFile: session.file, + })}\n`); + + const lease = acquirePiSessionLease({ + sessionFile: session.file, + owner: "cli", + ownerId: "new-runtime", + isProcessIdentityLive: (_pid, startedAt) => startedAt === "2026-08-06T00:00:00.000Z", + }); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).not.toBe("reused-pid"); + lease.release(); + + fs.writeFileSync(lockPath, `${JSON.stringify({ + version: 2, + token: "same-incarnation", + owner: "sdk", + ownerId: "live-runtime", + pid: process.pid, + processStartedAt: "2026-08-06T00:00:00.000Z", + acquiredAt: new Date().toISOString(), + sessionFile: session.file, + })}\n`); + expect(() => acquirePiSessionLease({ + sessionFile: session.file, + owner: "cli", + ownerId: "blocked-runtime", + isProcessIdentityLive: (_pid, startedAt) => startedAt === "2026-08-06T00:00:00.000Z", + })).toThrow(/already owned/iu); + fs.unlinkSync(lockPath); + }); + + it("does not release a replacement sidecar owned by another writer", () => { + const session = makeSession(); + const lockPath = `${session.file}.ade-lease`; + const lease = acquirePiSessionLease({ sessionFile: session.file, owner: "sdk", ownerId: "chat-lease" }); + const replacement = { + version: 1 as const, + token: "replacement", + owner: "cli" as const, + ownerId: "external-cli", + pid: process.pid, + acquiredAt: new Date().toISOString(), + sessionFile: session.file, + }; + fs.writeFileSync(lockPath, `${JSON.stringify(replacement)}\n`); + + lease.release(); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8"))).toMatchObject({ token: "replacement", owner: "cli" }); + fs.unlinkSync(lockPath); + }); + + it("rejects a session id whose header belongs to another cwd", () => { + const session = makeSession(); + const otherCwd = path.join(session.root, "other"); + expect(resolvePiSessionFile({ cwd: otherCwd, sessionId: session.id, sessionDir: path.join(session.root, "sessions") })).toBeNull(); + expect(resolvePiSessionFile({ cwd: otherCwd, sessionId: "", sessionFile: session.file, sessionDir: path.join(session.root, "sessions") })).toBeNull(); + }); + + it("rejects native headers with a missing cwd at both explicit and id lookup boundaries", () => { + const session = makeSession(); + const missingCwdId = "019fd86d-f40d-76c6-a194-d5ba030cbad4"; + const missingCwdFile = path.join(session.root, "sessions", "encoded", `${missingCwdId}.jsonl`); + fs.writeFileSync(missingCwdFile, `${JSON.stringify({ type: "session", id: missingCwdId })}\n`); + + expect(resolvePiSessionFile({ + cwd: session.cwd, + sessionId: missingCwdId, + sessionFile: missingCwdFile, + sessionDir: path.join(session.root, "sessions"), + })).toBeNull(); + expect(resolvePiSessionFile({ + cwd: session.cwd, + sessionId: missingCwdId, + sessionDir: path.join(session.root, "sessions"), + })).toBeNull(); + }); + + it("snapshots only exact-cwd native sessions for implicit PTY ownership", () => { + const session = makeSession(); + const foreignCwd = path.join(session.root, "foreign"); + const foreignId = "019fd86d-f40d-76c6-a194-d5ba030cbad5"; + const foreignFile = path.join(session.root, "sessions", "encoded", `${foreignId}.jsonl`); + fs.writeFileSync(foreignFile, `${JSON.stringify({ type: "session", id: foreignId, cwd: foreignCwd })}\n`); + + expect(listPiSessionFilesForCwd({ + cwd: session.cwd, + sessionDir: path.join(session.root, "sessions"), + })).toEqual([{ filePath: fs.realpathSync(session.file), id: session.id }]); + }); + + it("rejects a live lease held by another Node process and allows handoff after release", async () => { + const session = makeSession(); + const childScript = ` + const { acquirePiSessionLease } = await import(process.env.ADE_PI_LEASE_MODULE); + const lease = acquirePiSessionLease({ sessionFile: process.env.ADE_PI_LEASE_FILE, owner: "sdk", ownerId: "child" }); + process.stdout.write("ready\\n"); + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + if (chunk.includes("release")) { + lease.release(); + process.exit(0); + } + }); + `; + const child = spawn(process.execPath, [ + "--experimental-strip-types", + "--input-type=module", + "-e", + childScript, + ], { + stdio: ["pipe", "pipe", "pipe"], + env: { + PATH: process.env.PATH ?? "", + HOME: session.root, + USERPROFILE: session.root, + ADE_PI_LEASE_MODULE: pathToFileURL(path.resolve(__dirname, "piSessionLease.ts")).href, + ADE_PI_LEASE_FILE: session.file, + }, + }); + + try { + await waitForChildOutput(child, "ready"); + const lockPath = `${session.file}.ade-lease`; + const childRecord = JSON.parse(fs.readFileSync(lockPath, "utf8")) as { pid: number; token: string }; + expect(childRecord.pid).toBe(child.pid); + expect(() => acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "parent" })).toThrow(/already owned/iu); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8"))).toMatchObject({ pid: child.pid, token: childRecord.token }); + + child.stdin?.write("release\\n"); + await waitForChildExit(child); + const parentLease = acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "parent" }); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).pid).toBe(process.pid); + parentLease.release(); + } finally { + if (child.exitCode == null && child.signalCode == null) { + child.stdin?.write("release\\n"); + await waitForChildExit(child); + if (child.exitCode == null && child.signalCode == null) child.kill(); + } + } + }); +}); diff --git a/apps/desktop/src/main/services/chat/piSessionLease.ts b/apps/desktop/src/main/services/chat/piSessionLease.ts new file mode 100644 index 0000000000..cb4d372f06 --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSessionLease.ts @@ -0,0 +1,424 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +// The cross-process lease test loads this source file directly with Node's +// strip-types loader, which requires the explicit source extension. +// @ts-expect-error TS5097: the bundler resolves the sibling TypeScript module. +import { pathsEqual } from "../shared/pathCompare.ts"; + +export type PiSessionLeaseOwner = "sdk" | "cli"; + +type LeaseRecord = { + version: 1 | 2; + token: string; + owner: PiSessionLeaseOwner; + ownerId: string; + pid: number; + processStartedAt?: string; + acquiredAt: string; + sessionFile: string; +}; + +type ProcessIdentityLiveCheck = (pid: number, startedAt: string) => boolean; + +export type PiSessionLease = { + sessionFile: string; + lockPath: string; + token: string; + owner: PiSessionLeaseOwner; + release: () => void; +}; + +const localLeases = new Map(); + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function homeDir(env: NodeJS.ProcessEnv): string { + return nonEmpty(env.USERPROFILE) ?? nonEmpty(env.HOME) ?? os.homedir(); +} + +/** Resolve the native Pi session tree used by both CLI and discovery paths. */ +export function piSessionDirectoryForEnvironment( + env: NodeJS.ProcessEnv = process.env, + fallbackDir?: string | null, +): string { + const explicit = nonEmpty(env.PI_CODING_AGENT_SESSION_DIR); + if (explicit && path.isAbsolute(explicit)) return path.resolve(explicit); + if (fallbackDir && path.isAbsolute(fallbackDir)) return path.resolve(fallbackDir); + const agentDir = nonEmpty(env.PI_CODING_AGENT_DIR) ?? path.join(homeDir(env), ".pi", "agent"); + return path.join(path.resolve(agentDir), "sessions"); +} + +function samePath(left: string, right: string): boolean { + return pathsEqual(path.resolve(left), path.resolve(right)); +} + +function pathWithinDirectory(filePath: string, directoryPath: string): boolean { + const relative = path.relative(directoryPath, filePath); + return relative === "" + || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +export type PiSessionHeader = { + id: string; + /** Normalized, non-empty native Pi working directory from the header. */ + cwd: string; +}; + +export function normalizePiSessionCwd(value: unknown): string | null { + const clean = nonEmpty(value); + return clean ? path.resolve(clean) : null; +} + +/** Read a native Pi header. A session without a cwd is invalid for ADE use. */ +export function readPiSessionHeader(filePath: string): PiSessionHeader | null { + try { + const line = fs.readFileSync(filePath, "utf8").split(/\r?\n/u, 1)[0] ?? ""; + const parsed = JSON.parse(line) as Record; + const id = nonEmpty(parsed.id); + const cwd = normalizePiSessionCwd(parsed.cwd); + return parsed.type === "session" && id && cwd ? { id, cwd } : null; + } catch { + return null; + } +} + +export function piSessionHeaderMatchesCwd( + header: Pick | null | undefined, + requestedCwd: unknown, +): boolean { + const expected = normalizePiSessionCwd(requestedCwd); + return Boolean(header?.cwd && expected && samePath(header.cwd, expected)); +} + +function canonicalSessionFile(filePath: string): string | null { + try { + return fs.realpathSync(path.resolve(filePath)); + } catch { + return null; + } +} + +export type PiSessionFile = { + filePath: string; + id: string; +}; + +/** + * Snapshot every valid native Pi session under a configured root. + * + * This deliberately reads the header instead of trusting timestamp-prefixed + * filenames, refuses symlinks, and requires an exact requested cwd. Callers + * use it before an implicit/fork launch to distinguish a newly-created JSONL + * from a recent session that was already present. + */ +export function listPiSessionFilesForCwd(args: { + cwd: string; + sessionDir?: string | null; + env?: NodeJS.ProcessEnv; +}): PiSessionFile[] { + const requestedCwd = normalizePiSessionCwd(args.cwd); + if (!requestedCwd) return []; + const root = nonEmpty(args.sessionDir) ?? piSessionDirectoryForEnvironment(args.env ?? process.env); + const pendingDirectories = [path.resolve(root)]; + const files: PiSessionFile[] = []; + const seenFiles = new Set(); + + while (pendingDirectories.length > 0) { + const directory = pendingDirectories.pop(); + if (!directory) continue; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const candidate = path.join(directory, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + pendingDirectories.push(candidate); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + const resolved = canonicalSessionFile(candidate); + if (!resolved || seenFiles.has(resolved)) continue; + const header = readPiSessionHeader(resolved); + if (!header || !piSessionHeaderMatchesCwd(header, requestedCwd)) continue; + seenFiles.add(resolved); + files.push({ filePath: resolved, id: header.id }); + } + } + return files; +} + +/** Resolve a native Pi JSONL file without importing Pi. */ +export function resolvePiSessionFile(args: { + cwd: string; + sessionId: string; + sessionFile?: string | null; + sessionDir?: string | null; + env?: NodeJS.ProcessEnv; +}): string | null { + const targetId = args.sessionId.trim(); + const explicit = nonEmpty(args.sessionFile); + if (!targetId && !explicit) return null; + if (explicit) { + // Use the filesystem's canonical spelling so macOS aliases such as + // /var -> /private/var cannot produce two sidecars for one JSONL file. + const resolved = canonicalSessionFile(explicit); + if (!resolved) return null; + const requestedSessionDir = nonEmpty(args.sessionDir); + if (requestedSessionDir) { + let canonicalSessionDir: string; + try { + canonicalSessionDir = fs.realpathSync(path.resolve(requestedSessionDir)); + } catch { + return null; + } + if (!pathWithinDirectory(resolved, canonicalSessionDir)) return null; + } + const header = readPiSessionHeader(resolved); + if (header && (!targetId || header.id === targetId) && piSessionHeaderMatchesCwd(header, args.cwd)) return resolved; + return null; + } + if (!targetId) return null; + return listPiSessionFilesForCwd({ + cwd: args.cwd, + ...(args.sessionDir ? { sessionDir: args.sessionDir } : {}), + ...(args.env ? { env: args.env } : {}), + }).find((session) => session.id === targetId)?.filePath ?? null; +} + +function lockPathFor(sessionFile: string): string { + return `${sessionFile}.ade-lease`; +} + +function processIsAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException)?.code === "EPERM"; + } +} + +function leaseOwnerIsLive(record: LeaseRecord, isProcessIdentityLive?: ProcessIdentityLiveCheck): boolean { + if (!processIsAlive(record.pid)) return false; + const startedAt = nonEmpty(record.processStartedAt); + // Version-one sidecars predate process identity tracking. Keep their + // conservative PID-only behavior so an older ADE process can still block a + // newer runtime rather than risk two writers opening the same JSONL file. + return !startedAt || !isProcessIdentityLive || isProcessIdentityLive(record.pid, startedAt); +} + +function readLease(lockPath: string): LeaseRecord | null { + try { + return parseLease(fs.readFileSync(lockPath, "utf8")); + } catch { + return null; + } +} + +function parseLease(contents: string): LeaseRecord | null { + try { + const parsed = JSON.parse(contents) as Partial; + if ((parsed.version !== 1 && parsed.version !== 2) + || !nonEmpty(parsed.token) + || (parsed.owner !== "sdk" && parsed.owner !== "cli") + || !nonEmpty(parsed.sessionFile) + || (parsed.processStartedAt !== undefined && parsed.processStartedAt !== null && !nonEmpty(parsed.processStartedAt))) return null; + return parsed as LeaseRecord; + } catch { + return null; + } +} + +function writeLease(lockPath: string, record: LeaseRecord): boolean { + const stagingPath = `${lockPath}.new-${randomUUID()}`; + try { + // Write the full JSON before publishing it. A direct wx write exposes a + // partially written sidecar to another process, which could mistake it for + // a stale lease and reclaim a live writer. A same-directory hard link is + // create-only and therefore gives us an atomic no-clobber publication on + // the filesystems ADE supports (APFS, ext4, and NTFS). + fs.writeFileSync(stagingPath, `${JSON.stringify(record)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 }); + fs.linkSync(stagingPath, lockPath); + return true; + } catch { + return false; + } finally { + try { fs.unlinkSync(stagingPath); } catch { /* the link owns the content */ } + } +} + +function restoreClaimedLease(lockPath: string, claimedPath: string, record: LeaseRecord | null): void { + if (!record) { + try { fs.unlinkSync(claimedPath); } catch { /* best effort */ } + return; + } + // Never rename over a lock that another writer acquired while this process + // temporarily claimed the old sidecar. If the path is occupied, keep the + // claim as a visible blocker until its owner releases it; deleting it here + // would let a third writer acquire the main path while the claimed owner is + // still writing without an adjacent sidecar. + if (!fs.existsSync(lockPath)) { + if (writeLease(lockPath, record)) { + try { fs.unlinkSync(claimedPath); } catch { /* best effort */ } + } + return; + } +} + +function liveReclaimClaim(lockPath: string, isProcessIdentityLive?: ProcessIdentityLiveCheck): LeaseRecord | null { + const directory = path.dirname(lockPath); + const prefix = `${path.basename(lockPath)}.reclaim-`; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith(prefix)) continue; + const claimPath = path.join(directory, entry.name); + const record = readLease(claimPath); + if (!record) { + try { fs.unlinkSync(claimPath); } catch { /* best effort */ } + continue; + } + if (leaseOwnerIsLive(record, isProcessIdentityLive)) return record; + try { fs.unlinkSync(claimPath); } catch { /* best effort */ } + } + return null; +} + +/** + * Atomically move the current sidecar out of the contested path. Once the + * move succeeds, another process may create a replacement lock, but it can no + * longer be removed by this reclaim attempt. This closes the stale-sidecar + * race where two processes both observed a dead PID and one unlinked the + * other's newly acquired lease. + */ +function reclaimStaleLease( + lockPath: string, + observed: LeaseRecord | null, + isProcessIdentityLive?: ProcessIdentityLiveCheck, +): boolean { + const claimedPath = `${lockPath}.reclaim-${randomUUID()}`; + try { + fs.renameSync(lockPath, claimedPath); + } catch { + return false; + } + + const claimed = readLease(claimedPath); + const replacedSinceObservation = Boolean(observed && (!claimed || claimed.token !== observed.token)); + if (replacedSinceObservation || (claimed && leaseOwnerIsLive(claimed, isProcessIdentityLive))) { + restoreClaimedLease(lockPath, claimedPath, claimed); + return false; + } + try { fs.unlinkSync(claimedPath); } catch { /* best effort */ } + return true; +} + +function releaseLease(lockPath: string, token: string): void { + const claimedPath = `${lockPath}.release-${randomUUID()}`; + try { + fs.renameSync(lockPath, claimedPath); + } catch { + return; + } + const claimed = readLease(claimedPath); + if (claimed?.token === token) { + try { fs.unlinkSync(claimedPath); } catch { /* best effort */ } + } else { + restoreClaimedLease(lockPath, claimedPath, claimed); + } + + // A stale-sidecar contender may have temporarily moved this owner's lock + // into a reclaim claim. Remove only claims bearing our token; never touch a + // replacement owner or an unrelated contender. + const directory = path.dirname(lockPath); + const prefix = `${path.basename(lockPath)}.reclaim-`; + try { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.startsWith(prefix)) continue; + const claimPath = path.join(directory, entry.name); + if (readLease(claimPath)?.token === token) { + try { fs.unlinkSync(claimPath); } catch { /* best effort */ } + } + } + } catch { /* best effort */ } +} + +/** + * Reserve one native Pi session for either the ADE SDK or a tracked CLI. + * The sidecar is intentionally adjacent to Pi's JSONL file so independent ADE + * runtimes and the desktop's PTY service converge on the same lock without + * sharing secrets or requiring Pi changes. + */ +export function acquirePiSessionLease(args: { + sessionFile: string; + owner: PiSessionLeaseOwner; + ownerId: string; + processStartedAt?: string | null; + isProcessIdentityLive?: ProcessIdentityLiveCheck; +}): PiSessionLease { + const sessionFile = path.resolve(args.sessionFile); + const lockPath = lockPathFor(sessionFile); + if (localLeases.has(lockPath)) { + throw new Error(`Pi session is already owned by another ${args.owner === "sdk" ? "ADE chat" : "CLI"} writer.`); + } + const record: LeaseRecord = { + version: 2, + token: randomUUID(), + owner: args.owner, + ownerId: args.ownerId.trim() || randomUUID(), + pid: process.pid, + ...(nonEmpty(args.processStartedAt) ? { processStartedAt: nonEmpty(args.processStartedAt) ?? undefined } : {}), + acquiredAt: new Date().toISOString(), + sessionFile, + }; + for (let attempt = 0; attempt < 3; attempt += 1) { + const reclaimClaim = liveReclaimClaim(lockPath, args.isProcessIdentityLive); + if (reclaimClaim) { + throw new Error(`Pi session is already being reclaimed by ${reclaimClaim.owner === "sdk" ? "ADE chat" : "Pi CLI"}. Stop that writer or fork the session before continuing.`); + } + if (writeLease(lockPath, record)) break; + const current = readLease(lockPath); + if (current && leaseOwnerIsLive(current, args.isProcessIdentityLive)) { + throw new Error(`Pi session is already owned by ${current.owner === "sdk" ? "ADE chat" : "Pi CLI"}. Stop that writer or fork the session before continuing.`); + } + if (reclaimStaleLease(lockPath, current, args.isProcessIdentityLive)) { + if (writeLease(lockPath, record)) break; + } + if (attempt === 2) throw new Error("Pi session ownership could not be acquired safely."); + } + + const lease: PiSessionLease = { + sessionFile, + lockPath, + token: record.token, + owner: args.owner, + release: () => { + releaseLease(lockPath, record.token); + localLeases.delete(lockPath); + }, + }; + localLeases.set(lockPath, lease); + return lease; +} + +export function piSessionLeasePath(sessionFile: string): string { + return lockPathFor(path.resolve(sessionFile)); +} + +/** Synthetic target used to serialize ADE-created sessions before Pi writes its first JSONL file. */ +export function piSessionCreationLeaseTarget(sessionDir: string): string { + return path.join(path.resolve(sessionDir), ".ade-session-create"); +} diff --git a/apps/desktop/src/main/services/chat/threadPointerLedger.ts b/apps/desktop/src/main/services/chat/threadPointerLedger.ts index 38d362fee1..855f21e409 100644 --- a/apps/desktop/src/main/services/chat/threadPointerLedger.ts +++ b/apps/desktop/src/main/services/chat/threadPointerLedger.ts @@ -25,7 +25,8 @@ export function isThreadPointerLedgerEntry(value: unknown): value is ThreadPoint || record.provider === "claude" || record.provider === "opencode" || record.provider === "cursor" - || record.provider === "droid") + || record.provider === "droid" + || record.provider === "pi") && (record.pointer === null || typeof record.pointer === "string") && (record.prevPointer === null || typeof record.prevPointer === "string") && typeof record.reason === "string" diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index d9346960bb..c9e34d41d7 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -783,6 +783,9 @@ function coerceAutomationPermissionConfig(value: unknown): AutomationPermissionC ...(asString(value.providers.opencode)?.trim() ? { opencode: asString(value.providers.opencode)!.trim() as NonNullable["opencode"] } : {}), + ...(asString(value.providers.pi)?.trim() + ? { pi: asString(value.providers.pi)!.trim() as NonNullable["pi"] } + : {}), ...(asString(value.providers.codexSandbox)?.trim() ? { codexSandbox: @@ -1406,7 +1409,7 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { const providersRaw = isRecord(permissionsRaw.providers) ? permissionsRaw.providers : null; if (providersRaw) { const providers: NonNullable["providers"]> = {}; - const providerMode = (key: "claude" | "codex" | "cursor" | "droid" | "opencode") => { + const providerMode = (key: "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi") => { const mode = asString(providersRaw[key])?.trim(); if (mode === "default" || mode === "plan" || mode === "edit" || mode === "full-auto" || mode === "config-toml") { providers[key] = mode; @@ -1417,6 +1420,7 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { providerMode("cursor"); providerMode("droid"); providerMode("opencode"); + providerMode("pi"); const codexSandbox = asString(providersRaw.codexSandbox)?.trim(); if (codexSandbox === "read-only" || codexSandbox === "workspace-write" || codexSandbox === "danger-full-access") { providers.codexSandbox = codexSandbox; diff --git a/apps/desktop/src/main/services/externalSessions/discoverPi.test.ts b/apps/desktop/src/main/services/externalSessions/discoverPi.test.ts new file mode 100644 index 0000000000..4c66ff97a5 --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoverPi.test.ts @@ -0,0 +1,110 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { discoverPiSessions, piResumeCommandForSession } from "./discoverPi"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("Pi external session discovery", () => { + it("reads native JSONL messages and preserves a resumable id", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-discovery-")); + roots.push(root); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const id = "019fd86d-f40d-76c6-a194-d5ba030cbad3"; + fs.writeFileSync(path.join(root, `2026-04-01T00-00-00-000Z_${id}.jsonl`), [ + JSON.stringify({ type: "session", id, cwd, timestamp: "2026-04-01T00:00:00.000Z" }), + JSON.stringify({ type: "message", id: "u1", parentId: null, timestamp: "2026-04-01T00:00:01.000Z", message: { role: "user", content: [{ type: "text", text: "Inspect this repo" }] } }), + JSON.stringify({ type: "message", id: "a1", parentId: "u1", timestamp: "2026-04-01T00:00:02.000Z", message: { role: "assistant", content: [{ type: "text", text: "I will inspect it." }] } }), + JSON.stringify({ type: "session_info", id: "i1", parentId: "a1", timestamp: "2026-04-01T00:00:03.000Z", name: "Repo inspection" }), + ].join("\n") + "\n"); + + const [session] = await discoverPiSessions({ + env: { PI_CODING_AGENT_SESSION_DIR: root }, + scopeRoots: [root], + limit: 10, + }); + expect(session).toMatchObject({ + provider: "pi", + id, + cwd, + title: "Repo inspection", + preview: "Inspect this repo", + messageCount: 1, + sourcePath: path.join(root, `2026-04-01T00-00-00-000Z_${id}.jsonl`), + }); + expect(session?.messages?.map((message) => message.text)).toEqual(["Inspect this repo", "I will inspect it."]); + const [exact] = await discoverPiSessions({ + env: { PI_CODING_AGENT_SESSION_DIR: root }, + scopeRoots: [root], + sessionId: id, + }); + expect(exact?.id).toBe(id); + expect(piResumeCommandForSession(id)).toBe(`pi --session ${id}`); + }); + + it("uses header ids for exact lookup even when the session is older than the recent budget", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-discovery-exact-")); + roots.push(root); + const cwd = path.join(root, "repo"); + fs.mkdirSync(path.join(root, "nested", "sessions"), { recursive: true }); + const targetId = "019fd86d-f40d-76c6-a194-d5ba030cbad4"; + const recentId = "019fd86d-f40d-76c6-a194-d5ba030cbaaa"; + const target = path.join(root, "nested", "sessions", `2026-03-01T00-00-00-000Z_${targetId}.jsonl`); + const recent = path.join(root, `2026-04-01T00-00-00-000Z_${recentId}.jsonl`); + fs.writeFileSync(target, `${JSON.stringify({ type: "session", id: targetId, cwd, timestamp: "2026-03-01T00:00:00.000Z" })}\n`); + fs.writeFileSync(recent, `${JSON.stringify({ type: "session", id: recentId, cwd, timestamp: "2026-04-01T00:00:00.000Z" })}\n`); + + const recentOnly = await discoverPiSessions({ + env: { PI_CODING_AGENT_SESSION_DIR: root }, + scopeRoots: [cwd], + limit: 1, + }); + expect(recentOnly.map((entry) => entry.id)).toEqual([recentId]); + + const exact = await discoverPiSessions({ + env: { PI_CODING_AGENT_SESSION_DIR: root }, + scopeRoots: [cwd], + sessionId: targetId, + limit: 1, + }); + expect(exact[0]).toMatchObject({ id: targetId, sourcePath: target }); + }); + + it("walks the full native session tree without following symlinked directories", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-discovery-deep-")); + roots.push(root); + const cwd = path.join(root, "repo"); + fs.mkdirSync(cwd, { recursive: true }); + const nested = Array.from({ length: 7 }, (_, index) => `level-${index}`) + .reduce((current, segment) => path.join(current, segment), root); + fs.mkdirSync(nested, { recursive: true }); + const id = "019fd86d-f40d-76c6-a194-d5ba030cbad5"; + fs.writeFileSync( + path.join(nested, `2026-05-01T00-00-00-000Z_${id}.jsonl`), + `${JSON.stringify({ type: "session", id, cwd, timestamp: "2026-05-01T00:00:00.000Z" })}\n`, + ); + + if (process.platform !== "win32") { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-discovery-outside-")); + roots.push(outside); + const outsideId = "019fd86d-f40d-76c6-a194-d5ba030cbad6"; + fs.writeFileSync( + path.join(outside, `2026-05-02T00-00-00-000Z_${outsideId}.jsonl`), + `${JSON.stringify({ type: "session", id: outsideId, cwd, timestamp: "2026-05-02T00:00:00.000Z" })}\n`, + ); + fs.symlinkSync(outside, path.join(root, "linked-sessions"), "dir"); + } + + const sessions = await discoverPiSessions({ + env: { PI_CODING_AGENT_SESSION_DIR: root }, + scopeRoots: [cwd], + limit: 10, + }); + expect(sessions.map((session) => session.id)).toEqual([id]); + }); +}); diff --git a/apps/desktop/src/main/services/externalSessions/discoverPi.ts b/apps/desktop/src/main/services/externalSessions/discoverPi.ts new file mode 100644 index 0000000000..ecab160f1e --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoverPi.ts @@ -0,0 +1,132 @@ +import path from "node:path"; +import { + asEpochMs, + asRecord, + asString, + cleanSessionTitle, + countJsonlUserMessagesCheap, + cwdIsInScope, + externalSessionMessageFromRecord, + firstUserTextFromRecords, + normalizeExternalSessionLimit, + normalizeProviderCwd, + readJsonlRecords, + recordWithFile, + safeReadDir, + sessionFileCandidate, + sortDiscoveryRecords, + EXTERNAL_SESSION_READ_BUDGET_MULTIPLIER, + type ExternalSessionDiscoveryArgs, + type ExternalSessionDiscoveryRecord, +} from "./discoveryUtils"; +import { commandArrayToLine } from "../../../shared/shell"; +import type { TerminalResumeLaunchConfig } from "../../../shared/types/sessions"; +import { piSessionDirectoryForEnvironment } from "../chat/piSessionLease"; + +function piSessionsDir(args: ExternalSessionDiscoveryArgs): string { + return piSessionDirectoryForEnvironment(args.env ?? process.env); +} + +function sessionTitle(records: Record[]): string | null { + const info = records + .filter((record) => record.type === "session_info") + .at(-1); + return cleanSessionTitle(asString(info?.name)); +} + +function launchFor(records: Record[]): TerminalResumeLaunchConfig { + const model = records + .map((record) => record.type === "model_change" ? `${asString(record.provider) ?? ""}/${asString(record.modelId) ?? ""}` : "") + .filter((value) => value !== "/") + .at(-1); + const thinking = records + .map((record) => record.type === "thinking_level_change" ? asString(record.thinkingLevel) : null) + .filter((value): value is string => Boolean(value)) + .at(-1); + return { + ...(model ? { model } : {}), + ...(thinking ? { reasoningEffort: thinking } : {}), + }; +} + +function collectSessionFiles(root: string): string[] { + const files: string[] = []; + const pendingDirectories = [path.resolve(root)]; + while (pendingDirectories.length > 0) { + const dir = pendingDirectories.pop(); + if (!dir) continue; + for (const entry of safeReadDir(dir)) { + // Do not follow user-created symlinks while walking the native session + // tree. The header cwd is checked below, but avoiding traversal here + // also prevents a link from expanding discovery outside the configured + // Pi session root (or creating a directory cycle). + if (entry.isSymbolicLink()) continue; + const filePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + pendingDirectories.push(filePath); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + // Pi includes the session id in the JSONL header; current releases + // prefix the filename with a timestamp, so basename === id is not a + // reliable lookup rule. + files.push(filePath); + } + } + } + return files; +} + +/** Discover Pi's native JSONL sessions without importing the user's Pi package. */ +export async function discoverPiSessions( + args: ExternalSessionDiscoveryArgs = {}, +): Promise { + const limit = normalizeExternalSessionLimit(args.limit); + const lookupId = args.sessionId?.trim() || null; + const candidates = collectSessionFiles(piSessionsDir(args)) + .map((filePath) => sessionFileCandidate(filePath, {})) + .filter((candidate): candidate is NonNullable => candidate !== null) + .sort((left, right) => right.mtimeMs - left.mtimeMs); + // An exact lookup must search the whole tree: the requested native session + // may be older than the recent-session discovery budget. + const candidatesToRead = lookupId + ? candidates + : candidates.slice(0, Math.max(limit * EXTERNAL_SESSION_READ_BUDGET_MULTIPLIER, limit)); + const records: ExternalSessionDiscoveryRecord[] = []; + const seen = new Set(); + for (const candidate of candidatesToRead) { + const jsonl = readJsonlRecords(candidate.filePath, 512); + const header = asRecord(jsonl[0]); + if (!header || header.type !== "session") continue; + const id = asString(header.id) ?? path.basename(candidate.filePath, ".jsonl"); + if (!id || (lookupId && id !== lookupId) || seen.has(id)) continue; + const cwd = normalizeProviderCwd(asString(header.cwd)); + if (!cwdIsInScope(cwd, args.scopeRoots)) continue; + const normalizedRecords = jsonl.map(asRecord).filter((record): record is Record => record !== null); + const messages = normalizedRecords + .map(externalSessionMessageFromRecord) + .filter((message): message is NonNullable => message !== null) + .slice(-8); + const timestamp = asEpochMs(header.timestamp); + records.push(recordWithFile({ + provider: "pi", + id, + cwd, + title: sessionTitle(normalizedRecords), + preview: firstUserTextFromRecords(normalizedRecords), + messages, + createdAt: timestamp, + updatedAt: candidate.mtimeMs, + messageCount: countJsonlUserMessagesCheap(candidate.filePath, "pi"), + launch: launchFor(normalizedRecords), + filePath: candidate.filePath, + sourceMtimeMs: candidate.mtimeMs, + })); + seen.add(id); + } + return sortDiscoveryRecords(records, limit); +} + +/** Build a direct Pi CLI resume line for callers that need a native session file. */ +export function piResumeCommandForSession(sessionFileOrId: string): string { + const value = sessionFileOrId.trim(); + return commandArrayToLine(["pi", "--session", value], { platform: "linux" }); +} diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts index 8f18d2b486..c5b532d641 100644 --- a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts @@ -620,7 +620,7 @@ function recoverExternalSessionCommandName(raw: string | null): string | null { return command ? cleanExternalSessionText(command) : null; } -function externalSessionMessageFromRecord(record: unknown): ExternalSessionMessage | null { +export function externalSessionMessageFromRecord(record: unknown): ExternalSessionMessage | null { const shape = externalSessionRecordShape(record); if (!shape) return null; const role = isUserShape(shape) ? "user" : isAssistantShape(shape) ? "assistant" : null; diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts index b4ce0421f2..f96627a20c 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts @@ -27,6 +27,7 @@ import { discoverCodexSessions } from "./discoverCodex"; import { discoverCursorSessions } from "./discoverCursor"; import { discoverDroidSessions } from "./discoverDroid"; import { discoverOpenCodeSessions } from "./discoverOpenCode"; +import { discoverPiSessions } from "./discoverPi"; import { resolveCodexComputerUseMcpConfig } from "../../utils/codexComputerUse"; import { CLAUDE_SESSION_POINTER_MAX_LIMIT } from "../sessions/sessionService"; import { createImportedSessionStore, type ImportedSessionStore } from "./importedSessionStore"; @@ -108,7 +109,7 @@ type LaneScopedExternalSessionImportArgs = ExternalSessionImportArgs & { enforceLaneScopeCwd?: string | null; }; -const PROVIDERS: ExternalSessionProvider[] = ["claude", "codex", "cursor", "droid", "opencode"]; +const PROVIDERS: ExternalSessionProvider[] = ["claude", "codex", "cursor", "droid", "opencode", "pi"]; const UUID_EXTERNAL_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; const CLI_EXTERNAL_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$/u; const PROJECT_SCOPE_DISCOVERY_LIMIT = 200; @@ -149,6 +150,13 @@ const PROVIDER_CAPABILITIES: Record; @@ -159,6 +167,7 @@ const CHAT_SESSION_TOOL_TYPES = new Set([ "opencode-chat", "cursor", "droid-chat", + "pi-chat", ]); function isChatToolType(toolType: TerminalToolType | null | undefined): boolean { @@ -463,6 +472,10 @@ async function forkCommandFor(args: { return `${buildTrackedCliResumeCommand(forkMetadata, args.overrides)} --fork`; } + if (args.provider === "pi") { + return commandArrayToLine(["pi", "--fork", validateExternalSessionId("pi", args.targetId)]); + } + throw new Error("Cursor sessions cannot be forked."); } @@ -587,6 +600,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) cursor: discoverCursorSessions, droid: discoverDroidSessions, opencode: discoverOpenCodeSessions, + pi: discoverPiSessions, }; const list = async (rawArgs: ExternalSessionListArgs = {}): Promise => { @@ -878,10 +892,10 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) } else if (provider === "droid" || provider === "codex") { metadataTargetId = null; runCwd = laneCwd; - } else if (provider === "opencode") { - if (!sourceCwd) throw new Error("OpenCode fork import requires the source session cwd."); + } else if (provider === "opencode" || provider === "pi") { + if (!sourceCwd) throw new Error(`${provider === "pi" ? "Pi" : "OpenCode"} fork import requires the source session cwd.`); if (!pathsEqual(realishPath(sourceCwd), realishPath(laneCwd))) { - throw new Error("OpenCode sessions cannot be copied into a different lane folder."); + throw new Error(`${provider === "pi" ? "Pi" : "OpenCode"} sessions cannot be copied into a different lane folder.`); } metadataTargetId = null; runCwd = sourceCwd; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 7be3d1d9cf..442a84a706 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -39,6 +39,8 @@ import { buildPrAiResolutionContextKey, isAdeUsageRangePreset, isAdeUsageScope } import { detectCliAuthStatuses } from "../ai/authDetector"; import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { buildProviderConnections } from "../ai/providerConnectionStatus"; +import { resolvePiInstallation } from "../ai/piInstallation"; +import { pathsEqual } from "../shared/pathCompare"; import { browseProjectDirectories } from "../projects/projectBrowserService"; import { getProjectDetail } from "../projects/projectDetailService"; import { inspectProjectPathCached } from "../projects/projectPathInspector"; @@ -3705,7 +3707,7 @@ export function registerIpc({ const ctx = getCtx(); const normalized = resolveRendererSuppliedPath(raw, ctx.project.rootPath); const allowedDirs = getAllowedDirs(getCtx); - const allowed = allowedDirs.some((dir) => { + let allowed = allowedDirs.some((dir) => { try { resolvePathWithinRoot(dir, normalized); return true; @@ -3713,6 +3715,13 @@ export function registerIpc({ return false; } }); + // Pi keeps its user-owned profile outside the project roots. Permit only + // the three known JSON config files, never arbitrary files under ~/.pi. + if (!allowed) { + const pi = resolvePiInstallation(); + const piConfigPaths = [pi.settingsPath, pi.authPath, pi.modelsPath].map((value) => path.resolve(value)); + allowed = piConfigPaths.some((candidate) => pathsEqual(candidate, normalized)); + } if (!allowed) { throw new Error("Path is outside allowed directories."); } @@ -4634,6 +4643,7 @@ export function registerIpc({ opencodeProviders: status.opencodeProviders, opencodeProvidersStale: status.opencodeProvidersStale, modelsDevLastFetchedAt: status.modelsDevLastFetchedAt, + piInstallation: status.piInstallation, customProviders: status.customProviders, customModelSlugs: status.customModelSlugs, apiKeyStore: status.apiKeyStore, @@ -5664,7 +5674,7 @@ export function registerIpc({ if (!isRecord(arg)) throw new Error("external session import expects an object payload."); const record = arg as Record; const { provider, target, mode } = record; - if (provider !== "claude" && provider !== "codex" && provider !== "cursor" && provider !== "droid" && provider !== "opencode") { + if (provider !== "claude" && provider !== "codex" && provider !== "cursor" && provider !== "droid" && provider !== "opencode" && provider !== "pi") { throw new Error("external session import provider is invalid."); } if (target !== "cli" && target !== "chat") throw new Error("external session import target must be cli or chat."); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index da40f00e62..9991618757 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -322,10 +322,13 @@ vi.mock("../../utils/codexComputerUse", () => ({ import { createPtyService, ensureNodePtySpawnHelperExecutable, + isPiExecutableCommand, materializeRuntimeCliLaunch, + piForkParentIdFromCommand, PTY_AI_TITLE_DEBOUNCE_MS, PTY_AI_TITLE_TIMEOUT_MS, EARLY_CLI_AI_TITLE_DELAY_MS, + selectPiStorageSessionCandidate, } from "./ptyService"; import { resolveBuiltInBrowserActorCapability } from "../builtInBrowser/builtInBrowserActorCapabilities"; @@ -741,6 +744,48 @@ describe("ptyService", () => { expect(linux.env?.ADE_AGENT_SKILLS_DIRS).not.toContain(";"); }); + it("recognizes Pi executables across Windows launch forms", () => { + expect(isPiExecutableCommand("pi")).toBe(true); + expect(isPiExecutableCommand("pi.exe")).toBe(true); + expect(isPiExecutableCommand("pi.cmd")).toBe(true); + expect(isPiExecutableCommand("C:\\Program Files\\Pi\\pi.bat")).toBe(true); + expect(isPiExecutableCommand("pi-wrapper.exe")).toBe(false); + }); + + describe("Pi native session ownership selection", () => { + it("skips a recent pre-existing session file and selects only the new header", () => { + const oldFile = "/tmp/pi-sessions/2026-08-08T00-00-00-000Z-old.jsonl"; + const newFile = "/tmp/pi-sessions/2026-08-08T00-00-01-000Z-new.jsonl"; + const selected = selectPiStorageSessionCandidate({ + startedAt: "2026-08-08T00:00:01.100Z", + maxStartDeltaMs: 10_000, + candidates: [ + { id: "old", sourcePath: oldFile, createdAt: Date.parse("2026-08-08T00:00:01.050Z") }, + { id: "new", sourcePath: newFile, createdAt: Date.parse("2026-08-08T00:00:01.200Z") }, + ], + excludedIds: new Set(["old"]), + excludedFiles: new Set([oldFile]), + }); + + expect(selected).toEqual({ id: "new", filePath: newFile }); + }); + + it("never upgrades a fork to its parent session", () => { + const parentId = "parent-session"; + const childId = "child-session"; + expect(piForkParentIdFromCommand(`pi --fork ${parentId}`)).toBe(parentId); + expect(selectPiStorageSessionCandidate({ + startedAt: "2026-08-08T00:00:01.100Z", + maxStartDeltaMs: 10_000, + candidates: [ + { id: parentId, sourcePath: "/tmp/pi-sessions/parent.jsonl", createdAt: Date.parse("2026-08-08T00:00:01.050Z") }, + { id: childId, sourcePath: "/tmp/pi-sessions/child.jsonl", createdAt: Date.parse("2026-08-08T00:00:01.200Z") }, + ], + excludedIds: new Set([parentId]), + })).toEqual({ id: childId, filePath: "/tmp/pi-sessions/child.jsonl" }); + }); + }); + describe("resource attribution roots", () => { it("classifies live PTY roots from explicit spawn metadata without sampling processes", async () => { const { service } = createHarness(); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 79fd0ae65e..6fcfd48d01 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -27,12 +27,21 @@ import { } from "../../utils/codexComputerUse"; import { runGit } from "../git/git"; import { resolveOpenCodeBinaryPath } from "../opencode/openCodeBinaryManager"; +import { + acquirePiSessionLease, + piSessionCreationLeaseTarget, + piSessionDirectoryForEnvironment, + listPiSessionFilesForCwd, + resolvePiSessionFile, + type PiSessionLease, +} from "../chat/piSessionLease"; import { preferNativeExecutablePath, resolveCliSpawnInvocation, shouldUseWindowsCmdWrapper, windowsTaskkillInvocation, } from "../shared/processExecution"; +import { pathsEqual } from "../shared/pathCompare"; import type { ResourceAttributionRoot, ResourceAttributionRootKind } from "./resourceUsageSampling"; import { augmentProcessPathWithShellAndKnownCliDirs, @@ -96,11 +105,13 @@ import { resolveWindowsShellKind, } from "../../../shared/cliLaunch"; import { + commandArrayToLine, commandArrayToWindowsShellLine, quoteShellArg, resolveCanonicalCommandLineLaunch, } from "../../../shared/shell"; import { claudeProjectSlugForCwd } from "../externalSessions/discoveryUtils"; +import { discoverPiSessions } from "../externalSessions/discoverPi"; import { droidProjectSlugForCwd } from "../externalSessions/discoverDroid"; import { claudeAgentSkillPluginRoots } from "../skills/agentSkillRuntimeService"; import { stripAnsi } from "../../utils/ansiStrip"; @@ -173,7 +184,7 @@ export type NodePtySpawnHelperExecutableResult = | { status: "failed"; path?: string; error: string }; /** Interactive agent TUIs often hide useful text in an alt-screen, so titles come from the first submitted user prompt instead of startup output. */ -const CLI_USER_TITLE_TOOL_TYPES = new Set(["claude", "codex", "cursor-cli", "droid", "opencode"]); +const CLI_USER_TITLE_TOOL_TYPES = new Set(["claude", "codex", "cursor-cli", "droid", "opencode", "pi"]); function shouldScheduleOutputSnippetTitle(tool: TerminalToolType | null): boolean { if (!tool || tool === "shell") return false; @@ -731,6 +742,18 @@ type PtyEntry = { exitCode: number | null; endedAt: string | null; } | null; + piSessionLease: PiSessionLease | null; + piSessionDir: string | null; + piLaunchEnv: NodeJS.ProcessEnv | null; + piSessionLeaseIsCreation: boolean; + /** Native sessions present before this PTY was spawned. */ + piPreexistingSessionFiles: ReadonlySet; + piPreexistingSessionIds: ReadonlySet; + /** `--fork` parent must never be mistaken for the forked child. */ + piForkParentSessionId: string | null; + piSessionLeaseUpgradeInFlight: boolean; + piSessionLeaseUpgradeTimer: ReturnType | null; + piSessionLeaseUpgradeAttempts: number; }; function isHighSurrogateCodeUnit(codeUnit: number): boolean { @@ -1107,6 +1130,11 @@ function isOpenCodeCommandName(command: string): boolean { return basename === "opencode" || basename === "opencode.exe" || basename === "opencode.cmd" || basename === "opencode.bat"; } +export function isPiExecutableCommand(command: string): boolean { + const basename = command.trim().replace(/^['"]|['"]$/g, "").split(/[\\/]/).pop()?.toLowerCase() ?? ""; + return basename === "pi" || basename === "pi.exe" || basename === "pi.cmd" || basename === "pi.bat"; +} + function resolveDirectOpenCodeCommand(command: string, toolType: TerminalToolType | null): string { if (!isOpenCodeToolType(toolType) || !isOpenCodeCommandName(command)) return command; return resolveOpenCodeBinaryPath() ?? command; @@ -1135,6 +1163,7 @@ const WINDOWS_DIRECT_PROVIDER_EXECUTABLES: ReadonlyArray<{ { toolTypes: ["claude", "claude-orchestrated"], launchCandidates: ["claude"] }, { toolTypes: ["codex", "codex-orchestrated"], launchCandidates: ["codex"] }, { toolTypes: ["droid"], launchCandidates: ["droid"] }, + { toolTypes: ["pi", "pi-chat"], launchCandidates: ["pi"] }, ]; function resolveDirectProviderCommand(command: string, toolType: TerminalToolType | null): string { @@ -1269,12 +1298,14 @@ function normalizeToolType(raw: unknown): TerminalToolType | null { "cursor-cli", "droid", "opencode", + "pi", "claude-orchestrated", "codex-orchestrated", "opencode-orchestrated", "codex-chat", "claude-chat", "opencode-chat", + "pi-chat", "cursor", "droid-chat", "aider", @@ -1295,14 +1326,19 @@ function buildInitialResumeMetadata(args: { startupCommand: string; }): TerminalResumeMetadata | null { const parsedLaunch = parseTrackedCliLaunchConfig(args.startupCommand, args.toolType); + const parsedResume = parseTrackedCliResumeCommand(args.startupCommand, args.toolType); const isClaude = isClaudeTrackedCliToolType(args.toolType); const isCodex = args.toolType === "codex" || args.toolType === "codex-orchestrated"; const isCursor = args.toolType === "cursor-cli"; const isDroid = args.toolType === "droid"; const isOpenCode = args.toolType === "opencode" || args.toolType === "opencode-orchestrated"; + const isPi = args.toolType === "pi" || args.toolType === "pi-chat"; - // Extract pre-assigned --session-id from Claude startup command + // Extract pre-assigned --session-id from Claude startup command. Other + // providers expose their resume target directly in the parsed command. const preAssignedId = isClaude ? extractClaudeSessionIdFromCommand(args.startupCommand) : null; + const parsedTargetId = parsedResume?.targetId ?? null; + const initialTargetId = preAssignedId ?? parsedTargetId; if (parsedLaunch) { let provider: TerminalResumeMetadata["provider"] = "claude"; @@ -1310,16 +1346,17 @@ function buildInitialResumeMetadata(args: { else if (isCursor) provider = "cursor"; else if (isDroid) provider = "droid"; else if (isOpenCode) provider = "opencode"; + else if (isPi) provider = "pi"; return { provider, targetKind: isCodex ? "thread" : "session", - targetId: preAssignedId, + targetId: initialTargetId, launch: parsedLaunch, }; } if (isClaude) { - return { provider: "claude", targetKind: "session", targetId: preAssignedId, launch: {} }; + return { provider: "claude", targetKind: "session", targetId: initialTargetId, launch: {} }; } if (isCodex) { return { provider: "codex", targetKind: "thread", targetId: null, launch: {} }; @@ -1333,6 +1370,9 @@ function buildInitialResumeMetadata(args: { if (isOpenCode) { return { provider: "opencode", targetKind: "session", targetId: null, launch: {} }; } + if (isPi) { + return { provider: "pi", targetKind: "session", targetId: null, launch: {} }; + } return null; } @@ -1505,6 +1545,21 @@ function hasProviderStorageBackfillEvidence(provider: TerminalResumeProvider, te return /\bfactory droid\b/i.test(visible) || /\bdroid\s+(?:session|chat|workspace|permission|autonomy|mode|ready)\b/i.test(visible); } + if (provider === "pi") { + if ( + normalized.includes("login required") + || normalized.includes("authentication required") + || normalized.includes("not authenticated") + || normalized.includes("please log in") + || normalized.includes("sign in") + || normalized.includes("api key required") + || normalized.includes("no api key") + || normalized.includes("provider not configured") + || normalized.includes("no provider configured") + ) return false; + return /\bpi\b.*(?:what do you want|message|thinking|model)/i.test(visible) + || /\bpi\s*[›❯]/i.test(visible); + } if (provider === "opencode") { if ( normalized.includes("login required") @@ -1536,6 +1591,7 @@ function resumeProviderDisplayName(provider: TerminalResumeProvider): string { if (provider === "cursor") return "Cursor"; if (provider === "droid") return "Droid"; if (provider === "opencode") return "OpenCode"; + if (provider === "pi") return "Pi"; return "Agent"; } @@ -1826,6 +1882,76 @@ function resumeTargetIdForProvider( : null; } +export type PiStorageSessionCandidate = { + id: string; + sourcePath?: string | null; + createdAt?: number | null; + updatedAt?: number | null; +}; + +/** + * Pick the native Pi session that belongs to a launch. + * + * Implicit and fork launches begin with a directory creation lease because Pi + * does not expose their JSONL path until it writes the header. A timestamp-only + * lookup is unsafe: a recent session that predates the launch (or a fork's + * parent) can win. The snapshot/parent exclusions are therefore part of the + * selector's contract, not a caller-side hint. + */ +export function selectPiStorageSessionCandidate(args: { + candidates: readonly PiStorageSessionCandidate[]; + startedAt?: string | null; + maxStartDeltaMs?: number; + excludedIds?: ReadonlySet; + excludedFiles?: ReadonlySet; +}): { id: string; filePath: string | null } | null { + const requestedStartedAtMs = Date.parse(args.startedAt ?? ""); + const hasStartedAt = Number.isFinite(requestedStartedAtMs); + let best: { candidate: PiStorageSessionCandidate; score: number; timestamp: number } | null = null; + + for (const candidate of args.candidates) { + const id = sanitizeResumeTargetId(candidate.id); + if (!id || args.excludedIds?.has(id)) continue; + const sourcePath = typeof candidate.sourcePath === "string" && candidate.sourcePath.trim() + ? candidate.sourcePath.trim() + : null; + if (sourcePath && args.excludedFiles) { + let excluded = false; + for (const excludedFile of args.excludedFiles) { + if (pathsEqual(sourcePath, excludedFile)) { + excluded = true; + break; + } + } + if (excluded) continue; + } + const timestamp = candidate.createdAt ?? candidate.updatedAt ?? 0; + if (!Number.isFinite(timestamp) || timestamp <= 0) continue; + const score = hasStartedAt ? Math.abs(timestamp - requestedStartedAtMs) : 0; + if (hasStartedAt && typeof args.maxStartDeltaMs === "number" && score > args.maxStartDeltaMs) continue; + if ( + !best + || (hasStartedAt ? score < best.score : timestamp > best.timestamp) + || (hasStartedAt && score === best.score && timestamp > best.timestamp) + ) { + best = { candidate, score, timestamp }; + } + } + + if (!best) return null; + return { + id: sanitizeResumeTargetId(best.candidate.id)!, + filePath: typeof best.candidate.sourcePath === "string" && best.candidate.sourcePath.trim() + ? best.candidate.sourcePath.trim() + : null, + }; +} + +export function piForkParentIdFromCommand(command: string | null | undefined): string | null { + const match = /(?:^|\s)--fork(?:=|\s+)(?:["']?)([^\s"']+)/iu.exec(command ?? ""); + return sanitizeResumeTargetId(match?.[1] ?? null); +} + export function createPtyService({ projectRoot, transcriptsDir, @@ -1902,6 +2028,12 @@ export function createPtyService({ const terminalSnapshotDir = path.join(projectRoot, ".ade", "cache", "terminal-snapshots"); const ownerPid = processRegistry?.pid ?? null; const ownerProcessStartedAt = processRegistry?.startedAt ?? null; + const piLeaseIdentity = processRegistry + ? { + processStartedAt: ownerProcessStartedAt, + isProcessIdentityLive: (pid: number, startedAt: string) => processRegistry.isProcessIdentityLive(pid, startedAt), + } + : {}; const getResourceAttribution = (): PtyResourceAttribution => { const liveEntries = Array.from(ptys.values()).filter((entry) => !entry.disposed); @@ -2558,14 +2690,20 @@ export function createPtyService({ }; const scheduleTranscriptDependentWork = ( - entry: Pick, + entry: Pick, reason: "close" | "dispose" | "orphan-dispose", ): void => { void Promise.resolve(entry.transcriptRolloverPromise) .catch(() => {}) .then(() => endTranscriptStream(entry.transcriptStream)) .finally(() => { - backfillResumeTargetFromTranscriptBestEffort(entry.sessionId, entry.toolTypeHint, reason, entry.boundCwd); + backfillResumeTargetFromTranscriptBestEffort( + entry.sessionId, + entry.toolTypeHint, + reason, + entry.boundCwd, + entry.piLaunchEnv, + ); summarizeSessionBestEffort(entry.sessionId, { laneWorktreePath: entry.laneWorktreePath, boundCwd: entry.boundCwd, @@ -3157,16 +3295,57 @@ export function createPtyService({ return inferSessionCwdFromTranscriptPath(session.transcriptPath); }; + const resolvePiSessionIdFromStorage = async (args: { + cwd: string; + env?: NodeJS.ProcessEnv | null; + startedAt?: string | null; + maxStartDeltaMs?: number; + excludedIds?: ReadonlySet; + excludedFiles?: ReadonlySet; + }): Promise<{ id: string; filePath: string | null } | null> => { + try { + const sessions = await discoverPiSessions({ + ...(args.env ? { env: args.env } : {}), + cwd: args.cwd, + scopeRoots: [args.cwd], + limit: 200, + }); + return selectPiStorageSessionCandidate({ + candidates: sessions, + ...(args.startedAt !== undefined ? { startedAt: args.startedAt } : {}), + ...(args.maxStartDeltaMs !== undefined ? { maxStartDeltaMs: args.maxStartDeltaMs } : {}), + ...(args.excludedIds ? { excludedIds: args.excludedIds } : {}), + ...(args.excludedFiles ? { excludedFiles: args.excludedFiles } : {}), + }); + } catch { + return null; + } + }; + const tryBackfillResumeTarget = async ( sessionId: string, preferredToolType: TerminalToolType | null, reason: "close" | "dispose" | "orphan-dispose" | "session-list" | "resume-launch", sessionCwd?: string | null, + sessionEnv?: NodeJS.ProcessEnv | null, + knownSession?: TerminalSessionSummary | null, ): Promise => { - const session = sessionService.get(sessionId); + const session = knownSession !== undefined ? knownSession : sessionService.get(sessionId); if (!session?.tracked) return false; const effectiveToolType = preferredToolType ?? session.toolType ?? null; if (!isTrackedAgentCliToolType(effectiveToolType)) return false; + let effectiveSessionEnv = sessionEnv; + if (!effectiveSessionEnv && effectiveToolType === "pi") { + try { + effectiveSessionEnv = { + ...process.env, + ...((await getLaneRuntimeEnv?.(session.laneId)) ?? {}), + }; + } catch { + // Discovery falls back to the current process environment when the + // lane runtime env is unavailable. + } + } const existingTargetId = sanitizeResumeTargetId(session.resumeMetadata?.targetId ?? null); if (existingTargetId) { const cwd = sessionCwd ?? resolveSessionRunCwd(session); @@ -3282,6 +3461,31 @@ export function createPtyService({ } } + if (effectiveToolType === "pi" && cwd && reason !== "resume-launch" && hasStorageBackfillEvidence) { + const activePiEnvironment = sessionEnv + ?? Array.from(ptys.values()).find((entry) => entry.sessionId === sessionId && entry.piLaunchEnv)?.piLaunchEnv; + const piSession = await resolvePiSessionIdFromStorage({ + cwd, + env: effectiveSessionEnv ?? activePiEnvironment, + startedAt: session.startedAt, + maxStartDeltaMs: 10 * 60_000, + }); + if (piSession) { + const resumeCmd = commandArrayToLine(["pi", "--session", piSession.id], { platform: "linux" }); + missingResumeTargetBackfillFailures.delete(sessionId); + sessionService.setResumeCommand(sessionId, resumeCmd); + logger.info("pty.resume_target_backfilled", { + sessionId, + toolType: effectiveToolType, + reason, + source: "pi-session-jsonl", + piSessionId: piSession.id, + piSessionFile: piSession.filePath, + }); + return true; + } + } + if (reason === "session-list") { missingResumeTargetBackfillFailures.set(sessionId, { toolType: effectiveToolType, @@ -3297,8 +3501,9 @@ export function createPtyService({ preferredToolType: TerminalToolType | null, reason: "close" | "dispose" | "orphan-dispose", sessionCwd?: string | null, + sessionEnv?: NodeJS.ProcessEnv | null, ): void => { - void tryBackfillResumeTarget(sessionId, preferredToolType, reason, sessionCwd).catch((err) => { + void tryBackfillResumeTarget(sessionId, preferredToolType, reason, sessionCwd, sessionEnv).catch((err) => { logger.warn("pty.resume_target_backfill_failed", { sessionId, toolType: preferredToolType, @@ -3613,6 +3818,12 @@ export function createPtyService({ if (entry.disposed) return; flushPendingPtyOutput(entry); entry.processOutputData = null; + if (entry.piSessionLeaseUpgradeTimer) { + clearTimeout(entry.piSessionLeaseUpgradeTimer); + entry.piSessionLeaseUpgradeTimer = null; + } + entry.piSessionLease?.release(); + entry.piSessionLease = null; entry.disposed = true; entry.attentionRequested = false; sessionService.clearAttentionRequest(entry.sessionId); @@ -4301,6 +4512,27 @@ export function createPtyService({ ); return lastReadyPromptIndex >= 0 && lastReadyPromptIndex > lastBlockerIndex; } + if (provider === "pi") { + const lastBlockerIndex = lastIndexOfAny(normalized, [ + "login required", + "authentication required", + "not authenticated", + "please log in", + "sign in", + "api key required", + "no api key", + "provider not configured", + "no provider configured", + ]); + const lastReadyIndex = lastIndexOfAny(normalized, [ + "pi", + "what do you want", + "message", + "thinking", + "model:", + ]); + return lastReadyIndex >= 0 && lastReadyIndex > lastBlockerIndex; + } if (provider === "opencode") { const lastBlockerIndex = lastIndexOfAny(normalized, [ "login required", @@ -4545,7 +4777,25 @@ export function createPtyService({ let storedResumeTargetId = resumeTargetIdForProvider(resolvedSession, provider); if (!storedResumeTargetId && provider !== "cursor" && isTrackedAgentCliToolType(resolvedSession.toolType)) { const cwd = resolveSessionRunCwd(resolvedSession); - const backfilled = await tryBackfillResumeTarget(sessionId, resolvedSession.toolType, "resume-launch", cwd); + let sessionEnv: NodeJS.ProcessEnv | undefined; + if (provider === "pi") { + try { + sessionEnv = { + ...process.env, + ...((await getLaneRuntimeEnv?.(resolvedSession.laneId)) ?? {}), + }; + } catch { + // Discovery falls back to the current process environment when the + // lane runtime env is unavailable. + } + } + const backfilled = await tryBackfillResumeTarget( + sessionId, + resolvedSession.toolType, + "resume-launch", + cwd, + sessionEnv, + ); const updatedSession = backfilled ? sessionService.get(sessionId) : null; if (updatedSession) { resolvedSession = updatedSession; @@ -4830,7 +5080,20 @@ export function createPtyService({ )); for (const sessionId of uniqueSessionIds) { try { - await tryBackfillResumeTarget(sessionId, null, "session-list"); + const session = sessionService.get(sessionId); + let sessionEnv: NodeJS.ProcessEnv | undefined; + if (providerFromTool(session?.toolType) === "pi" && session) { + try { + sessionEnv = { + ...process.env, + ...((await getLaneRuntimeEnv?.(session.laneId)) ?? {}), + }; + } catch { + // Discovery falls back to the current process environment when + // the lane runtime env is unavailable. + } + } + await tryBackfillResumeTarget(sessionId, null, "session-list", undefined, sessionEnv, session); } catch (err) { logger.warn("pty.resume_target_backfill_failed", { sessionId, @@ -4977,6 +5240,13 @@ export function createPtyService({ : ""; let startupCommand = withBundledOpenCodeCommandLine(requestedStartupCommand.trim(), toolTypeHint); const cleanupPaths: string[] = []; + let piSessionLease: PiSessionLease | null = null; + let piSessionDirForEntry: string | null = null; + let piLaunchEnvForEntry: NodeJS.ProcessEnv | null = null; + let piSessionLeaseIsCreation = false; + let piPreexistingSessionFiles: ReadonlySet = new Set(); + let piPreexistingSessionIds: ReadonlySet = new Set(); + let piForkParentSessionId: string | null = null; let transcriptStream: fs.WriteStream | null = null; let transcriptBytesWritten = 0; @@ -5158,7 +5428,7 @@ export function createPtyService({ && isTrackedAgentCliToolType(toolTypeHint) && !sanitizeResumeTargetId(existingSession.resumeMetadata?.targetId ?? null); if (shouldBackfillResumeTarget) { - const backfilled = await tryBackfillResumeTarget(sessionId, toolTypeHint, "resume-launch", cwd); + const backfilled = await tryBackfillResumeTarget(sessionId, toolTypeHint, "resume-launch", cwd, launchEnv); const updatedSession = backfilled ? sessionService.get(sessionId) : null; if (updatedSession?.resumeCommand?.trim()) { initialResumeCommand = updatedSession.resumeCommand.trim(); @@ -5203,6 +5473,111 @@ export function createPtyService({ const shellCandidates = resolveShellCandidates(shellMode); let launchedDirectCommand = false; try { + const piResumeTargetId = initialResumeMetadata?.provider === "pi" + && initialResumeMetadata.targetKind === "session" + ? sanitizeResumeTargetId(initialResumeMetadata.targetId ?? null) + : null; + if (toolTypeHint === "pi") { + const piOwnershipCommands = [ + initialResumeCommand, + startupCommand, + directCommand, + directArgs.join(" "), + ].filter((value): value is string => Boolean(value)); + const isForkLaunch = piOwnershipCommands.some((command) => /(?:^|\s)--fork(?:=|\s|$)/iu.test(command)); + const isContinueLaunch = /(?:^|\s)(?:--continue|-c|-r)(?:\s|$)/iu.test(initialResumeCommand ?? "") + || /(?:^|\s)(?:--continue|-c|-r)(?:\s|$)/iu.test(startupCommand); + const piSessionDir = piSessionDirectoryForEnvironment(launchEnv); + piSessionDirForEntry = piSessionDir; + piLaunchEnvForEntry = launchEnv; + // The synthetic creation lease is the first file in this directory; + // create the user-selected Pi session root before publishing it. + fs.mkdirSync(piSessionDir, { recursive: true }); + if (isForkLaunch) { + piForkParentSessionId = piForkParentIdFromCommand(piOwnershipCommands.join(" ")) ?? piResumeTargetId; + } + // Snapshot valid native headers before Pi starts. A recent file that + // predates this PTY is not a candidate for the creation lease, even + // if its timestamp is closest to ADE's launch time. + const preexistingPiSessions = listPiSessionFilesForCwd({ + cwd, + sessionDir: piSessionDir, + env: launchEnv, + }); + piPreexistingSessionFiles = new Set(preexistingPiSessions.map((session) => session.filePath)); + piPreexistingSessionIds = new Set(preexistingPiSessions.map((session) => session.id)); + if (piResumeTargetId && !isForkLaunch) { + const piSessionFile = resolvePiSessionFile({ + cwd, + sessionId: piResumeTargetId, + sessionDir: piSessionDir, + env: launchEnv, + }); + if (!piSessionFile) { + throw new Error(`Pi session '${piResumeTargetId}' was not found in the selected working directory.`); + } + piSessionLease = acquirePiSessionLease({ + sessionFile: piSessionFile, + owner: "cli", + ownerId: ptyId, + ...piLeaseIdentity, + }); + } else if (isContinueLaunch && !isForkLaunch) { + // Resolve --continue/-c/-r before spawning whenever Pi already has + // a concrete latest session. This removes the window where Pi can + // begin writing that JSONL before ADE has upgraded its synthetic + // directory lease to the adjacent session lease. + const latest = await resolvePiSessionIdFromStorage({ cwd, env: launchEnv }); + if (latest) { + const piSessionFile = resolvePiSessionFile({ + cwd, + sessionId: latest.id, + sessionFile: latest.filePath, + sessionDir: piSessionDir, + env: launchEnv, + }); + if (!piSessionFile) { + throw new Error("Pi's latest session is outside the authorized native session directory."); + } + piSessionLease = acquirePiSessionLease({ + sessionFile: piSessionFile, + owner: "cli", + ownerId: ptyId, + ...piLeaseIdentity, + }); + const piResumeCommand = `pi --session ${latest.id}`; + initialResumeCommand = piResumeCommand; + startupCommand = withBundledOpenCodeCommandLine(piResumeCommand, toolTypeHint); + sessionService.setResumeCommand(sessionId, piResumeCommand); + if (directCommand && isPiExecutableCommand(directCommand)) { + directArgs = directArgs.filter((arg) => !/^(?:--continue|-c|-r)$/iu.test(arg)); + directArgs.push("--session", latest.id); + } + } else { + piSessionLeaseIsCreation = true; + piSessionLease = acquirePiSessionLease({ + sessionFile: piSessionCreationLeaseTarget(piSessionDir), + owner: "cli", + ownerId: ptyId, + ...piLeaseIdentity, + }); + } + } else { + // Fresh, --continue, and --fork launches do not know the native + // JSONL path until Pi writes it. Hold a directory creation lease + // for the entire PTY lifetime so two ADE launches cannot both + // target the implicit "most recent" session at once. + const creationTarget = piSessionCreationLeaseTarget(piSessionDir); + piSessionLease = acquirePiSessionLease({ + sessionFile: creationTarget, + owner: "cli", + ownerId: ptyId, + ...piLeaseIdentity, + }); + piSessionLeaseIsCreation = true; + } + launchEnv.ADE_PI_SESSION_LEASE_PATH = piSessionLease.lockPath; + } const spawnHelperRepair = ensureNodePtySpawnHelperExecutable(); if (spawnHelperRepair.status === "chmod_applied") { logger.info("pty.spawn_helper_chmod_applied", { path: spawnHelperRepair.path }); @@ -5287,6 +5662,11 @@ export function createPtyService({ resourcesPath: process.resourcesPath ?? "", err: String(err), }); + piSessionLease?.release(); + piSessionLease = null; + piSessionDirForEntry = null; + piLaunchEnvForEntry = null; + piSessionLeaseIsCreation = false; for (const cleanupPath of cleanupPaths) { try { fs.unlinkSync(cleanupPath); @@ -5392,6 +5772,16 @@ export function createPtyService({ cliUserTitleLineBuffer: "", cliUserTitleCommitted: false, priorEndState, + piSessionLease, + piSessionDir: piSessionDirForEntry, + piLaunchEnv: piLaunchEnvForEntry, + piSessionLeaseIsCreation, + piPreexistingSessionFiles, + piPreexistingSessionIds, + piForkParentSessionId, + piSessionLeaseUpgradeInFlight: false, + piSessionLeaseUpgradeTimer: null, + piSessionLeaseUpgradeAttempts: 0, }; ptys.set(ptyId, entry); if (chatSessionId) { @@ -5406,6 +5796,104 @@ export function createPtyService({ let titleOutputBuffer = ""; let titleBufferFull = false; + const schedulePiSessionLeaseUpgrade = (entry: PtyEntry, delayMs: number): void => { + if ( + entry.disposed + || entry.toolTypeHint !== "pi" + || !entry.piSessionLeaseIsCreation + || entry.piSessionLeaseUpgradeTimer + ) return; + entry.piSessionLeaseUpgradeTimer = setTimeout(() => { + entry.piSessionLeaseUpgradeTimer = null; + void maybeUpgradePiSessionLease(entry); + }, delayMs); + entry.piSessionLeaseUpgradeTimer.unref?.(); + }; + + const maybeUpgradePiSessionLease = async (entry: PtyEntry): Promise => { + if ( + entry.disposed + || entry.toolTypeHint !== "pi" + || !entry.piSessionLeaseIsCreation + || entry.piSessionLeaseUpgradeInFlight + || !entry.piSessionLease + || !entry.piSessionDir + || !entry.piLaunchEnv + ) return; + const creationLease = entry.piSessionLease; + entry.piSessionLeaseUpgradeInFlight = true; + let candidateFound = false; + try { + const candidate = await resolvePiSessionIdFromStorage({ + cwd: entry.boundCwd, + env: entry.piLaunchEnv, + startedAt: sessionService.get(entry.sessionId)?.startedAt ?? null, + maxStartDeltaMs: 10 * 60_000, + excludedIds: new Set([ + ...entry.piPreexistingSessionIds, + ...(entry.piForkParentSessionId ? [entry.piForkParentSessionId] : []), + ]), + excludedFiles: entry.piPreexistingSessionFiles, + }); + if (!candidate) return; + candidateFound = true; + const sessionFile = resolvePiSessionFile({ + cwd: entry.boundCwd, + sessionId: candidate.id, + sessionFile: candidate.filePath, + sessionDir: entry.piSessionDir, + env: entry.piLaunchEnv, + }); + if (!sessionFile) throw new Error("Pi discovered a session outside the authorized native session directory."); + if (entry.disposed || entry.piSessionLease !== creationLease) return; + const concreteLease = acquirePiSessionLease({ + sessionFile, + owner: "cli", + ownerId: ptyId, + ...piLeaseIdentity, + }); + if (entry.disposed || entry.piSessionLease !== creationLease) { + concreteLease.release(); + return; + } + entry.piSessionLease = concreteLease; + entry.piSessionLeaseIsCreation = false; + creationLease.release(); + entry.resumeCommand = `pi --session ${candidate.id}`; + entry.resumeCommandIsFallback = false; + sessionService.setResumeCommand(entry.sessionId, entry.resumeCommand); + logger.info("pty.pi_session_lease_upgraded", { + sessionId: entry.sessionId, + ptyId, + sessionIdFromHeader: candidate.id, + sessionFile, + }); + } catch (error) { + logger.warn("pty.pi_session_lease_upgrade_failed", { + sessionId: entry.sessionId, + ptyId, + candidateFound, + error: error instanceof Error ? error.message : String(error), + }); + // Once Pi has selected a concrete native session, failing to own it + // is unsafe: leaving the process running would let two ADE writers + // mutate the same JSONL. Terminate this launch and let the session + // row remain resumable through its native pointer. + if (candidateFound && !entry.disposed) { + terminatePtyProcessTree(entry, "SIGTERM", logger); + closeEntry(ptyId, 1); + } + } finally { + entry.piSessionLeaseUpgradeInFlight = false; + if (entry.piSessionLeaseIsCreation && !entry.disposed) { + const delays = [250, 1_000, 3_000, 8_000, 20_000, 30_000]; + const attempt = entry.piSessionLeaseUpgradeAttempts; + entry.piSessionLeaseUpgradeAttempts += 1; + if (attempt < delays.length) schedulePiSessionLeaseUpgrade(entry, delays[attempt]!); + } + } + }; + const processOutputData = (data: string): void => { // Late chunks can arrive after closeEntry()/dispose() has flushed the // final buffer and emitted ptyExit. Bail out so post-teardown data @@ -5413,6 +5901,7 @@ export function createPtyService({ // emit ptyData after ptyExit while transcript summarization is in // flight. if (entry.disposed) return; + void maybeUpgradePiSessionLease(entry); resyncLiveSessionRowIfNeeded(entry, ptyId); appendRecentOutput(entry, data); adoptCliRuntimeWindowTitle(entry, data); @@ -5468,6 +5957,7 @@ export function createPtyService({ } }; entry.processOutputData = processOutputData; + if (entry.piSessionLeaseIsCreation) void maybeUpgradePiSessionLease(entry); pty.onData((rawData) => { if (entry.disposed) return; @@ -6913,6 +7403,12 @@ export function createPtyService({ if (entry.disposed) return { disposed: false, reason: "already-disposed" }; flushPendingPtyOutput(entry); entry.processOutputData = null; + if (entry.piSessionLeaseUpgradeTimer) { + clearTimeout(entry.piSessionLeaseUpgradeTimer); + entry.piSessionLeaseUpgradeTimer = null; + } + entry.piSessionLease?.release(); + entry.piSessionLease = null; entry.disposed = true; entry.attentionRequested = false; sessionService.clearAttentionRequest(entry.sessionId); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 2b422ac3a4..93ea8d2f53 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -145,7 +145,7 @@ const CLAUDE_SESSION_COLUMNS = ` `; function isResumeProvider(value: unknown): value is TerminalResumeProvider { - return value === "claude" || value === "codex" || value === "cursor" || value === "droid" || value === "opencode"; + return value === "claude" || value === "codex" || value === "cursor" || value === "droid" || value === "opencode" || value === "pi"; } function normalizeAttentionSource(value: unknown): SessionAttentionSource | null { @@ -481,12 +481,14 @@ export function createSessionService({ db }: { db: AdeDb }) { "cursor-cli", "droid", "opencode", + "pi", "claude-orchestrated", "codex-orchestrated", "opencode-orchestrated", "codex-chat", "claude-chat", "opencode-chat", + "pi-chat", "cursor", "droid-chat", "aider", diff --git a/apps/desktop/src/main/utils/sessionSummary.ts b/apps/desktop/src/main/utils/sessionSummary.ts index def4542eb7..6b8dfbc50a 100644 --- a/apps/desktop/src/main/utils/sessionSummary.ts +++ b/apps/desktop/src/main/utils/sessionSummary.ts @@ -117,7 +117,7 @@ function isTerminalChromeLine(raw: string): boolean { if (/\bClaude Codev?\d/i.test(line) || /^Claude Code\b/i.test(line)) return true; if (/\b(?:for shortcuts|for agents|bypass permissions|auto mode on)\b/i.test(line)) return true; if (/^Resume this session with:/i.test(line)) return true; - if (/^(?:claude\s+--resume|codex\s+resume|cursor-agent\s+--resume|droid\s+--resume|opencode\s+--(?:continue|session))\b/i.test(line)) return true; + if (/^(?:claude\s+--resume|codex\s+resume|cursor-agent\s+--resume|droid\s+--resume|opencode\s+--(?:continue|session)|pi\s+--(?:continue|session))\b/i.test(line)) return true; if (/^\[?esc\]?\s+close\b/i.test(line)) return true; return false; } diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index 05619ba202..ee25b53d54 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -19,9 +19,9 @@ import { import { parseCommandLine } from "../../shared/shell"; const OSC_133_REGEX = /\u001b\]133;([ABCD])(?:;[^\u0007\u001b]*)?(?:\u0007|\u001b\\)/g; -const RESUME_BACKTICK_REGEX = /`([^`\r\n]*(?:claude|codex|cursor-agent|droid|opencode)\s+[^`\r\n]*(?:--resume|-r|resume|--continue|-c|--session|-s)[^`\r\n]*)`/gi; +const RESUME_BACKTICK_REGEX = /`([^`\r\n]*(?:claude|codex|cursor-agent|droid|opencode|pi)\s+[^`\r\n]*(?:--resume|-r|resume|--continue|-c|--session|-s)[^`\r\n]*)`/gi; const RESUME_HINT_PREFIX_REGEX = /\b(?:resume|continue)\s+with\s+(.+)$/i; -const RESUME_COMMAND_LINE_REGEX = /^(?:.*?(?:[%$#❯›]\s+))?((?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)\s+)*(?:claude|codex|cursor-agent|droid|opencode)\s+.+)$/i; +const RESUME_COMMAND_LINE_REGEX = /^(?:.*?(?:[%$#❯›]\s+))?((?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)\s+)*(?:claude|codex|cursor-agent|droid|opencode|pi)\s+.+)$/i; export const sanitizeResumeTargetId = sanitizeTrackedCliResumeTargetId; @@ -146,6 +146,7 @@ function toolFromCommand(raw: string): TerminalToolType | null { if (normalized.startsWith("cursor-agent ")) return "cursor-cli"; if (normalized.startsWith("droid ")) return "droid"; if (normalized.startsWith("opencode ")) return "opencode"; + if (/^pi(?:\s|$)/.test(normalized)) return "pi"; return null; } @@ -155,6 +156,7 @@ export function providerFromTool(toolType: TerminalToolType | null | undefined): if (toolType === "cursor-cli") return "cursor"; if (toolType === "droid") return "droid"; if (toolType === "opencode" || toolType === "opencode-orchestrated" || toolType === "opencode-chat") return "opencode"; + if (toolType === "pi" || toolType === "pi-chat") return "pi"; return null; } @@ -228,6 +230,14 @@ function extractTrackedCliPermissionMode(command: string, provider: TerminalResu return "plan"; } + if (provider === "pi") { + const tools = extractCliFlagValue(normalized, "--tools")?.split(",").map((entry) => entry.trim()) ?? []; + if (tools.includes("bash")) return "full-auto"; + if (tools.includes("edit") || tools.includes("write")) return "edit"; + if (tools.length > 0) return "plan"; + return "default"; + } + if (provider === "opencode") { if ( normalized.includes("opencode_config_content=") @@ -270,7 +280,8 @@ export function parseTrackedCliLaunchConfig( const variant = extractOpenCodeVariant(normalized); return variant?.toLowerCase() === "fast" ? null : variant; })() - : (extractCliFlagValue(normalized, "--effort") ?? extractCliFlagValue(normalized, "--reasoning-effort"))); + : (extractCliFlagValue(normalized, provider === "pi" ? "--thinking" : "--effort") + ?? extractCliFlagValue(normalized, "--reasoning-effort"))); const fastMode = provider === "codex" ? extractFastMode(normalized) : provider === "claude" @@ -374,7 +385,9 @@ function parseProviderResumeTarget(provider: TerminalResumeProvider, command: st return sanitizeResumeTargetId(raw) ?? undefined; } - const match = command.match(/^opencode\b.*?(?:--session(?:=|\s+)([^\s]+)|-s\s+([^\s]+)|--continue\b|-c\b)(?:\s|$)/i); + const match = (provider === "pi" + ? command.match(/^pi\b.*?(?:--session(?:=|\s+)([^\s]+)|--continue\b|-c\b|-r\b)(?:\s|$)/i) + : command.match(/^opencode\b.*?(?:--session(?:=|\s+)([^\s]+)|-s\s+([^\s]+)|--continue\b|-c\b)(?:\s|$)/i)); if (!match) return undefined; const raw = match[1] ?? match[2]; if (raw == null) return null; @@ -451,6 +464,7 @@ export function defaultResumeCommandForTool(toolType: TerminalToolType | null | if (toolType === "cursor-cli") return "cursor-agent --model auto --continue"; if (toolType === "droid") return "droid --resume"; if (toolType === "opencode" || toolType === "opencode-orchestrated") return "opencode --continue"; + if (toolType === "pi") return "pi --continue"; return null; } diff --git a/apps/desktop/src/main/utils/terminalTuiMarkers.ts b/apps/desktop/src/main/utils/terminalTuiMarkers.ts index d36d0f7118..0593e7ab78 100644 --- a/apps/desktop/src/main/utils/terminalTuiMarkers.ts +++ b/apps/desktop/src/main/utils/terminalTuiMarkers.ts @@ -135,12 +135,20 @@ const DROID_PACK: MarkerPack = { working: [ESC_TO_INTERRUPT], }; +/** Pi's interactive CLI uses the same compact approval/plan vocabulary as its RPC surface. */ +const PI_PACK: MarkerPack = { + planning: [/\bplan mode\b/i, /\bplanning\b/i], + waitingInput: [NUMBERED_YES_OPTION, YES_NO_PROMPT, /\bapprove\b[^\n]{0,30}\?/i], + working: [ESC_TO_INTERRUPT], +}; + const PACKS: Record = { claude: CLAUDE_PACK, codex: CODEX_PACK, cursor: CURSOR_PACK, droid: DROID_PACK, opencode: OPENCODE_PACK, + pi: PI_PACK, }; export type TuiMarkerState = { diff --git a/apps/desktop/src/renderer/assets/provider-logos/pi.svg b/apps/desktop/src/renderer/assets/provider-logos/pi.svg new file mode 100644 index 0000000000..785f40d295 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/pi.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index bf1ebc56b6..01652259bc 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -56,6 +56,7 @@ vi.mock("@lobehub/icons", () => { Codex: brand(), Cursor: brand(), Gemini: brand(), + GithubCopilot: brand(), Google: brand(), Grok: brand(), Groq: brand(), diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 250917d6e5..01e7f1cb0f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -34,6 +34,7 @@ vi.mock("@lobehub/icons", () => { Codex: brand(), Cursor: brand(), OpenCode: brand(), + GithubCopilot: brand(), }; }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 6c83dbbea0..8c886b5707 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -118,6 +118,7 @@ vi.mock("@lobehub/icons", () => { Codex: brand(), Cursor: brand(), Gemini: brand(), + GithubCopilot: brand(), Google: brand(), Grok: brand(), Groq: brand(), diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index ba289a802f..60105a7887 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1603,7 +1603,7 @@ function defaultNativeControls(profile: ChatSurfaceProfile): NativeControlState }; } -type ChatRuntimeProviderKey = "claude" | "codex" | "cursor" | "droid" | "opencode"; +type ChatRuntimeProviderKey = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi"; function resolveChatRuntimeProvider(desc: ModelDescriptor | null | undefined): ChatRuntimeProviderKey { return desc ? resolveProviderGroupForModel(desc) : "opencode"; @@ -2847,6 +2847,7 @@ function chatToolTypeForProvider(provider: string | null | undefined): TerminalT case "claude": return "claude-chat"; case "cursor": return "cursor"; case "droid": return "droid-chat"; + case "pi": return "pi-chat"; default: return "opencode-chat"; } } @@ -5468,8 +5469,10 @@ export function AgentChatPane({ return filterCursorModelIdsForDraftKind([...merged], workDraftKind); }, [availableModelIds, availableModelIdsOverride, modelSelectionConstrained, selectedSessionModelId, selectedEvents.length, runtimeCatalogVersion, workDraftKind]); const modelPickerProviderAuthStatus = useMemo( - () => (aiStatus ? familiesFromStatus(aiStatus) : undefined), - [aiStatus], + () => (aiStatus + ? familiesFromStatus(aiStatus, { allowCliOnlyModels: workDraftKind === "cli" }) + : undefined), + [aiStatus, workDraftKind], ); const cursorCloudModelIds = useMemo( () => effectiveAvailableModelIds.filter((id) => id.startsWith("cursor/")), diff --git a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx index f6c7d9ec4c..e0cf9cd087 100644 --- a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx @@ -77,6 +77,7 @@ function CommandCopyButton({ command, label }: { command: string; label: string function ShellRunButton({ command, + initialInput, label, laneId, chatSessionId, @@ -85,6 +86,7 @@ function ShellRunButton({ onLaunched, }: { command: string; + initialInput?: string; label: string; laneId?: string | null; chatSessionId?: string | null; @@ -118,6 +120,7 @@ function ShellRunButton({ tracked: true, toolType: "shell", startupCommand: command, + ...(initialInput ? { initialInput, initialInputDelayMs: 1_200 } : {}), }); })() .then((created) => { @@ -132,7 +135,7 @@ function ShellRunButton({ setError(err instanceof Error ? err.message : String(err)); }) .finally(() => setRunning(false)); - }, [chatSessionId, command, disabled, label, laneId, onLaunched, onRevealTerminal]); + }, [chatSessionId, command, disabled, initialInput, label, laneId, onLaunched, onRevealTerminal]); return (
@@ -305,7 +308,8 @@ export function AgentCliAuthCard({ ; +type ResolverPermissionFamily = Extract; type ResolverPermissionPreferences = Record; const DEFAULT_RESOLVER_PERMISSIONS: ResolverPermissionPreferences = { @@ -246,6 +246,7 @@ const DEFAULT_RESOLVER_PERMISSIONS: ResolverPermissionPreferences = { opencode: "edit", cursor: "default", droid: "edit", + pi: "default", }; function normalizeResolverPermissionMode(value: unknown): PrAgentPermissionMode | null { @@ -273,6 +274,7 @@ function readPersistedResolverPermissions(): ResolverPermissionPreferences { opencode: normalizeResolverPermissionMode(parsed?.opencode) ?? DEFAULT_RESOLVER_PERMISSIONS.opencode, cursor: normalizeResolverPermissionMode(parsed?.cursor) ?? DEFAULT_RESOLVER_PERMISSIONS.cursor, droid: normalizeResolverPermissionMode(parsed?.droid) ?? DEFAULT_RESOLVER_PERMISSIONS.droid, + pi: normalizeResolverPermissionMode(parsed?.pi) ?? DEFAULT_RESOLVER_PERMISSIONS.pi, }; } catch { return DEFAULT_RESOLVER_PERMISSIONS; diff --git a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx index 8ccaeca22b..8f6539ae85 100644 --- a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx @@ -23,6 +23,7 @@ vi.mock("@lobehub/icons", () => { Codex: brand(), Cursor: brand(), Gemini: brand(), + GithubCopilot: brand(), Google: brand(), Grok: brand(), Groq: brand(), diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx index 72597843b5..bc1bdbd835 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx @@ -26,6 +26,7 @@ vi.mock("@lobehub/icons", () => { Codex: brand(), Cursor: brand(), Gemini: brand(), + GithubCopilot: brand(), Google: brand(), Grok: brand(), Groq: brand(), @@ -53,6 +54,7 @@ function buildStatus( opencodeProviders?: Array<{ id: string; name: string; connected: boolean; modelCount: number }>; opencodeProvidersStale?: boolean; modelsDevLastFetchedAt?: number | null; + piInstallation?: AiSettingsStatus["piInstallation"]; }, ): AiSettingsStatus { const claudeBinaryPresent = options?.claudeBinaryPresent ?? claudeRuntimeAvailable; @@ -175,6 +177,19 @@ function buildStatus( lastCheckedAt: "2026-03-17T19:00:00.000Z", sources: [], }, + ...(options?.piInstallation ? { + pi: { + provider: "pi", + authAvailable: options.piInstallation.providers.some((provider) => provider.configured), + runtimeDetected: options.piInstallation.sdkAvailable || options.piInstallation.cliAvailable, + runtimeAvailable: options.piInstallation.availableModelIds.length > 0, + usageAvailable: false, + path: options.piInstallation.cliPath ?? options.piInstallation.packageRoot, + blocker: options.piInstallation.blocker, + lastCheckedAt: "2026-03-17T19:00:00.000Z", + sources: [], + }, + } : {}), }, apiKeyStore: { secureStorageAvailable: true, @@ -186,6 +201,7 @@ function buildStatus( opencodeProviders: options?.opencodeProviders ?? [], ...(options?.opencodeProvidersStale != null ? { opencodeProvidersStale: options.opencodeProvidersStale } : {}), ...(options?.modelsDevLastFetchedAt !== undefined ? { modelsDevLastFetchedAt: options.modelsDevLastFetchedAt } : {}), + ...(options?.piInstallation ? { piInstallation: options.piInstallation } : {}), } as AiSettingsStatus; } @@ -264,6 +280,7 @@ describe("ProvidersSection", () => { }, app: { openExternal: vi.fn().mockResolvedValue(undefined), + openPath: vi.fn().mockResolvedValue(undefined), }, } as any; }); @@ -510,6 +527,63 @@ describe("ProvidersSection", () => { expect(screen.queryByText(/subscriptions ·/i)).toBeNull(); }); + it("renders the Pi card with connected providers and opens Pi settings files", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { + piInstallation: { + installed: true, + sdkAvailable: true, + cliAvailable: true, + cliPath: "/Users/example/.local/bin/pi", + packageRoot: "/Users/example/.pi/agent/node_modules/@earendil-works/pi-coding-agent", + version: "0.84.0", + agentDir: "/Users/example/.pi/agent", + settingsPath: "/Users/example/.pi/agent/settings.json", + authPath: "/Users/example/.pi/agent/auth.json", + modelsPath: "/Users/example/.pi/agent/models.json", + modelsStorePath: "/Users/example/.pi/agent/models-store.json", + blocker: null, + providers: [ + { + id: "openai-codex", + name: "OpenAI Codex", + modelCount: 7, + availableModelCount: 7, + configured: true, + authType: "oauth", + authMethods: ["oauth"], + authSource: "stored", + authLabel: "OAuth", + subscription: true, + }, + ], + availableModelIds: ["pi/openai-codex/gpt-5.4"], + authFileDetected: true, + modelsFileDetected: false, + settingsFileDetected: true, + stale: false, + }, + })); + const listApiKeysMock = window.ade.ai.listApiKeys as ReturnType; + listApiKeysMock.mockReset(); + listApiKeysMock.mockResolvedValue([]); + + renderProvidersSection(); + + expect(await screen.findByText("Pi")).toBeTruthy(); + expect(screen.getByText(/Uses Pi’s installed SDK package/)).toBeTruthy(); + expect(screen.getByText(/Version 0.84.0/)).toBeTruthy(); + expect(screen.getByText("Configured providers")).toBeTruthy(); + expect(screen.getByText("OpenAI Codex")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open settings.json" })).toBeTruthy(); + + await act(async () => { + screen.getByRole("button", { name: "Open settings.json" }).click(); + }); + expect(window.ade.app.openPath).toHaveBeenCalledWith("/Users/example/.pi/agent/settings.json"); + }); + it("collapses the OpenCode group to an install card when the binary is missing", async () => { const getStatusMock = window.ade.ai.getStatus as ReturnType; getStatusMock.mockReset(); diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 03995399f4..9df29b3d3d 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -4,6 +4,8 @@ import type { AiConfig, AiApiKeyVerificationResult, AiClaudeAvailability, + AiPiInstallationStatus, + AiPiProviderStatus, AiProviderConnectionStatus, AiSettingsStatus, ProjectConfigSnapshot, @@ -32,7 +34,7 @@ import { XCircle, } from "@phosphor-icons/react"; import { ClaudeLogo, CodexLogo, CursorAgentLogo, OpenCodeLogo } from "../terminals/ToolLogos"; -import { ProviderLogo } from "../shared/ProviderLogos"; +import { PiLogo, ProviderLogo } from "../shared/ProviderLogos"; import { COLORS, MONO_FONT, @@ -42,11 +44,13 @@ import { primaryButton, } from "../lanes/laneDesignTokens"; import { cursorProviderAvailable, rendererPlatformAttribute } from "../../lib/platform"; +import { openExternalUrl } from "../../lib/openExternal"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { shouldRefreshAiStatusForChatEvent } from "../../lib/aiProviderStatus"; import { showToast } from "../app/toast/toastStore"; import { ClaudeLoginPromptButton, revealTerminalSessionInWork } from "../work/ClaudeLoginPromptButton"; +import { PiLoginPromptButton } from "../work/PiLoginPromptButton"; import { OpenCodeProviderDetailModal, type ApiKeySource, @@ -441,6 +445,8 @@ function describeCredentialSource(connection: AiProviderConnectionStatus | null if (localSource.source === "cursor-env") return "Detected via CURSOR_API_KEY environment variable."; if (localSource.source === "cursor-api-key-store") return "Cursor API key is stored in ADE encrypted storage."; if (localSource.source === "factory-env") return "Detected via FACTORY_API_KEY environment variable."; + if (localSource.source === "pi-auth-file") return "Detected via ~/.pi/agent/auth.json."; + if (localSource.source === "pi-models-file") return "Detected via ~/.pi/agent/models.json."; return null; } @@ -481,6 +487,61 @@ function formatLocalModelLabel(modelId: string): string { return String(modelId ?? "").trim(); } +function getPiTone( + connection: AiProviderConnectionStatus | null | undefined, + installation: AiPiInstallationStatus | null | undefined, +): { color: string; label: string } { + if (!installation?.installed && !connection?.runtimeDetected) { + return { color: COLORS.textDim, label: "Not detected" }; + } + if (installation?.installed && !installation.sdkAvailable) { + return { color: COLORS.warning, label: "SDK needed" }; + } + if (connection?.runtimeAvailable) { + return { color: COLORS.success, label: "Ready" }; + } + if (installation?.sdkAvailable && connection?.authAvailable) { + return { color: COLORS.warning, label: "Configured" }; + } + if (installation?.sdkAvailable || installation?.cliAvailable) { + return { color: COLORS.warning, label: "Sign-in required" }; + } + return { color: COLORS.danger, label: "Unavailable" }; +} + +function buildPiMessage( + connection: AiProviderConnectionStatus | null | undefined, + installation: AiPiInstallationStatus | null | undefined, +): string { + if (!installation) { + return "Checking Pi installation and provider inventory."; + } + const configuredProviders = installation.providers.filter((provider) => provider.configured).length; + const availableModels = installation.availableModelIds.length; + if (connection?.runtimeAvailable) { + const version = installation.version ? `Pi ${installation.version}` : "Pi"; + return `${version} is installed. ${availableModels} model${availableModels === 1 ? " is" : "s are"} available across ${configuredProviders} configured provider${configuredProviders === 1 ? "" : "s"}.`; + } + if (connection?.blocker) { + return connection.blocker; + } + if (installation.installed && !installation.sdkAvailable) { + return installation.blocker + ?? "Pi CLI is available, but ADE's Pi SDK package is missing. Install @earendil-works/pi-coding-agent or set ADE_PI_PACKAGE_ROOT."; + } + if (!installation.installed) { + return "Pi is not installed for this user yet. Install @earendil-works/pi-coding-agent, then use Refresh. Pi keeps its own credentials and profile."; + } + return "Pi is installed, but no configured providers or available models were detected yet."; +} + +function piProviderAuthSummary(provider: AiPiProviderStatus): string { + if (provider.authType === "oauth") return provider.subscription ? "OAuth subscription" : "OAuth"; + if (provider.authType === "api-key") return "API key"; + if (provider.authType === "local") return "Local endpoint"; + return provider.authMethods.length ? provider.authMethods.join(" / ") : "No auth"; +} + function buildLocalProviderDrafts( snapshot: ProjectConfigSnapshot | null | undefined, status: AiSettingsStatus | null | undefined, @@ -627,9 +688,12 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh const detectedAuth = useMemo(() => status?.detectedAuth ?? [], [status?.detectedAuth]); const providerConnections = status?.providerConnections; + const piInstallation = status?.piInstallation ?? null; + const piConnection = providerConnections?.pi ?? null; // Keep provider cards neutral while the status payload is unavailable. A // failed first probe must not be presented as a real "Binary Missing" state. const isInitialCheckInFlight = status == null; + const piStatusLoadFailed = isInitialCheckInFlight && !loading && statusLoadError !== null; const opencodeStatusKnown = status !== null; const opencodeStatusLoadFailed = !opencodeStatusKnown && !loading && statusLoadError !== null; const opencodeInstalled = status?.opencodeBinaryInstalled !== false; @@ -638,11 +702,14 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh const apiKeySources = useMemo(() => { const map = new Map(); + const sourceForKey = (source: string | undefined): ApiKeySource | null => + source === "store" || source === "env" || source === "config" ? source : null; for (const entry of detectedAuth) { - if (entry.type === "api-key" && entry.provider && entry.source) { - map.set(entry.provider.toLowerCase(), entry.source); - } else if (entry.type === "openrouter" && entry.source) { - map.set("openrouter", entry.source); + const source = sourceForKey(entry.source); + if (entry.type === "api-key" && entry.provider && source) { + map.set(entry.provider.toLowerCase(), source); + } else if (entry.type === "openrouter" && source) { + map.set("openrouter", source); } } return map; @@ -1284,6 +1351,128 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh ); })()} + + {(() => { + const tone = piStatusLoadFailed + ? { color: COLORS.danger, label: "Unavailable" } + : isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getPiTone(piConnection, piInstallation); + const message = piStatusLoadFailed + ? `Could not load Pi status: ${statusLoadError}` + : isInitialCheckInFlight + ? "Checking Pi installation and provider inventory." + : buildPiMessage(piConnection, piInstallation); + const configuredProviders = piInstallation?.providers.filter((provider) => provider.configured) ?? []; + return ( +
+
+
+ +
+
Pi
+
+ Uses Pi’s installed SDK package and redacted auth status from its native profile. +
+
+
+
+ {isInitialCheckInFlight ? : piConnection?.runtimeAvailable ? : piConnection?.authAvailable || piConnection?.runtimeDetected ? : } + {tone.label} +
+
+
{message}
+ {piStatusLoadFailed ? ( + + ) : null} + {piInstallation?.error ? ( +
+ Inventory fallback: {piInstallation.error} +
+ ) : null} + {piInstallation?.version ? ( +
+ Version {piInstallation.version}{piInstallation.stale ? " · cached" : ""} +
+ ) : null} + {piConnection?.path && !isInitialCheckInFlight ? {piConnection.path} : null} + + {configuredProviders.length > 0 ? ( +
+
+ Configured providers +
+
+ {configuredProviders.map((provider) => ( +
+
+
+ + + {provider.name} + +
+ {provider.availableModelCount > 0 ? ( + + ) : ( + + No models + + )} +
+
+ {provider.availableModelCount || provider.modelCount} model{(provider.availableModelCount || provider.modelCount) === 1 ? "" : "s"} + {provider.availableModelCount > 0 && provider.modelCount > provider.availableModelCount ? ` · ${provider.modelCount} known` : ""} +
+
+ {piProviderAuthSummary(provider)}{provider.authLabel ? ` · ${provider.authLabel}` : ""} +
+
+ ))} +
+
+ ) : null} + + {piInstallation ? ( +
+ revealTerminalSessionInWork(navigate, terminal)} + /> + {!piInstallation.cliAvailable ? ( + <> + + Install the Pi CLI to use Pi’s native /login flow. + + + + ) : null} + {piInstallation.settingsFileDetected ? ( + + ) : ( + settings.json not found + )} + {piInstallation.authFileDetected ? ( + + ) : ( + auth.json not found + )} + {piInstallation.modelsFileDetected ? ( + + ) : ( + models.json not found + )} +
+ ) : null} +
+ ); + })()}
{/* ══ OpenCode — Universal Model Access ══ */} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx index 2676b06b18..0a3633d6b4 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx @@ -1,7 +1,7 @@ import { memo, useCallback } from "react"; import * as ContextMenu from "@radix-ui/react-context-menu"; import { Star, Lightning } from "@phosphor-icons/react"; -import { modelSupportsFastMode, type ModelDescriptor } from "../../../../shared/modelRegistry"; +import { formatPiProviderLabel, modelSupportsFastMode, type ModelDescriptor } from "../../../../shared/modelRegistry"; import { ModelRowLogo } from "../ProviderLogos"; import { cn } from "../../ui/cn"; import { usePrefersReducedMotion } from "../../../hooks/usePrefersReducedMotion"; @@ -15,6 +15,11 @@ function isLocalModel(model: ModelDescriptor): boolean { function subProviderLabel(model: ModelDescriptor): string | null { const sub = (model as ModelDescriptor & { subProvider?: string }).subProvider; if (typeof sub === "string" && sub.trim().length) return sub.trim(); + if (model.providerRoute === "pi-sdk" && model.piProviderId) { + const label = formatPiProviderLabel(model.piProviderId); + const profile = model.piProfileId?.trim(); + return profile && profile !== "default" ? `${label} · ${profile}` : label; + } if (model.providerRoute === "opencode" && model.openCodeProviderId) { // Rows shown inside the OpenCode rail; "via OpenCode" was redundant. const id = model.openCodeProviderId; @@ -34,9 +39,11 @@ export type ModelListRowProps = { model: ModelDescriptor; isFavorite: boolean; isActive: boolean; + isFocused?: boolean; isAvailable: boolean; onSelect: (modelId: string) => void; onToggleFavorite: (modelId: string) => void; + onFocus?: () => void; onCopyId?: (modelId: string) => void; onSetSurfaceDefault?: (modelId: string) => void; onViewDocs?: (modelId: string) => void; @@ -78,9 +85,11 @@ export const ModelListRow = memo(function ModelListRow({ model, isFavorite, isActive, + isFocused = false, isAvailable, onSelect, onToggleFavorite, + onFocus, onCopyId, onSetSurfaceDefault, onViewDocs, @@ -202,11 +211,15 @@ export const ModelListRow = memo(function ModelListRow({
+ {providerTabs.length > 1 ? ( +
+ {providerTabs.map((tab) => { + const active = tab.key === activeProviderTabKey; + return ( + + ); + })} +
+ ) : null} +
- {providerTabs.length > 1 ? ( -
- {providerTabs.map((tab) => { - const active = tab.key === activeProviderTabKey; - return ( - - ); - })} -
- ) : null} {activeOutOfView && activeModel ? (
) : null} + {activeProviderRefreshFailed && activeRefreshProvider && !isEmpty ? ( + onProviderRailSelect(activeProviderFamily) + : undefined} + getProviderLabel={refreshProviderLabel} + /> + ) : null} + {isEmpty ? ( - onProviderRailSelect(activeProviderFamily) + : undefined} providerAuthStatus={effectiveAuth} + getProviderLabel={refreshProviderLabel} + isProviderReady={providerIsReady} {...(onOpenSignIn ? { onOpenSignIn } : {})} /> ) : ( @@ -808,8 +903,8 @@ export const ModelPickerContent = memo(function ModelPickerContent({
setFocusedIndex(virtualRow.index)} onCopyId={handleCopyId} onSetSurfaceDefault={handleSetSurfaceDefault} fastModeOn={fastMode && isActive} {...(onFastModeChange ? { onFastModeChange: handleFastChipChange } : {})} - {...(onOpenSignIn ? { onSignIn: () => onOpenSignIn(m.family, m.authTypes) } : {})} + {...(onOpenSignIn ? { onSignIn: () => onOpenSignIn(pickerFamilyForModel(m), m.authTypes) } : {})} />
); @@ -849,69 +946,3 @@ function cssEscape(value: string): string { } return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`); } - -function EmptyState({ - selection, - searchActive, - opencodeBinaryInstalled, - opencodeBinaryKnown, - refreshingProvider, - providerAuthStatus, - onOpenSignIn, -}: { - selection: RailSelection; - searchActive: boolean; - opencodeBinaryInstalled: boolean; - opencodeBinaryKnown: boolean; - refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; - providerAuthStatus?: Partial>; - onOpenSignIn?: (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void; -}) { - if (!searchActive && selection !== "favorites" && selection !== "recents") { - const family = selection.slice("provider:".length) as ProviderFamily; - if ( - opencodeBinaryKnown - && !opencodeBinaryInstalled - && (family === "opencode" || family === "ollama" || family === "lmstudio") - ) { - return ( - - ); - } - if (refreshingProvider) { - const label = refreshProviderLabel(refreshingProvider); - return ( -
- Checking {label} - - Loading the cached catalog and refreshing it in the background. - -
- ); - } - return ( - - ); - } - let body = "No models match this view."; - if (searchActive) body = "No models match your search."; - else if (selection === "favorites") body = "Star a model to pin it here."; - else if (selection === "recents") body = "Models you use will appear here."; - return ( -
- {body} -
- ); -} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerEmptyState.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerEmptyState.tsx new file mode 100644 index 0000000000..9b335edf78 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerEmptyState.tsx @@ -0,0 +1,124 @@ +import type { AuthType, ProviderFamily } from "../../../../shared/modelRegistry"; +import type { AgentChatModelCatalogRefreshProvider } from "../../../../shared/types"; +import type { AuthStatus, RailSelection } from "./ModelPickerRail"; +import { ProviderEmptyState } from "./providerEmptyState"; + +export type ModelPickerEmptyStateProps = { + selection: RailSelection; + searchActive: boolean; + opencodeBinaryInstalled: boolean; + opencodeBinaryKnown: boolean; + refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; + refreshErrorProvider?: AgentChatModelCatalogRefreshProvider | null; + onRetryRefresh?: () => void; + providerAuthStatus?: Partial>; + onOpenSignIn?: (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void; + getProviderLabel: (provider: AgentChatModelCatalogRefreshProvider) => string; + isProviderReady: (status: AuthStatus | undefined) => boolean; +}; + +export function ModelPickerEmptyState({ + selection, + searchActive, + opencodeBinaryInstalled, + opencodeBinaryKnown, + refreshingProvider, + refreshErrorProvider, + onRetryRefresh, + providerAuthStatus, + onOpenSignIn, + getProviderLabel, + isProviderReady, +}: ModelPickerEmptyStateProps) { + if (!searchActive && selection !== "favorites" && selection !== "recents") { + const family = selection.slice("provider:".length) as ProviderFamily; + if (refreshErrorProvider) { + return ( + + ); + } + if ( + opencodeBinaryKnown + && !opencodeBinaryInstalled + && (family === "opencode" || family === "ollama" || family === "lmstudio") + ) { + return ( + + ); + } + if (refreshingProvider) { + const label = getProviderLabel(refreshingProvider); + return ( +
+ Checking {label} + + Loading the cached catalog and refreshing it in the background. + +
+ ); + } + return ( + + ); + } + + let body = "No models match this view."; + if (searchActive) body = "No models match your search."; + else if (selection === "favorites") body = "Star a model to pin it here."; + else if (selection === "recents") body = "Models you use will appear here."; + return ( +
+ {body} +
+ ); +} + +export function ProviderRefreshError({ + provider, + onRetry, + getProviderLabel, +}: { + provider: AgentChatModelCatalogRefreshProvider; + onRetry?: () => void; + getProviderLabel: (provider: AgentChatModelCatalogRefreshProvider) => string; +}) { + const label = getProviderLabel(provider); + return ( +
+ Couldn’t refresh {label} models; cached results may be stale. + {onRetry ? ( + + ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx index 4e9fee2575..60a8051673 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback } from "react"; +import { memo, useCallback, useRef } from "react"; import { Star, Clock } from "@phosphor-icons/react"; import type { ProviderFamily } from "../../../../shared/modelRegistry"; import { ProviderLogo } from "../ProviderLogos"; @@ -32,6 +32,26 @@ export const ModelPickerRail = memo(function ModelPickerRail({ onSelect, providerAuthStatus, }: ModelPickerRailProps) { + const buttonRefs = useRef(new Map()); + const handleRailKeyDown = useCallback((event: React.KeyboardEvent, index: number) => { + if (!(event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End")) return; + event.preventDefault(); + event.stopPropagation(); + const nextIndex = event.key === "Home" + ? 0 + : event.key === "End" + ? Math.max(0, entries.length - 1) + : Math.min( + Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)), + Math.max(0, entries.length - 1), + ); + const next = entries[nextIndex]; + if (!next) return; + const nextKey = entryKey(next); + onSelect(nextKey); + buttonRefs.current.get(nextKey)?.focus(); + }, [entries, onSelect]); + return (
{ + if (node) buttonRefs.current.set(key, node); + else buttonRefs.current.delete(key); + }} /> ); })} @@ -72,6 +99,10 @@ const RailButton = memo(function RailButton({ authStatus, onSelect, showDivider, + index, + tabIndex, + onKeyDown, + refCallback, }: { entry: RailEntry; selectionKey: RailSelection; @@ -79,6 +110,10 @@ const RailButton = memo(function RailButton({ authStatus: AuthStatus; onSelect: (selection: RailSelection) => void; showDivider: boolean; + index: number; + tabIndex: 0 | -1; + onKeyDown: (event: React.KeyboardEvent, index: number) => void; + refCallback: (node: HTMLButtonElement | null) => void; }) { const handleClick = useCallback(() => onSelect(selectionKey), [onSelect, selectionKey]); @@ -109,13 +144,17 @@ const RailButton = memo(function RailButton({ <> {showDivider ?
: null} + {opening ? Opening Pi’s native login terminal… : null} + {error ? {error} : null} + + ); +} diff --git a/apps/desktop/src/renderer/lib/modelOptions.ts b/apps/desktop/src/renderer/lib/modelOptions.ts index d3bc5a0eab..91646e92f6 100644 --- a/apps/desktop/src/renderer/lib/modelOptions.ts +++ b/apps/desktop/src/renderer/lib/modelOptions.ts @@ -220,6 +220,10 @@ export function deriveConfiguredModelIds( } addAvailableModelIdsByPrefix(ids, status.availableModelIds, "opencode/"); + for (const rawId of status.piInstallation?.availableModelIds ?? []) { + const id = String(rawId ?? "").trim(); + if (id.startsWith("pi/")) ids.add(id as ModelId); + } const registryOrdered = MODEL_REGISTRY .filter((model) => !model.deprecated && ids.has(model.id)) diff --git a/apps/desktop/src/renderer/lib/nativeLaunchControls.ts b/apps/desktop/src/renderer/lib/nativeLaunchControls.ts index d7a0570d08..2b4e6be836 100644 --- a/apps/desktop/src/renderer/lib/nativeLaunchControls.ts +++ b/apps/desktop/src/renderer/lib/nativeLaunchControls.ts @@ -19,7 +19,7 @@ import { import type { NativeControlState } from "./draftLaunchJobs"; import { resolveModelDescriptorWithRuntimeCatalog } from "../components/shared/ModelPicker/modelCatalog"; -type ChatRuntimeProviderKey = "claude" | "codex" | "cursor" | "droid" | "opencode"; +type ChatRuntimeProviderKey = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi"; type CliProvider = ChatRuntimeProviderKey; export function defaultNativeControls(profile: ChatSurfaceProfile = "standard"): NativeControlState { diff --git a/apps/desktop/src/renderer/lib/sessions.ts b/apps/desktop/src/renderer/lib/sessions.ts index 2485f4a8ad..f5df7313ee 100644 --- a/apps/desktop/src/renderer/lib/sessions.ts +++ b/apps/desktop/src/renderer/lib/sessions.ts @@ -59,6 +59,7 @@ export function chatToolTypeForProvider(provider: AgentChatProvider | string | n case "codex": return "codex-chat"; case "cursor": return "cursor"; case "droid": return "droid-chat"; + case "pi": return "pi-chat"; default: return "opencode-chat"; } } @@ -96,6 +97,7 @@ export function defaultSessionLabel(toolType: string | null | undefined): string if (toolType === "claude-chat") return "Claude chat"; if (toolType === "codex-chat") return "Codex chat"; if (toolType === "opencode-chat") return "OpenCode chat"; + if (toolType === "pi-chat") return "Pi chat"; if (toolType === "cursor") return "Cursor chat"; if (toolType === "cursor-cli") return "Cursor CLI session"; if (toolType === "droid") return "Droid CLI session"; @@ -167,6 +169,7 @@ const SHORT_TOOL_TYPE_LABELS: Record = { "cursor-cli": "Cursor", droid: "Droid", opencode: "OpenCode", + pi: "Pi", aider: "Aider", continue: "Continue", }; @@ -176,6 +179,7 @@ const SHORT_TOOL_TYPE_PREFIXES: readonly [string, string][] = [ ["claude", "Claude"], ["codex", "Codex"], ["opencode", "OpenCode"], + ["pi", "Pi"], ]; /** Resolve a short label via exact match, prefix match, or hyphen-to-space fallback. */ diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index f849f5b5b5..12669231a8 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -62,6 +62,7 @@ const IDLE_ATTENTION_TOOL_TYPES = new Set([ "cursor-cli", "droid", "opencode", + "pi", "claude-orchestrated", "codex-orchestrated", "opencode-orchestrated", diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index 24091bb3ad..626da708ba 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -19,12 +19,12 @@ import { import { buildAdeCliAgentGuidance, buildAdeCliInlineGuidance } from "./adeCliGuidance"; import { isProviderSlashCommandInput } from "./chatSlashCommands"; import { resolveClaudeCliModelAlias } from "./claudeCliModels"; -import { decodeOpenCodeRegistryId } from "./modelRegistry"; +import { decodeOpenCodeRegistryId, decodePiRegistryId } from "./modelRegistry"; import { effectiveOrchestrationPermissionMode } from "./orchestrationRuntimePolicy"; import { commandArrayToLine, parseCommandLine, quoteShellArg } from "./shell"; import type { OrchestrationRole } from "./types/orchestration"; -export type CliProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; +export type CliProvider = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi"; export type LaunchProfile = CliProvider | "shell"; export type TrackedCliLaunchCommand = { command?: string; @@ -136,7 +136,7 @@ export function buildPtyContinuationLaunchFields( }; } -export const LAUNCH_PROFILES = ["claude", "codex", "cursor", "droid", "opencode", "shell"] as const satisfies readonly LaunchProfile[]; +export const LAUNCH_PROFILES = ["claude", "codex", "cursor", "droid", "opencode", "pi", "shell"] as const satisfies readonly LaunchProfile[]; export const TRACKED_CLI_PERMISSION_MODES = ["default", "auto", "plan", "edit", "full-auto", "config-toml"] as const satisfies readonly AgentChatPermissionMode[]; export function sanitizeTrackedCliResumeTargetId(value: string | null | undefined): string | null { @@ -155,6 +155,7 @@ export const LAUNCH_PROFILE_TOOL_TYPE: Record = cursor: "cursor-cli", droid: "droid", opencode: "opencode", + pi: "pi", shell: "shell", }; @@ -165,6 +166,7 @@ export const LAUNCH_PROFILE_TITLE: Record = { cursor: "Cursor Agent CLI", droid: "Factory Droid CLI", opencode: "OpenCode CLI", + pi: "Pi CLI", shell: "Shell", }; @@ -318,6 +320,7 @@ const LAUNCH_PROFILE_TOOL_TYPES: Record arg !== "--append-system-prompt" && all[index - 1] !== "--append-system-prompt", + ); + return { + command: "pi", + args: commandArgs, + startupCommand: commandArrayToLine(["pi", ...startupArgs], { platform: "linux" }), + ...(initialPrompt ? { initialInput: initialPrompt, initialInputDelayMs: 750 } : {}), + ...(agentSkillEnv ? { env: agentSkillEnv } : {}), + }; + } + const opencode = buildOpenCodeCommandParts({ permissionMode, model: args.model, @@ -787,6 +827,47 @@ export function resolveCodexCliModelForLaunch(model: string | null | undefined): return raw; } +/** Pi accepts provider/model, while ADE model refs may be prefixed with `pi/`. */ +export function resolvePiCliModelForLaunch(model: string | null | undefined): string | null { + const raw = normalizeCliFlagValue(model); + if (!raw) return null; + const slash = raw.indexOf("/"); + if (slash > 0 && raw.slice(0, slash).toLowerCase() === "pi") { + const decoded = decodePiRegistryId(raw); + if (decoded) return `${decoded.providerId}/${decoded.modelId}`; + // Legacy Pi ids may only carry the provider/model suffix. Strip only + // ADE's provider prefix and leave encoded model separators untouched. + return raw.slice(slash + 1).trim() || null; + } + return raw; +} + +export function piToolsForPermissionMode(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + const mode = permissionMode ?? "default"; + // Pi's built-in tool names are exactly read, bash, edit, and write. Keep + // the allowlist limited to those names; unknown names make Pi reject the + // launch instead of merely disabling an optional tool. + return mode === "full-auto" + ? ["read", "bash", "edit", "write"] + : mode === "edit" + ? ["read", "edit", "write"] + : ["read"]; +} + +export function piToolFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + const tools = piToolsForPermissionMode(permissionMode); + return ["--tools", tools.join(",")]; +} + +export function piThinkingFlags(reasoningEffort: string | null | undefined): string[] { + const normalized = normalizeCliFlagValue(reasoningEffort); + if (!normalized) return []; + const lower = normalized.toLowerCase(); + const thinking = lower === "ultra" || lower === "ultracode" ? "xhigh" : lower; + if (!["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking)) return []; + return ["--thinking", thinking]; +} + export function codexReasoningEffortFlags(reasoningEffort: string | null | undefined): string[] { const effort = normalizeCliFlagValue(reasoningEffort); return effort ? ["-c", `model_reasoning_effort="${effort}"`] : []; @@ -826,11 +907,18 @@ function claudeSessionSettingsFlags( return Object.keys(settings).length ? ["--settings", JSON.stringify(settings)] : []; } -function workTabCliPrompt(initialPrompt: string | null, skillRoots: readonly string[]): string { +function workTabCliPrompt( + initialPrompt: string | null, + skillRoots: readonly string[], + additionalGuidance?: string, +): string { const preamble = workTabCliPreamblePrompt(skillRoots, Boolean(initialPrompt)); - if (!initialPrompt) return preamble; + const withAdditionalGuidance = additionalGuidance + ? [preamble, "", additionalGuidance].join("\n") + : preamble; + if (!initialPrompt) return withAdditionalGuidance; return [ - preamble, + withAdditionalGuidance, "", "User prompt:", initialPrompt, @@ -1237,6 +1325,33 @@ export function buildTrackedCliResumeLaunchCommand( }; } + if (metadata.provider === "pi") { + const parts = [ + "pi", + ...modelToCliFlag(resolvePiCliModelForLaunch(model)), + ...piThinkingFlags(reasoningEffort), + ...piToolFlags(permissionMode), + ]; + // Pi's supported native continuation target is a session id/file passed to + // --session. When ADE has not captured a concrete id yet, continue the + // most recent session instead of silently launching a new one. + if (metadata.targetKind === "session" && targetId) parts.push("--session", targetId); + else parts.push("--continue"); + // A bare `pi` command resolves to an npm `.cmd` shim on some Windows + // installs. `cmd.exe` rewrites multiline prompts, expands `%NAME%`, and + // imposes a command-line length limit, so deliver the resume prompt over + // the PTY on Windows just like fresh Pi launches do. POSIX keeps the + // prompt in argv where it round-trips intact. + const promptRidesInArgv = Boolean(prompt) && (options.platform ?? process.platform) !== "win32"; + if (prompt && promptRidesInArgv) parts.push(prompt); + return { + command: parts[0]!, + args: parts.slice(1), + startupCommand: commandArrayToLine(parts, { platform: "linux" }), + ...(prompt && !promptRidesInArgv ? { initialInput: prompt, initialInputDelayMs: 750 } : {}), + }; + } + const opencode = buildOpenCodeCommandParts({ permissionMode, model, diff --git a/apps/desktop/src/shared/contextCompaction.ts b/apps/desktop/src/shared/contextCompaction.ts index dd7cb632bc..3bc932e928 100644 --- a/apps/desktop/src/shared/contextCompaction.ts +++ b/apps/desktop/src/shared/contextCompaction.ts @@ -1,6 +1,6 @@ import type { AgentChatEvent } from "./types"; -export type ContextCompactProvider = "claude" | "codex" | "opencode" | "cursor" | "droid"; +export type ContextCompactProvider = "claude" | "codex" | "opencode" | "cursor" | "droid" | "pi"; export type ContextCompactEvent = Extract; @@ -23,6 +23,7 @@ const PROVIDER_TINTS: Record = { cursor: "Cursor", droid: "Droid", opencode: "OpenCode", + pi: "Pi", }; const CONTINUE_CLI_DESCRIPTION = diff --git a/apps/desktop/src/shared/modelCatalog.test.ts b/apps/desktop/src/shared/modelCatalog.test.ts new file mode 100644 index 0000000000..73e5381673 --- /dev/null +++ b/apps/desktop/src/shared/modelCatalog.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { buildProviderGroupBlocks, createModelOrderMap } from "./modelCatalog"; +import { createDynamicPiModelDescriptor } from "./modelRegistry"; + +describe("Pi model catalog grouping", () => { + it("keeps branded provider labels in the Pi rail and subsection", () => { + const model = createDynamicPiModelDescriptor("openai-codex", "gpt-5.4", { + profileId: "work", + }); + + const [group] = buildProviderGroupBlocks([model], createModelOrderMap()); + + expect(group?.key).toBe("pi"); + expect(group?.label).toBe("Pi"); + expect(group?.providers[0]?.label).toBe("OpenAI Codex"); + expect(group?.providers[0]?.subsections[0]?.label).toBe("OpenAI Codex · work"); + expect(group?.providers[0]?.subsections[0]?.models[0]?.id).toBe(model.id); + }); + + it("keeps Pi profiles in separate readable subsections", () => { + const models = [ + createDynamicPiModelDescriptor("openai-codex", "gpt-5.4", { profileId: "default" }), + createDynamicPiModelDescriptor("openai-codex", "gpt-5.5", { profileId: "team" }), + ]; + + const [group] = buildProviderGroupBlocks(models, createModelOrderMap()); + const subsections = group?.providers[0]?.subsections ?? []; + + expect(subsections).toHaveLength(2); + expect(new Set(subsections.map((section) => section.key)).size).toBe(2); + expect(subsections.map((section) => section.label)).toEqual([ + "OpenAI Codex", + "OpenAI Codex · team", + ]); + }); +}); diff --git a/apps/desktop/src/shared/modelCatalog.ts b/apps/desktop/src/shared/modelCatalog.ts index 2dd26c9eb0..19c958a4fa 100644 --- a/apps/desktop/src/shared/modelCatalog.ts +++ b/apps/desktop/src/shared/modelCatalog.ts @@ -6,6 +6,8 @@ import { cursorCliLineGroupLabel, droidCliLineGroupFromModelId, droidCliLineGroupLabel, + formatPiProviderLabel, + resolveProviderGroupForModel, type CursorCliLineGroup, type DroidCliLineGroup, type ModelDescriptor, @@ -65,9 +67,12 @@ const PROVIDER_LABELS: Record = { opencode: "OpenCode (Free)", anthropic: "Anthropic", openai: "OpenAI", + "openai-codex": "OpenAI Codex", cursor: "Cursor", factory: "Factory Droid", + pi: "Pi", google: "Google", + "github-copilot": "GitHub Copilot", deepseek: "DeepSeek", mistral: "Mistral", xai: "xAI", @@ -83,9 +88,12 @@ export const PROVIDER_BADGE_COLORS: Record = { opencode: "#2563EB", anthropic: "#D97706", openai: "#10A37F", + "openai-codex": "#22B88A", cursor: "#A78BFA", factory: "#6B7280", + pi: "#F97316", google: "#F59E0B", + "github-copilot": "#8B5CF6", deepseek: "#3B82F6", mistral: "#F97316", xai: "#DC2626", @@ -101,7 +109,9 @@ export const PROVIDER_ORDER: string[] = [ "opencode", "anthropic", "openai", + "openai-codex", "google", + "github-copilot", "deepseek", "mistral", "xai", @@ -112,6 +122,7 @@ export const PROVIDER_ORDER: string[] = [ "lmstudio", "cursor", "factory", + "pi", ]; const PROVIDER_GROUP_ORDER: Record = { @@ -119,6 +130,7 @@ const PROVIDER_GROUP_ORDER: Record = { codex: 20, cursor: 30, droid: 35, + pi: 38, opencode: 40, ollama: 50, lmstudio: 60, @@ -129,6 +141,7 @@ export const PROVIDER_GROUP_COLORS: Record = { codex: "#10A37F", cursor: "#A78BFA", droid: "#6B7280", + pi: "#F97316", opencode: "#2563EB", ollama: "#71717A", lmstudio: "#64748B", @@ -137,6 +150,24 @@ export const PROVIDER_GROUP_COLORS: Record = { const CURSOR_SECTION_PREFIX = "__cursor_line__:"; const DROID_SECTION_PREFIX = "__droid_line__:"; const OPENCODE_PROVIDER_PREFIX = "__ocprov__:"; +const PI_PROVIDER_PREFIX = "__piprov__:"; + +function piSubsectionParts(key: string): { profileId: string; providerId: string } | null { + if (!key.startsWith(PI_PROVIDER_PREFIX)) return null; + const encoded = key.slice(PI_PROVIDER_PREFIX.length).split(":"); + try { + if (encoded.length >= 2) { + return { + profileId: decodeURIComponent(encoded[0] || "default") || "default", + providerId: decodeURIComponent(encoded.slice(1).join(":")), + }; + } + // Keep old cached catalogs readable while new catalogs use profile-aware keys. + return { profileId: "default", providerId: decodeURIComponent(encoded[0] ?? "") }; + } catch { + return null; + } +} export function providerLabel(family: string): string { return PROVIDER_LABELS[family] ?? family; @@ -147,16 +178,10 @@ export function providerBadgeColor(provider: string, models: ModelDescriptor[]): } export function classifyProviderGroup(model: ModelDescriptor): ProviderGroupKey { - if (model.family === "cursor") return "cursor"; if (model.family === "ollama" || model.family === "lmstudio") { return model.family; } - if (model.isCliWrapped) { - if (model.family === "anthropic" || model.cliCommand === "claude") return "claude"; - if (model.family === "openai" || model.cliCommand === "codex") return "codex"; - if (model.family === "factory" || model.cliCommand === "droid") return "droid"; - } - return "opencode"; + return resolveProviderGroupForModel(model); } export function providerGroupLabel(group: ProviderGroupKey): string { @@ -169,6 +194,8 @@ export function providerGroupLabel(group: ProviderGroupKey): string { return "Cursor"; case "droid": return "Droid"; + case "pi": + return "Pi"; case "opencode": return "OpenCode"; case "ollama": @@ -185,6 +212,10 @@ export function subsectionKeyForModel(model: ModelDescriptor, group: ProviderGro if (model.family === "factory" && group === "droid") { return `${DROID_SECTION_PREFIX}${droidCliLineGroupFromModelId(model.providerModelId)}`; } + if (group === "pi" && model.piProviderId) { + const profileId = model.piProfileId?.trim() || "default"; + return `${PI_PROVIDER_PREFIX}${encodeURIComponent(profileId)}:${encodeURIComponent(model.piProviderId)}`; + } if (group === "opencode" && model.openCodeProviderId) { return `${OPENCODE_PROVIDER_PREFIX}${model.openCodeProviderId}`; } @@ -193,6 +224,11 @@ export function subsectionKeyForModel(model: ModelDescriptor, group: ProviderGro export function subsectionLabel(family: string, key: string): string { if (key === "__default__") return ""; + const piParts = piSubsectionParts(key); + if (piParts) { + const provider = formatPiProviderLabel(piParts.providerId); + return piParts.profileId === "default" ? provider : `${provider} · ${piParts.profileId}`; + } if (family === "opencode" && key.startsWith(OPENCODE_PROVIDER_PREFIX)) { const pid = key.slice(OPENCODE_PROVIDER_PREFIX.length); return providerLabel(pid); @@ -209,6 +245,7 @@ export function subsectionLabel(family: string, key: string): string { } export function subsectionSortOrder(family: string, key: string): number { + if (key.startsWith(PI_PROVIDER_PREFIX)) return PROVIDER_ORDER.length + 1; if (family === "opencode" && key.startsWith(OPENCODE_PROVIDER_PREFIX)) { const pid = key.slice(OPENCODE_PROVIDER_PREFIX.length); const index = PROVIDER_ORDER.indexOf(pid); @@ -236,6 +273,8 @@ export function matchesQuery(model: ModelDescriptor, query: string): boolean { model.shortId, model.providerModelId, model.openCodeProviderId ?? "", + model.piProviderId ?? "", + model.piModelId ?? "", ...(model.aliases ?? []), ] .join(" ") @@ -288,7 +327,9 @@ export function buildProviderGroupBlocks( const group = classifyProviderGroup(model); const family = group === "opencode" && model.openCodeProviderId ? model.openCodeProviderId - : model.family; + : group === "pi" && model.piProviderId + ? model.piProviderId + : model.family; const subKey = group === "opencode" && model.openCodeProviderId ? "__default__" : subsectionKeyForModel(model, group); @@ -341,7 +382,9 @@ export function buildProviderGroupBlocks( key: family, label: groupKey === "opencode" ? opencodeProviderNameById.get(family) ?? providerLabel(family) - : providerLabel(family), + : groupKey === "pi" + ? formatPiProviderLabel(family) + : providerLabel(family), badgeColor: providerBadgeColor(family, subsections.flatMap((s) => s.models)), subsections, modelCount, diff --git a/apps/desktop/src/shared/modelRegistry.test.ts b/apps/desktop/src/shared/modelRegistry.test.ts index 5f26d6ad9a..fb8f121e7d 100644 --- a/apps/desktop/src/shared/modelRegistry.test.ts +++ b/apps/desktop/src/shared/modelRegistry.test.ts @@ -3,10 +3,15 @@ import { createDynamicDroidCliModelDescriptor, createDynamicLocalModelDescriptor, createDynamicOpenCodeModelDescriptor, + createDynamicPiModelDescriptor, + classifyWorkerExecutionPath, decodeOpenCodeRegistryId, + decodePiRegistryId, droidCliLineGroupFromModelId, droidCliLineGroupLabel, encodeOpenCodeRegistryId, + encodePiRegistryId, + formatPiProviderLabel, ensureOpenCodeBaseURL, getAvailableModels, getDefaultModelDescriptor, @@ -15,8 +20,10 @@ import { getRuntimeModelRefForDescriptor, listModelDescriptorsForProvider, MODEL_REGISTRY, + replaceDynamicPiModelDescriptors, resolveModelAlias, resolveCursorCliModelVariant, + resolveCliProviderForModel, resolveModelDescriptor, resolveModelDescriptorForProvider, resolveModelSlug, @@ -52,6 +59,64 @@ describe("modelRegistry", () => { expect(d.openCodeModelId).toBe("openai/gpt-oss-20b"); }); + it("round-trips Pi registry ids and preserves the upstream provider", () => { + const id = encodePiRegistryId("default", "openai-codex", "gpt-5.4"); + expect(id).toBe("pi/default/openai-codex/gpt-5.4"); + expect(decodePiRegistryId(id)).toEqual({ + profileId: "default", + providerId: "openai-codex", + modelId: "gpt-5.4", + }); + const descriptor = createDynamicPiModelDescriptor("openai-codex", "gpt-5.4", { + profileId: "default", + displayName: "GPT-5.4", + }); + expect(descriptor.id).toBe(id); + expect(descriptor.providerRoute).toBe("pi-sdk"); + expect(resolveCliProviderForModel(descriptor)).toBe("pi"); + expect(classifyWorkerExecutionPath(descriptor)).toBe("api"); + expect(descriptor.piProviderId).toBe("openai-codex"); + expect(descriptor.piModelId).toBe("gpt-5.4"); + expect(descriptor.family).toBe("openai"); + }); + + it("rejects ambiguous or incomplete Pi registry components", () => { + expect(() => encodePiRegistryId("default", "", "gpt-5.4")).toThrow("Pi provider id is required"); + expect(() => encodePiRegistryId("default", "openai/codex", "gpt-5.4")).toThrow("cannot contain"); + expect(() => encodePiRegistryId("default", "openai-codex", "")).toThrow("Pi model id is required"); + }); + + it("matches provider-scoped Pi OAuth only to its upstream provider", () => { + const openAiPi = createDynamicPiModelDescriptor("openai-codex", "gpt-5.4", { + profileId: "team", + displayName: "Team GPT-5.4", + }); + const anthropicPi = createDynamicPiModelDescriptor("anthropic", "claude-sonnet-4-6", { + profileId: "team", + displayName: "Team Claude Sonnet", + }); + // Keep the registry's dynamic map isolated from other tests while proving + // that one provider's OAuth does not unlock another provider's Pi rows. + replaceDynamicPiModelDescriptors([openAiPi, anthropicPi]); + try { + const anthropicAuth = getAvailableModels([{ type: "oauth", provider: "anthropic" }]); + expect(anthropicAuth.map((model) => model.id)).toContain(anthropicPi.id); + expect(anthropicAuth.map((model) => model.id)).not.toContain(openAiPi.id); + + const openAiAuth = getAvailableModels([{ type: "oauth", provider: "openai-codex" }]); + expect(openAiAuth.map((model) => model.id)).toContain(openAiPi.id); + expect(openAiAuth.map((model) => model.id)).not.toContain(anthropicPi.id); + } finally { + replaceDynamicPiModelDescriptors([]); + } + }); + + it("humanizes Pi provider ids without losing branded names", () => { + expect(formatPiProviderLabel("openai-codex")).toBe("OpenAI Codex"); + expect(formatPiProviderLabel("google-gemini-cli")).toBe("Google Gemini CLI"); + expect(formatPiProviderLabel("custom-provider")).toBe("Custom Provider"); + }); + it("canonicalizes persisted OpenCode Anthropic aliases before launch", () => { const sonnet = getModelById("opencode/anthropic/claude-sonnet-4-6"); const currentOpus = getModelById("opencode/anthropic/opus"); diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 848845d166..8a91b426fe 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -18,7 +18,8 @@ export type ProviderFamily = | "ollama" | "lmstudio" | "cursor" - | "factory"; + | "factory" + | "pi"; export type LocalProviderFamily = Extract; @@ -78,6 +79,10 @@ export type ModelDescriptor = { openCodeModelId?: string; /** True when the model was injected via a local proxy (e.g. vibeproxy in ~/.factory/config.json). */ customProxy?: boolean; + /** Pi dynamic inventory identity; the underlying family remains available for branding. */ + piProfileId?: string; + piProviderId?: string; + piModelId?: string; /** Cursor models can be available through local CLI, Cursor SDK/API chat, or both. */ cursorAvailability?: CursorModelAvailability; /** Concrete Cursor CLI ids reachable from an abstract picker row. */ @@ -96,7 +101,7 @@ export type DynamicLocalModelDescriptorOptions = { }; export type WorkerExecutionPath = "cli" | "api" | "local"; -export type ModelProviderGroup = "claude" | "codex" | "opencode" | "cursor" | "droid"; +export type ModelProviderGroup = "claude" | "codex" | "opencode" | "cursor" | "droid" | "pi"; /** Select a valid reasoning tier without duplicating fallback policy in each UI. */ export function selectSupportedReasoningEffort(args: { @@ -115,7 +120,7 @@ export function selectSupportedReasoningEffort(args: { } export function isModelProviderGroup(value: string | null | undefined): value is ModelProviderGroup { - return value === "claude" || value === "codex" || value === "opencode" || value === "cursor" || value === "droid"; + return value === "claude" || value === "codex" || value === "opencode" || value === "cursor" || value === "droid" || value === "pi"; } export function modelSupportsServiceTier( @@ -643,6 +648,8 @@ let byAlias = new Map(); let bySdkModelId = new Map(); let dynamicOpenCodeById = new Map(); let dynamicOpenCodeByAlias = new Map(); +let dynamicPiById = new Map(); +let dynamicPiByAlias = new Map(); function rebuildIndexes() { byId = new Map(); @@ -782,6 +789,149 @@ export function createDynamicLocalModelDescriptor( }; } +export type DynamicPiModelDescriptorOptions = { + displayName?: string; + contextWindow?: number; + maxOutputTokens?: number; + capabilities?: Partial; + reasoningTiers?: string[]; + defaultReasoningEffort?: string; + aliases?: string[]; + color?: string; + authTypes?: AuthType[]; + profileId?: string; +}; + +/** Stable ADE id for a Pi-backed model: `pi///`. */ +export function encodePiRegistryId(profileId: string, providerId: string, modelId: string): string { + const provider = providerId.trim(); + const model = modelId.trim(); + if (!provider) throw new Error("Pi provider id is required"); + if (provider.includes("/")) throw new Error("Pi provider id cannot contain '/'"); + if (!model) throw new Error("Pi model id is required"); + return `pi/${encodeURIComponent(profileId.trim() || "default")}/${provider}/${encodeURIComponent(model)}`; +} + +export function decodePiRegistryId(id: string): { profileId: string; providerId: string; modelId: string } | null { + const trimmed = id.trim(); + if (!trimmed.toLowerCase().startsWith("pi/")) return null; + const parts = trimmed.slice(3).split("/"); + if (parts.length < 3) return null; + const providerId = parts[1]?.trim() ?? ""; + const encodedModel = parts.slice(2).join("/"); + if (!providerId || !encodedModel) return null; + try { + const profileId = decodeURIComponent(parts[0] ?? "").trim(); + const modelId = decodeURIComponent(encodedModel).trim(); + return profileId && modelId ? { profileId, providerId, modelId } : null; + } catch { + return null; + } +} + +/** Human-readable label for the provider inside a Pi profile. */ +export function formatPiProviderLabel(providerId: string): string { + const normalized = providerId.trim().toLowerCase(); + const known: Record = { + anthropic: "Anthropic", + openai: "OpenAI", + "openai-codex": "OpenAI Codex", + google: "Google", + "google-gemini-cli": "Google Gemini CLI", + "google-antigravity": "Google Antigravity", + mistral: "Mistral", + deepseek: "DeepSeek", + xai: "xAI", + groq: "Groq", + together: "Together", + openrouter: "OpenRouter", + ollama: "Ollama", + lmstudio: "LM Studio", + "github-copilot": "GitHub Copilot", + }; + if (known[normalized]) return known[normalized]; + return normalized + .split(/[-_]+/u) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" ") || providerId.trim(); +} + +function piFamilyForProvider(providerId: string): ProviderFamily { + const known: Record = { + anthropic: "anthropic", + openai: "openai", + "openai-codex": "openai", + google: "google", + "google-gemini-cli": "google", + "google-antigravity": "google", + "google-vertex": "google", + mistral: "mistral", + deepseek: "deepseek", + xai: "xai", + groq: "groq", + together: "together", + openrouter: "openrouter", + ollama: "ollama", + lmstudio: "lmstudio", + factory: "factory", + }; + return known[providerId.trim().toLowerCase()] ?? "opencode"; +} + +export function createDynamicPiModelDescriptor( + providerId: string, + modelId: string, + options?: DynamicPiModelDescriptorOptions, +): ModelDescriptor { + const provider = providerId.trim(); + const model = modelId.trim(); + const profileId = options?.profileId?.trim() || "default"; + const aliases = options?.aliases?.map((alias) => alias.trim()).filter(Boolean) ?? []; + return { + id: encodePiRegistryId(profileId, provider, model), + shortId: model, + displayName: options?.displayName?.trim() || formatOpenCodeDisplayName(model), + family: piFamilyForProvider(provider), + authTypes: options?.authTypes?.length ? [...options.authTypes] : ["oauth", "api-key"], + contextWindow: options?.contextWindow ?? 200_000, + maxOutputTokens: options?.maxOutputTokens ?? 32_000, + capabilities: { + tools: options?.capabilities?.tools ?? true, + vision: options?.capabilities?.vision ?? false, + reasoning: options?.capabilities?.reasoning ?? true, + streaming: options?.capabilities?.streaming ?? true, + }, + color: options?.color ?? "#F97316", + providerRoute: "pi-sdk", + providerModelId: `${provider}/${model}`, + piProfileId: profileId, + piProviderId: provider, + piModelId: model, + ...(options?.reasoningTiers?.length ? { reasoningTiers: [...options.reasoningTiers] } : {}), + ...(options?.defaultReasoningEffort ? { defaultReasoningEffort: options.defaultReasoningEffort } : {}), + ...(aliases.length ? { aliases } : {}), + isCliWrapped: false, + }; +} + +export function replaceDynamicPiModelDescriptors(descriptors: ModelDescriptor[]): void { + dynamicPiById = new Map(); + dynamicPiByAlias = new Map(); + for (const descriptor of descriptors) { + if (descriptor.providerRoute !== "pi-sdk" || byId.has(descriptor.id)) continue; + dynamicPiById.set(descriptor.id, descriptor); + for (const alias of descriptor.aliases ?? []) { + const normalized = alias.trim().toLowerCase(); + if (normalized) dynamicPiByAlias.set(normalized, descriptor); + } + } +} + +export function getDynamicPiModelDescriptors(): ModelDescriptor[] { + return [...dynamicPiById.values()]; +} + export type DynamicOpenCodeModelDescriptorOptions = { displayName?: string; contextWindow?: number; @@ -1431,10 +1581,16 @@ export function getModelById(id: string): ModelDescriptor | undefined { const normalizedLower = normalized.toLowerCase(); const cached = byId.get(normalized) ?? byId.get(normalizedLower); if (cached) return cached; - const aliased = byAlias.get(normalizedLower) ?? dynamicOpenCodeByAlias.get(normalizedLower); + const aliased = byAlias.get(normalizedLower) ?? dynamicOpenCodeByAlias.get(normalizedLower) ?? dynamicPiByAlias.get(normalizedLower); if (aliased) return aliased; const dynamicOpenCode = dynamicOpenCodeById.get(normalized); if (dynamicOpenCode) return dynamicOpenCode; + const dynamicPi = dynamicPiById.get(normalized); + if (dynamicPi) return dynamicPi; + const piDecoded = decodePiRegistryId(normalized); + if (piDecoded) { + return createDynamicPiModelDescriptor(piDecoded.providerId, piDecoded.modelId, { profileId: piDecoded.profileId }); + } const openCodeDecoded = decodeOpenCodeRegistryId(normalized); if (openCodeDecoded) { return createDynamicOpenCodeModelDescriptor("", { @@ -1489,26 +1645,37 @@ export function getAvailableModels( if (authType === "cli-subscription") return hasMappedCli(model.family); if (authType === "api-key") { return hasAuth( - (auth) => auth.type === "api-key" && (!auth.provider || auth.provider === model.family) + (auth) => auth.type === "api-key" + && (!auth.provider + || auth.provider === model.family + || (model.providerRoute === "pi-sdk" && auth.provider === model.piProviderId)), ); } if (authType === "openrouter") return hasAuth((auth) => auth.type === "openrouter"); if (authType === "local") return hasMappedLocal(model.family); - if (authType === "oauth") return hasAuth((auth) => auth.type === "oauth"); + if (authType === "oauth") { + return hasAuth( + (auth) => auth.type === "oauth" + && (!auth.provider + || auth.provider === model.family + || (model.providerRoute === "pi-sdk" && auth.provider === model.piProviderId)), + ); + } return false; }); const staticModels = MODEL_REGISTRY.filter((model) => !model.deprecated && hasAuthForModel(model)); + const dynamicPiModels = getDynamicPiModelDescriptors().filter(hasAuthForModel); const dynamicOpenCodeLocals = getDynamicOpenCodeModelDescriptors().filter( (model) => model.authTypes.includes("local") && hasAuthForModel(model), ); - if (!dynamicOpenCodeLocals.length) return staticModels; + if (!dynamicPiModels.length && !dynamicOpenCodeLocals.length) return staticModels; const providersWithDynamicLocals = new Set(dynamicOpenCodeLocals.map((model) => model.family)); const filteredStatic = staticModels.filter( (model) => !(model.authTypes.includes("local") && providersWithDynamicLocals.has(model.family)), ); - return [...filteredStatic, ...dynamicOpenCodeLocals]; + return [...filteredStatic, ...dynamicOpenCodeLocals, ...dynamicPiModels]; } export function resolveModelAlias(alias: string): ModelDescriptor | undefined { @@ -1517,6 +1684,7 @@ export function resolveModelAlias(alias: string): ModelDescriptor | undefined { ?? byShortId.get(normalized) ?? byAlias.get(normalized) ?? dynamicOpenCodeByAlias.get(normalized) + ?? dynamicPiByAlias.get(normalized) ?? undefined; } @@ -1614,7 +1782,8 @@ export function resolveModelIdForProvider( export function resolveCliProviderForModel( descriptor: ModelDescriptor, -): "claude" | "codex" | "cursor" | "droid" | null { +): "claude" | "codex" | "cursor" | "droid" | "pi" | null { + if (descriptor.providerRoute === "pi-sdk") return "pi"; if (!descriptor.isCliWrapped) return null; if (descriptor.family === "cursor") return "cursor"; if (descriptor.family === "factory") return "droid"; @@ -1630,6 +1799,7 @@ export function resolveCliProviderForModel( export function resolveProviderGroupForModel( descriptor: ModelDescriptor, ): ModelProviderGroup { + if (descriptor.providerRoute === "pi-sdk") return "pi"; if (descriptor.family === "cursor") return "cursor"; return resolveCliProviderForModel(descriptor) ?? "opencode"; } @@ -1653,7 +1823,7 @@ export function getRuntimeModelRefForDescriptor( if (provider === "claude") { return descriptor.providerModelId; } - if (provider === "codex" || provider === "cursor" || provider === "droid") { + if (provider === "codex" || provider === "cursor" || provider === "droid" || provider === "pi") { return descriptor.providerModelId; } return descriptor.id; @@ -1662,12 +1832,17 @@ export function getRuntimeModelRefForDescriptor( export function classifyWorkerExecutionPath( descriptor: ModelDescriptor, ): WorkerExecutionPath { + // Pi has both a tracked native CLI and an isolated SDK chat runtime. This + // helper describes the chat execution path, so keep Pi on the SDK side + // even though resolveCliProviderForModel() must route its CLI launches. + if (descriptor.providerRoute === "pi-sdk") return "api"; if (resolveCliProviderForModel(descriptor)) return "cli"; if (descriptor.authTypes.includes("local")) return "local"; return "api"; } function listProviderModelsInternal(provider: ModelProviderGroup): ModelDescriptor[] { + if (provider === "pi") return getDynamicPiModelDescriptors(); return MODEL_REGISTRY.filter((descriptor) => { if (descriptor.deprecated) return false; if (provider === "claude") return descriptor.isCliWrapped && descriptor.family === "anthropic"; @@ -1782,6 +1957,7 @@ function pickDefaultModelForProvider( if (provider === "codex") return pickDefaultCodexModel(models); if (provider === "cursor") return pickDefaultCursorDescriptorFromCliList(models); if (provider === "droid") return pickDefaultDroidDescriptorFromCliList(models); + if (provider === "pi") return models[0]; return pickDefaultOpenCodeModel(models); } diff --git a/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts b/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts index 3fb5fc7eca..2bacb34bd4 100644 --- a/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts +++ b/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts @@ -27,6 +27,7 @@ const PROVIDER_PROFILE_EXPECTATIONS: Record { diff --git a/apps/desktop/src/shared/orchestrationRuntimePolicy.ts b/apps/desktop/src/shared/orchestrationRuntimePolicy.ts index 76239c8c3d..4563099b74 100644 --- a/apps/desktop/src/shared/orchestrationRuntimePolicy.ts +++ b/apps/desktop/src/shared/orchestrationRuntimePolicy.ts @@ -31,6 +31,7 @@ export type OrchestrationPermissionProfile = Partial>; export const ORCHESTRATION_LOCKED_PERMISSION_MODE = "full-auto" satisfies AgentChatPermissionMode; @@ -485,6 +486,8 @@ export function applyOrchestrationPermissionProfile( return { opencodePermissionMode: "full-auto" satisfies AgentChatOpenCodePermissionMode, }; + case "pi": + return { permissionMode: "full-auto" }; default: return {}; } diff --git a/apps/desktop/src/shared/pendingInputLabels.ts b/apps/desktop/src/shared/pendingInputLabels.ts index 7eb8bc5a21..79a06b0340 100644 --- a/apps/desktop/src/shared/pendingInputLabels.ts +++ b/apps/desktop/src/shared/pendingInputLabels.ts @@ -11,6 +11,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { droid: "Droid", factory: "Droid", opencode: "OpenCode", + pi: "Pi", ade: "ADE", agent: "Agent", }; @@ -34,6 +35,7 @@ const CHAT_PROVIDER_DISPLAY_NAMES: Record = { cursor: "Cursor", droid: "Droid", opencode: "OpenCode", + pi: "Pi", }; export function providerDisplayLabel( diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index e486b3eb6d..22eb0334cf 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -11,7 +11,7 @@ import type { OrchestrationContextItem, OrchestrationRole } from "./orchestratio import type { AdeRecoveryErrorCode } from "./recovery"; import type { SubagentCapability } from "../subagentCapabilities"; -export type AgentChatProvider = "codex" | "claude" | "cursor" | "droid" | "opencode" | (string & {}); +export type AgentChatProvider = "codex" | "claude" | "cursor" | "droid" | "opencode" | "pi" | (string & {}); export type AgentChatSessionStatus = "active" | "idle" | "ended"; export type AgentChatSessionProfile = "light" | "workflow"; @@ -1003,7 +1003,7 @@ export type AgentChatEvent = postTokens?: number; tokensRemoved?: number; durationMs?: number; - provider?: "claude" | "codex" | "opencode" | "cursor" | "droid"; + provider?: "claude" | "codex" | "opencode" | "cursor" | "droid" | "pi"; /** Stable merge key for started→completed pairs that may land on different turns. */ compactionId?: string; /** After the second compaction in a session, surfaces as "(N× this session)" in the pill. */ @@ -1498,6 +1498,12 @@ export type AgentChatSession = { codexSandbox?: AgentChatCodexSandbox; codexConfigSource?: AgentChatCodexConfigSource; opencodePermissionMode?: AgentChatOpenCodePermissionMode; + piProfileId?: string | null; + piProviderId?: string | null; + piModelId?: string | null; + /** Native Pi JSONL session pointer used for SDK resume and CLI handoff. */ + piSessionId?: string | null; + piSessionFile?: string | null; droidPermissionMode?: AgentChatDroidPermissionMode; cursorModeSnapshot?: AgentChatCursorModeSnapshot; cursorModeId?: string | null; @@ -1556,6 +1562,11 @@ export type AgentChatSessionSummary = { codexSandbox?: AgentChatCodexSandbox; codexConfigSource?: AgentChatCodexConfigSource; opencodePermissionMode?: AgentChatOpenCodePermissionMode; + piProfileId?: string | null; + piProviderId?: string | null; + piModelId?: string | null; + piSessionId?: string | null; + piSessionFile?: string | null; droidPermissionMode?: AgentChatDroidPermissionMode; cursorModeSnapshot?: AgentChatCursorModeSnapshot; cursorModeId?: string | null; @@ -1920,6 +1931,7 @@ export type AgentChatModelCatalog = { export type AgentChatModelCatalogRefreshProvider = | "opencode" + | "pi" | "cursor" | "droid" | "lmstudio" @@ -1961,6 +1973,11 @@ export type AgentChatCreateArgs = { codexSandbox?: AgentChatCodexSandbox; codexConfigSource?: AgentChatCodexConfigSource; opencodePermissionMode?: AgentChatOpenCodePermissionMode; + piProfileId?: string | null; + piProviderId?: string | null; + piModelId?: string | null; + piSessionId?: string | null; + piSessionFile?: string | null; droidPermissionMode?: AgentChatDroidPermissionMode; cursorModeId?: string | null; cursorConfigValues?: Record | null; @@ -2027,7 +2044,8 @@ export type AgentChatCliLaunchProvider = | "codex" | "cursor" | "droid" - | "opencode"; + | "opencode" + | "pi"; /** * Launch a tracked CLI/terminal agent (not the in-process chat SDK) with one or diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 5801355f24..f0e1ef1778 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -921,10 +921,10 @@ export type AiFeatureUsageRow = { }; export type AiDetectedAuth = { - type: "cli-subscription" | "api-key" | "openrouter" | "local"; + type: "cli-subscription" | "api-key" | "oauth" | "openrouter" | "local"; cli?: "claude" | "codex" | "cursor" | "droid"; provider?: string; - source?: "config" | "env" | "store"; + source?: "config" | "env" | "store" | "file"; endpointSource?: "auto" | "config"; path?: string; endpoint?: string; @@ -940,7 +940,9 @@ export type AiProviderCredentialSource = | "cursor-admin-env" | "cursor-env" | "cursor-api-key-store" - | "factory-env"; + | "factory-env" + | "pi-auth-file" + | "pi-models-file"; export type AiProviderConnectionSource = { kind: "cli" | "local-credentials"; @@ -953,7 +955,7 @@ export type AiProviderConnectionSource = { }; export type AiProviderConnectionStatus = { - provider: "claude" | "codex" | "cursor" | "droid"; + provider: "claude" | "codex" | "cursor" | "droid" | "pi"; authAvailable: boolean; runtimeDetected: boolean; runtimeAvailable: boolean; @@ -969,6 +971,7 @@ export type AiProviderConnections = { codex: AiProviderConnectionStatus; cursor: AiProviderConnectionStatus; droid: AiProviderConnectionStatus; + pi?: AiProviderConnectionStatus; }; export type AiApiKeyVerificationResult = { @@ -1216,6 +1219,52 @@ export const EMPTY_AGENT_TOOLS_CACHE_SNAPSHOT: AgentToolsCacheSnapshot = { fetching: false, }; +export type AiPiProviderAuthSource = + | "stored" + | "runtime" + | "environment" + | "fallback" + | "models_json_key" + | "models_json_command" + | null; + +export type AiPiProviderStatus = { + id: string; + name: string; + modelCount: number; + availableModelCount: number; + configured: boolean; + authType: "api-key" | "oauth" | "local" | "unknown" | null; + authMethods: Array<"api-key" | "oauth" | "local">; + authSource?: AiPiProviderAuthSource; + authLabel?: string | null; + subscription?: boolean; + loginLabel?: string | null; + authExpiresAt?: number | null; +}; + +export type AiPiInstallationStatus = { + installed: boolean; + sdkAvailable: boolean; + cliAvailable: boolean; + cliPath: string | null; + packageRoot: string | null; + version: string | null; + agentDir: string; + settingsPath: string; + authPath: string; + modelsPath: string; + modelsStorePath: string; + blocker: string | null; + providers: AiPiProviderStatus[]; + availableModelIds: string[]; + authFileDetected: boolean; + modelsFileDetected: boolean; + settingsFileDetected: boolean; + stale: boolean; + error?: string | null; +}; + export type AiSettingsStatus = { mode: "guest" | "subscription"; availableProviders: { @@ -1246,6 +1295,7 @@ export type AiSettingsStatus = { customModelSlugs?: string[]; /** Epoch ms of the last successful models.dev fetch (or cache mtime on fallback); null if never fetched. */ modelsDevLastFetchedAt?: number | null; + piInstallation?: AiPiInstallationStatus; apiKeyStore?: { secureStorageAvailable: boolean; macosKeychainAvailable?: boolean; @@ -1289,6 +1339,7 @@ export type AiProviderPermissions = { cursor?: AgentChatPermissionMode; droid?: AgentChatPermissionMode; opencode?: AgentChatPermissionMode; + pi?: AgentChatPermissionMode; codexSandbox?: "read-only" | "workspace-write" | "danger-full-access"; writablePaths?: string[]; allowedTools?: string[]; @@ -1447,6 +1498,7 @@ export type AiIntegrationStatus = { providerConnections?: AiProviderConnections; runtimeConnections?: AiRuntimeConnections; availableModelIds?: ModelId[]; + piInstallation?: AiPiInstallationStatus; }; export type ProjectUiConfig = { diff --git a/apps/desktop/src/shared/types/externalSessions.ts b/apps/desktop/src/shared/types/externalSessions.ts index 63b02ee052..ee276370a0 100644 --- a/apps/desktop/src/shared/types/externalSessions.ts +++ b/apps/desktop/src/shared/types/externalSessions.ts @@ -1,4 +1,4 @@ -export type ExternalSessionProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; +export type ExternalSessionProvider = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi"; export interface ExternalSessionCapabilities { resumeInPlace: boolean; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 1ab44a7e3b..4ede412783 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -23,6 +23,7 @@ export type TerminalToolType = | "cursor-cli" | "droid" | "opencode" + | "pi" | "claude-orchestrated" | "codex-orchestrated" | "opencode-orchestrated" @@ -31,6 +32,7 @@ export type TerminalToolType = | "opencode-chat" | "cursor" | "droid-chat" + | "pi-chat" | "aider" | "continue" | "other"; @@ -41,6 +43,7 @@ export type TrackedAgentCliToolType = | "cursor-cli" | "droid" | "opencode" + | "pi" | "claude-orchestrated" | "codex-orchestrated" | "opencode-orchestrated"; @@ -63,6 +66,7 @@ export function isTrackedAgentCliToolType( || toolType === "cursor-cli" || toolType === "droid" || toolType === "opencode" + || toolType === "pi" || toolType === "claude-orchestrated" || toolType === "codex-orchestrated" || toolType === "opencode-orchestrated"; @@ -118,7 +122,7 @@ export function parseSessionSettleOverride( return undefined; } -export type TerminalResumeProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; +export type TerminalResumeProvider = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi"; export type TerminalResumeTargetKind = "session" | "thread"; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index baf0358ee8..67797639d2 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1593,7 +1593,7 @@ export type SyncRunQuickCommandArgs = { tracked?: boolean; }; -export type SyncCliLaunchProvider = "claude" | "codex" | "cursor" | "droid" | "opencode" | "shell"; +export type SyncCliLaunchProvider = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi" | "shell"; export type SyncStartCliSessionArgs = { laneId: string; diff --git a/apps/desktop/tsup.config.ts b/apps/desktop/tsup.config.ts index 94f255fd3e..e9e42b1af9 100644 --- a/apps/desktop/tsup.config.ts +++ b/apps/desktop/tsup.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ "main/main": "src/main/main.ts", "main/cursorSdkWorker": "src/main/services/chat/cursorSdkWorker.ts", "main/droidSdkWorker": "src/main/services/chat/droidSdkWorker.ts", + "main/piSdkWorker": "src/main/services/chat/piSdkWorker.ts", "main/ptyHostWorker": "src/main/services/pty/ptyHostWorker.ts", "main/usageLedgerWorker": "src/main/services/usage/usageLedgerWorkerEntry.ts", "main/packagedRuntimeSmoke": "src/main/packagedRuntimeSmoke.ts", diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 7628c925bb..781317c22b 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -837,6 +837,11 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { var model: String var modelId: String? var sessionProfile: String? + /// Native Pi identity. Older hosts omit these additive fields; the iOS + /// picker also derives them from the canonical `pi/...` model id. + var piProfileId: String? = nil + var piProviderId: String? = nil + var piModelId: String? = nil var title: String? var goal: String? var reasoningEffort: String? @@ -899,6 +904,9 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { && lhs.model == rhs.model && lhs.modelId == rhs.modelId && lhs.sessionProfile == rhs.sessionProfile + && lhs.piProfileId == rhs.piProfileId + && lhs.piProviderId == rhs.piProviderId + && lhs.piModelId == rhs.piModelId && lhs.title == rhs.title && lhs.goal == rhs.goal && lhs.reasoningEffort == rhs.reasoningEffort @@ -3429,6 +3437,9 @@ struct AgentChatModelInfo: Codable, Equatable, Identifiable { var maxThinkingTokens: Int? var modelId: String? var family: String? + var piProfileId: String? = nil + var piProviderId: String? = nil + var piModelId: String? = nil var supportsReasoning: Bool? var supportsTools: Bool? var color: String? @@ -3474,6 +3485,9 @@ struct AgentChatModelCatalogModel: Codable, Equatable, Identifiable { var providerId: String? var providerName: String? var stale: Bool? + var piProfileId: String? = nil + var piProviderId: String? = nil + var piModelId: String? = nil } struct AgentChatModelCatalogSubsection: Codable, Equatable, Identifiable { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 3c732cff6d..0a0e4e3656 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -10704,6 +10704,21 @@ final class SyncService: ObservableObject { ) } + /// Open Pi's own interactive login flow on the paired machine. Pi remains + /// the credential owner: the phone only asks the host to start its native + /// CLI and types `/login` through the tracked PTY. + func startPiLoginTerminal(laneId: String) async throws -> StartCliSessionResult { + try await startCliSession( + laneId: laneId, + provider: "pi", + permissionMode: "default", + title: "Pi login", + initialInput: "/login\n", + cols: 100, + rows: 28 + ) + } + func startCliSession( laneId: String, provider: String, @@ -11593,6 +11608,9 @@ final class SyncService: ObservableObject { reasoningEffort: String? = nil, codexFastMode: Bool? = nil, sessionProfile: String? = nil, + piProfileId: String? = nil, + piProviderId: String? = nil, + piModelId: String? = nil, permissionMode: String? = nil, interactionMode: String? = nil, claudePermissionMode: String? = nil, @@ -11616,6 +11634,9 @@ final class SyncService: ObservableObject { reasoningEffort: reasoningEffort, codexFastMode: codexFastMode, sessionProfile: sessionProfile, + piProfileId: piProfileId, + piProviderId: piProviderId, + piModelId: piModelId, permissionMode: permissionMode, interactionMode: interactionMode, claudePermissionMode: claudePermissionMode, @@ -11678,6 +11699,9 @@ final class SyncService: ObservableObject { reasoningEffort: String? = nil, codexFastMode: Bool? = nil, sessionProfile: String? = nil, + piProfileId: String? = nil, + piProviderId: String? = nil, + piModelId: String? = nil, permissionMode: String? = nil, interactionMode: String? = nil, claudePermissionMode: String? = nil, @@ -11701,6 +11725,9 @@ final class SyncService: ObservableObject { reasoningEffort: reasoningEffort, codexFastMode: codexFastMode, sessionProfile: sessionProfile, + piProfileId: piProfileId, + piProviderId: piProviderId, + piModelId: piModelId, permissionMode: permissionMode, interactionMode: interactionMode, claudePermissionMode: claudePermissionMode, @@ -11760,6 +11787,9 @@ final class SyncService: ObservableObject { reasoningEffort: String?, codexFastMode: Bool?, sessionProfile: String?, + piProfileId: String?, + piProviderId: String?, + piModelId: String?, permissionMode: String?, interactionMode: String?, claudePermissionMode: String?, @@ -11791,6 +11821,15 @@ final class SyncService: ObservableObject { if let sessionProfile, !sessionProfile.isEmpty { args["sessionProfile"] = sessionProfile } + if let piProfileId, !piProfileId.isEmpty { + args["piProfileId"] = piProfileId + } + if let piProviderId, !piProviderId.isEmpty { + args["piProviderId"] = piProviderId + } + if let piModelId, !piModelId.isEmpty { + args["piModelId"] = piModelId + } if let permissionMode, !permissionMode.isEmpty { args["permissionMode"] = permissionMode } @@ -20711,6 +20750,9 @@ extension SyncService { kickoffText: String, reasoningEffort: String? = nil, codexFastMode: Bool? = nil, + piProfileId: String? = nil, + piProviderId: String? = nil, + piModelId: String? = nil, permissionMode: String? = nil, interactionMode: String? = nil, claudePermissionMode: String? = nil, @@ -20730,6 +20772,9 @@ extension SyncService { ] if let reasoningEffort, !reasoningEffort.isEmpty { args["reasoningEffort"] = reasoningEffort } if let codexFastMode { args["codexFastMode"] = codexFastMode } + if let piProfileId, !piProfileId.isEmpty { args["piProfileId"] = piProfileId } + if let piProviderId, !piProviderId.isEmpty { args["piProviderId"] = piProviderId } + if let piModelId, !piModelId.isEmpty { args["piModelId"] = piModelId } if let permissionMode, !permissionMode.isEmpty { args["permissionMode"] = permissionMode } if let interactionMode, !interactionMode.isEmpty { args["interactionMode"] = interactionMode } if let claudePermissionMode, !claudePermissionMode.isEmpty { args["claudePermissionMode"] = claudePermissionMode } diff --git a/apps/ios/ADE/Shared/ADESharedTheme.swift b/apps/ios/ADE/Shared/ADESharedTheme.swift index eca60587b7..ade788b050 100644 --- a/apps/ios/ADE/Shared/ADESharedTheme.swift +++ b/apps/ios/ADE/Shared/ADESharedTheme.swift @@ -8,6 +8,7 @@ public enum ADESharedTheme { // MARK: - Brand colors (mirror of ADEDesignSystem) public static let brandClaude = Color(red: 0xD9 / 255.0, green: 0x77 / 255.0, blue: 0x06 / 255.0) // #D97706 public static let brandCodex = Color(red: 0x10 / 255.0, green: 0xA3 / 255.0, blue: 0x7F / 255.0) // #10A37F + public static let brandPi = Color(red: 0xF9 / 255.0, green: 0x73 / 255.0, blue: 0x16 / 255.0) // #F97316 public static let brandCursor = Color(red: 0xA7 / 255.0, green: 0x8B / 255.0, blue: 0xFA / 255.0) // #A78BFA public static let brandOpenCode = Color(red: 0x25 / 255.0, green: 0x63 / 255.0, blue: 0xEB / 255.0) // #2563EB public static let brandGoogle = Color(red: 0xF5 / 255.0, green: 0x9E / 255.0, blue: 0x0B / 255.0) // #F59E0B @@ -27,6 +28,7 @@ public enum ADESharedTheme { switch providerSlug.lowercased() { case "claude", "anthropic": return brandClaude case "codex", "openai": return brandCodex + case "pi": return brandPi case "cursor": return brandCursor case "opencode": return brandOpenCode case "google", "gemini": return brandGoogle @@ -52,6 +54,7 @@ public enum ADESharedTheme { case "cursor": return "ProviderCursor" case "opencode": return "ProviderOpenCode" case "droid", "factory": return "ProviderDroid" + case "pi": return nil case "github": return "ProviderGitHub" default: return nil } @@ -67,6 +70,7 @@ public enum ADESharedTheme { case "cursor": return "Cursor" case "opencode": return "OpenCode" case "droid", "factory": return "Droid" + case "pi": return "Pi" case "google", "gemini": return "Gemini" case "mistral": return "Mistral" case "deepseek": return "DeepSeek" diff --git a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift index cf7190ab0c..e784622627 100644 --- a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift +++ b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift @@ -806,6 +806,12 @@ struct HubInlineComposer: View { let targetProjectId = project.id let targetProjectRootPath = project.rootPath let wire = workRuntimeWireFields(provider: provider, mode: runtimeMode) + let piMetadata = workResolvedPiModelMetadata( + modelId: modelId, + profileId: selectedModelOption?.piProfileId, + providerId: selectedModelOption?.piProviderId, + piModelId: selectedModelOption?.piModelId + ) let normalizedReasoning = reasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines) // Resolve the target lane. Auto-create mints a fresh lane in the TARGET @@ -885,6 +891,9 @@ struct HubInlineComposer: View { // Send an explicit true/false when fast mode applies so the user's // choice (including an explicit OFF) is honored; nil only when N/A. codexFastMode: fastModeSupported ? codexFastMode : nil, + piProfileId: piMetadata?.profileId, + piProviderId: piMetadata?.providerId, + piModelId: piMetadata?.modelId, permissionMode: wire.permissionMode, interactionMode: wire.interactionMode, claudePermissionMode: wire.claudePermissionMode, @@ -1171,11 +1180,10 @@ struct HubInlineComposer: View { // MARK: - File-private helpers (mirror the private new-chat-screen helpers) /// Collapse a free-form provider key to a chat-capable runtime family, matching -/// the new-chat screen so a picked Droid Core model stays on the droid runtime -/// instead of silently routing to Claude. +/// the new-chat screen so routed Pi and Droid models stay on their native +/// runtimes instead of silently routing to Claude. private func hubNormalizedChatProvider(_ provider: String) -> String { - let family = providerFamilyKey(provider) - return ["claude", "codex", "cursor", "opencode", "droid"].contains(family) ? family : "claude" + workNormalizedChatProvider(provider) } private func hubChatModelBelongs(_ modelId: String, to provider: String) -> Bool { @@ -1193,6 +1201,7 @@ private func hubDefaultChatModelId(provider: String) -> String { case "codex": return workDefaultCatalogModelId(provider: "codex") ?? "gpt-5.6-sol" case "cursor": return "auto" case "opencode": return "opencode/anthropic/claude-sonnet-5" + case "pi": return "" default: return "claude-sonnet-5" } } @@ -1201,7 +1210,7 @@ private func hubDefaultChatModelId(provider: String) -> String { /// screen's `workCliSupportsReasoningSelection`). private func hubCliSupportsReasoning(provider: String) -> Bool { let family = providerFamilyKey(provider) - return family == "claude" || family == "codex" || family == "droid" + return family == "claude" || family == "codex" || family == "droid" || family == "pi" } /// Derive a short CLI session title from the opener (mirrors the new-chat diff --git a/apps/ios/ADE/Views/PersonalChats/PersonalChatsScreen.swift b/apps/ios/ADE/Views/PersonalChats/PersonalChatsScreen.swift index b1e7abd762..6f9e8eade0 100644 --- a/apps/ios/ADE/Views/PersonalChats/PersonalChatsScreen.swift +++ b/apps/ios/ADE/Views/PersonalChats/PersonalChatsScreen.swift @@ -238,9 +238,21 @@ private struct PersonalChatRow: View { private var subtitle: String { let preview = summary.lastOutputPreview?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return preview.isEmpty - ? "\(providerLabel(summary.provider)) · \(prettyWorkChatModelName(summary.model))" - : preview + return preview.isEmpty ? modelContext : preview + } + + private var modelContext: String { + let rawModel = summary.modelId?.trimmingCharacters(in: .whitespacesAndNewlines) + .flatMap { $0.isEmpty ? nil : $0 } + ?? summary.model + let metadata = workResolvedPiModelMetadata( + modelId: rawModel, + profileId: summary.piProfileId, + providerId: summary.piProviderId, + piModelId: summary.piModelId + ) + let modelLabel = metadata.map(workPiModelDisplayName) ?? prettyWorkChatModelName(rawModel) + return "\(providerLabel(summary.provider)) · \(modelLabel)" } private var accessibilityStatus: String { @@ -259,6 +271,7 @@ private struct PersonalChatRow: View { accessibilityStatus, summary.archivedAt == nil ? nil : "Archived", subtitle, + subtitle == modelContext ? nil : modelContext, relativeTimestamp(summary.lastActivityAt), ] .compactMap { $0 } @@ -306,6 +319,12 @@ private struct PersonalChatRow: View { .font(.caption) .foregroundStyle(ADEColor.textSecondary) .lineLimit(2) + if subtitle != modelContext { + Text(modelContext) + .font(.caption2.weight(.medium)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } } Image(systemName: "chevron.right") @@ -366,7 +385,7 @@ func makePersonalChatSessionStub(_ summary: AgentChatSessionSummary) -> Terminal pinned: false, manuallyNamed: nil, goal: summary.goal, - toolType: provider == "cursor" ? "cursor" : "\(provider)-chat", + toolType: toolTypeForProvider(provider), title: summary.title ?? "Chat", status: status, startedAt: summary.startedAt, @@ -398,6 +417,7 @@ struct PersonalChatNewScreen: View { @State private var composerHeight: CGFloat = 88 @State private var composerFocused = true @State private var modelPickerPresented = false + @State private var selectedModelOption: WorkModelOption? @State private var busy = false @State private var errorMessage: String? @State private var createdSummary: AgentChatSessionSummary? @@ -474,9 +494,9 @@ struct PersonalChatNewScreen: View { commandScope: .personal, isBusy: busy, onSelect: { option, effort, runtimeProvider, fastMode in + selectedModelOption = option modelId = option.id - let family = providerFamilyKey(runtimeProvider) - provider = ["claude", "codex", "cursor", "opencode", "droid"].contains(family) ? family : "claude" + provider = workNormalizedChatProvider(runtimeProvider) reasoningEffort = effort ?? "" codexFastMode = fastMode runtimeMode = workDefaultRuntimeMode(provider: provider) @@ -558,6 +578,12 @@ struct PersonalChatNewScreen: View { errorMessage = nil defer { busy = false } let wire = workRuntimeWireFields(provider: provider, mode: runtimeMode) + let piMetadata = workResolvedPiModelMetadata( + modelId: modelId, + profileId: selectedModelOption?.piProfileId, + providerId: selectedModelOption?.piProviderId, + piModelId: selectedModelOption?.piModelId + ) do { let summary = try await syncService.createPersonalChat( provider: provider, @@ -565,6 +591,9 @@ struct PersonalChatNewScreen: View { kickoffText: prompt, reasoningEffort: reasoningEffort.isEmpty ? nil : reasoningEffort, codexFastMode: workComposerSupportsFastMode(modelId: modelId, provider: provider) ? codexFastMode : nil, + piProfileId: piMetadata?.profileId, + piProviderId: piMetadata?.providerId, + piModelId: piMetadata?.modelId, permissionMode: wire.permissionMode, interactionMode: wire.interactionMode, claudePermissionMode: wire.claudePermissionMode, diff --git a/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift b/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift index 192dcb7763..fbf81559bf 100644 --- a/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift @@ -181,6 +181,8 @@ func workSessionRuntimeLabel(session: TerminalSessionSummary) -> String { return "Claude" case "codex-chat": return "Codex" + case "pi-chat": + return "Pi" case "opencode-chat": return "OpenCode" case "cursor": diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift index 4a5877f1c2..32829ea9dd 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift @@ -232,6 +232,12 @@ enum WorkComposerSlashCatalog { ("/explain", "Explain a file or change."), ("/review", "Review the current diff."), ] + case "pi": + return [ + ("/compact", "Compact the native Pi session context."), + ("/explain", "Explain a file or change."), + ("/review", "Review the current diff."), + ] default: return [ ("/help", "Show available commands."), diff --git a/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift b/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift index 9f4b4ea59a..6c7b9dfe93 100644 --- a/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift +++ b/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift @@ -55,6 +55,7 @@ struct WorkContextCompactDivider: View { case "codex": return .white.opacity(0.85) case "opencode": return .cyan case "droid": return .orange + case "pi": return .orange default: return ADEColor.warning } } diff --git a/apps/ios/ADE/Views/Work/WorkExternalSessionAffordances.swift b/apps/ios/ADE/Views/Work/WorkExternalSessionAffordances.swift index 576775fc26..84ba9937a2 100644 --- a/apps/ios/ADE/Views/Work/WorkExternalSessionAffordances.swift +++ b/apps/ios/ADE/Views/Work/WorkExternalSessionAffordances.swift @@ -200,6 +200,7 @@ func workExternalSessionProviderName(_ provider: String) -> String { case "cursor": return "Cursor" case "droid", "factory": return "Droid" case "opencode": return "OpenCode" + case "pi": return "Pi" default: let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Unknown" : trimmed diff --git a/apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift b/apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift index 4df1101cbf..a208ad8344 100644 --- a/apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift @@ -1,7 +1,7 @@ import Foundation import SwiftUI -private let workImportSessionProviders = ["all", "claude", "codex", "cursor", "droid", "opencode"] +private let workImportSessionProviders = ["all", "claude", "codex", "cursor", "droid", "pi", "opencode"] private struct WorkPendingExternalSessionImport: Identifiable { let session: ExternalSessionSummary @@ -991,6 +991,7 @@ private func workImportToolType(provider: String) -> String { // Cursor session as a CLI terminal, matching the desktop import mapping. case "cursor": return "cursor-cli" case "droid": return "droid" + case "pi": return "pi" case "opencode": return "opencode" default: return provider } diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index 79f36aba03..4ef8625b18 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -13,8 +13,9 @@ struct WorkModelOption: Identifiable, Hashable { /// Short one-line pitch — "Fastest · cheapest" / "Best for deep reasoning". let tagline: String /// Provider family key that maps to a `providerAssetName` logo + tint - /// (e.g. "claude" for the CLAUDE brand avatar). For OpenCode-routed - /// models this is still the upstream family so the logo stays brand-true. + /// (e.g. "claude" for the CLAUDE brand avatar). Routed runtimes such as Pi + /// intentionally keep their runtime identity here; the upstream provider is + /// retained separately in the Pi metadata fields below. let provider: String /// Reasoning efforts supplied by the paired desktop host. Empty means the /// host did not advertise a selectable reasoning control for this model. @@ -24,6 +25,12 @@ struct WorkModelOption: Identifiable, Hashable { let serviceTiers: [String] let cursorAvailability: CursorModelAvailability? let isAvailable: Bool + /// Runtime model reference and native Pi identity supplied by the host. + /// These are optional so older hosts and non-Pi models remain compatible. + let runtimeModelId: String? + let piProfileId: String? + let piProviderId: String? + let piModelId: String? init( id: String, @@ -35,7 +42,11 @@ struct WorkModelOption: Identifiable, Hashable { defaultReasoningEffort: String? = nil, serviceTiers: [String] = [], cursorAvailability: CursorModelAvailability? = nil, - isAvailable: Bool = true + isAvailable: Bool = true, + runtimeModelId: String? = nil, + piProfileId: String? = nil, + piProviderId: String? = nil, + piModelId: String? = nil ) { self.id = id self.displayName = displayName @@ -47,9 +58,68 @@ struct WorkModelOption: Identifiable, Hashable { self.serviceTiers = serviceTiers self.cursorAvailability = cursorAvailability self.isAvailable = isAvailable + self.runtimeModelId = runtimeModelId + self.piProfileId = piProfileId + self.piProviderId = piProviderId + self.piModelId = piModelId } } +/// Native Pi model identity carried by ADE's canonical model id: +/// `pi///`. +/// +/// Pi's profile and model components are percent-encoded by the desktop +/// registry. The provider component is intentionally left readable in the +/// canonical id, but decoding it as well keeps this helper tolerant of older +/// or hand-authored catalog payloads. +struct WorkPiModelMetadata: Equatable, Hashable { + let profileId: String + let providerId: String + let modelId: String +} + +func workPiModelMetadata(for rawModelId: String) -> WorkPiModelMetadata? { + let trimmed = rawModelId.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: "/", omittingEmptySubsequences: true) + guard parts.count >= 4, parts[0].lowercased() == "pi" else { return nil } + + func decoded(_ value: String) -> String { + value.removingPercentEncoding ?? value + } + + let profileId = decoded(String(parts[1])) + let providerId = decoded(String(parts[2])) + let modelId = decoded(parts.dropFirst(3).map(String.init).joined(separator: "/")) + guard !profileId.isEmpty, !providerId.isEmpty, !modelId.isEmpty else { return nil } + return WorkPiModelMetadata(profileId: profileId, providerId: providerId, modelId: modelId) +} + +/// Resolve explicit Pi metadata when the host provided it, falling back to +/// the canonical id. This keeps native provider/model references intact across +/// both new and older catalog payloads. +func workResolvedPiModelMetadata( + modelId: String, + profileId: String? = nil, + providerId: String? = nil, + piModelId: String? = nil +) -> WorkPiModelMetadata? { + let parsed = workPiModelMetadata(for: modelId) + func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + guard let resolvedProfile = nonEmpty(profileId) ?? parsed?.profileId, + let resolvedProvider = nonEmpty(providerId) ?? parsed?.providerId, + let resolvedModel = nonEmpty(piModelId) ?? parsed?.modelId else { + return nil + } + return WorkPiModelMetadata( + profileId: resolvedProfile, + providerId: resolvedProvider, + modelId: resolvedModel + ) +} + extension WorkModelOption { enum Tier: String { case fast, balanced, flagship, reasoning } @@ -135,6 +205,7 @@ func workResolveCliProvider(for modelId: String, provider: String) -> String { case "codex": return "codex" case "cursor": return "cursor" case "droid": return "droid" + case "pi": return "pi" default: return "opencode" } } @@ -206,7 +277,7 @@ struct WorkModelProvider: Identifiable, Hashable { /// the desktop `ModelCatalogPanel` group layout. struct WorkModelCatalogGroup: Identifiable, Hashable { var id: String { key } - /// Runtime key: "claude" | "codex" | "cursor" | "opencode". + /// Runtime key: "claude" | "codex" | "cursor" | "droid" | "pi" | "opencode". let key: String let displayName: String let providers: [WorkModelProvider] @@ -228,7 +299,7 @@ struct WorkModelCatalogGroupLegacyView: Identifiable, Hashable { let models: [WorkModelOption] } -private let workModelGroupOrder = ["claude", "codex", "cursor", "droid", "opencode", "ollama", "lmstudio"] +private let workModelGroupOrder = ["claude", "codex", "pi", "cursor", "droid", "opencode", "ollama", "lmstudio"] private func workClaudeOpus5ReasoningEfforts() -> [AgentChatModelReasoningEffort] { [ @@ -656,13 +727,49 @@ func workModelCatalogGroups( currentProvider: String ) -> [WorkModelCatalogGroup] { let groups = hostCatalog.groups.map { group in - WorkModelCatalogGroup( - key: group.key, - displayName: group.displayName, - providers: group.providers.map { provider in + let isPiGroup = group.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "pi" + let providers: [WorkModelProvider] + if isPiGroup { + // A Pi subsection carries the profile/provider identity. Flattening all + // subsections into one provider makes two profiles with the same + // upstream provider indistinguishable in the mobile picker. + providers = group.providers.flatMap { provider in + if provider.subsections.isEmpty { + return [] + } + return provider.subsections.compactMap { subsection -> WorkModelProvider? in + let models = workDeduplicatedModelOptions( + subsection.models.map { model in + workCatalogModelOption( + from: model, + topLevelProvider: group.key, + providerKey: provider.key + ) + } + ) + guard !models.isEmpty else { return nil } + let sectionKey = workPiProviderSectionKey( + subsection: subsection, + providerKey: provider.key, + models: models + ) + return WorkModelProvider( + key: sectionKey, + displayName: workPiProviderSectionLabel( + key: sectionKey, + fallbackLabel: subsection.label, + providerKey: provider.key, + models: models + ), + models: models + ) + } + } + } else { + providers = group.providers.map { provider in let models = provider.subsections .flatMap(\.models) - .map { model in + .map { model in workCatalogModelOption( from: model, topLevelProvider: group.key, @@ -679,7 +786,12 @@ func workModelCatalogGroups( ) ) } - .filter { !$0.models.isEmpty } + } + + WorkModelCatalogGroup( + key: group.key, + displayName: group.displayName, + providers: providers.filter { !$0.models.isEmpty } ) } .filter { !$0.providers.isEmpty } @@ -696,6 +808,19 @@ private func workCatalogModelOption( topLevelProvider: String, providerKey: String ) -> WorkModelOption { + let piMetadata = workResolvedPiModelMetadata( + modelId: model.id, + profileId: model.piProfileId, + providerId: model.piProviderId, + piModelId: model.piModelId + ) ?? model.modelId.flatMap { + workResolvedPiModelMetadata( + modelId: $0, + profileId: model.piProfileId, + providerId: model.piProviderId, + piModelId: model.piModelId + ) + } let reasoningModelId = workCanonicalCodexRegistryId(for: model.id) ?? workCanonicalCodexRegistryId(for: model.runtimeModelId) ?? model.id @@ -736,7 +861,11 @@ private func workCatalogModelOption( ), serviceTiers: model.serviceTiers ?? [], cursorAvailability: model.cursorAvailability, - isAvailable: model.isAvailable + isAvailable: model.isAvailable, + runtimeModelId: model.runtimeModelId, + piProfileId: piMetadata?.profileId, + piProviderId: piMetadata?.providerId, + piModelId: piMetadata?.modelId ) } @@ -967,6 +1096,15 @@ private func workProviderDisplayName( providerKey: String, curatedGroups: [WorkModelCatalogGroup] ) -> String { + if groupKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "pi" { + if let parts = workPiProviderSectionParts(providerKey) { + let providerLabel = workPiProviderLabel(parts.providerId) + return parts.profileId.caseInsensitiveCompare("default") == .orderedSame + ? providerLabel + : "\(providerLabel) · \(parts.profileId)" + } + return workPiProviderLabel(providerKey) + } if let curated = curatedGroups .first(where: { $0.key == groupKey })? .providers @@ -991,6 +1129,126 @@ private func workProviderDisplayName( } } +/// Human-readable upstream provider labels used inside the Pi rail. Keep the +/// runtime rail branded as Pi while making a provider tab such as +/// `openai-codex` immediately understandable. +private let workPiProviderSectionPrefix = "__piprov__:" + +private let workPiProviderSectionAllowedCharacters: CharacterSet = { + CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) +}() + +private func workPiEncodedSectionComponent(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: workPiProviderSectionAllowedCharacters) ?? value +} + +private func workPiDecodedSectionComponent(_ value: Substring) -> String { + String(value).removingPercentEncoding ?? String(value) +} + +func workPiProviderSectionKey(profileId: String, providerId: String) -> String { + "\(workPiProviderSectionPrefix)\(workPiEncodedSectionComponent(profileId)):\(workPiEncodedSectionComponent(providerId))" +} + +private func workPiProviderSectionParts(_ key: String) -> (profileId: String, providerId: String)? { + guard key.hasPrefix(workPiProviderSectionPrefix) else { return nil } + let body = key.dropFirst(workPiProviderSectionPrefix.count) + let parts = body.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { return nil } + let profileId = workPiDecodedSectionComponent(parts[0]) + let providerId = workPiDecodedSectionComponent(parts[1]) + guard !profileId.isEmpty, !providerId.isEmpty else { return nil } + return (profileId: profileId, providerId: providerId) +} + +private func workPiProviderSectionKey( + subsection: AgentChatModelCatalogSubsection, + providerKey: String, + models: [WorkModelOption] +) -> String { + if workPiProviderSectionParts(subsection.key) != nil { + return subsection.key + } + if let metadata = models.compactMap({ + workResolvedPiModelMetadata( + modelId: $0.id, + profileId: $0.piProfileId, + providerId: $0.piProviderId, + piModelId: $0.piModelId + ) + }).first { + return workPiProviderSectionKey(profileId: metadata.profileId, providerId: metadata.providerId) + } + // Older hosts may send a non-profiled Pi subsection. Keep it distinct per + // upstream provider instead of allowing duplicate SwiftUI ids. + return "\(providerKey):\(subsection.key)" +} + +private func workPiProviderSectionLabel( + key: String, + fallbackLabel: String, + providerKey: String, + models: [WorkModelOption] +) -> String { + if let parts = workPiProviderSectionParts(key) { + let providerLabel = workPiProviderLabel(parts.providerId) + return parts.profileId.caseInsensitiveCompare("default") == .orderedSame + ? providerLabel + : "\(providerLabel) · \(parts.profileId)" + } + if let metadata = models.compactMap({ + workResolvedPiModelMetadata( + modelId: $0.id, + profileId: $0.piProfileId, + providerId: $0.piProviderId, + piModelId: $0.piModelId + ) + }).first { + let providerLabel = workPiProviderLabel(metadata.providerId) + return metadata.profileId.caseInsensitiveCompare("default") == .orderedSame + ? providerLabel + : "\(providerLabel) · \(metadata.profileId)" + } + let fallback = fallbackLabel.trimmingCharacters(in: .whitespacesAndNewlines) + return fallback.isEmpty || fallback.caseInsensitiveCompare("models") == .orderedSame + ? workPiProviderLabel(providerKey) + : fallback +} + +func workPiModelContextLabel(for model: WorkModelOption) -> String? { + guard let metadata = workResolvedPiModelMetadata( + modelId: model.id, + profileId: model.piProfileId, + providerId: model.piProviderId, + piModelId: model.piModelId + ) else { return nil } + return "\(workPiProviderLabel(metadata.providerId)) · \(metadata.profileId)" +} + +func workPiProviderLabel(_ provider: String) -> String { + let normalized = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "anthropic": return "Anthropic" + case "openai": return "OpenAI" + case "openai-codex": return "OpenAI Codex" + case "google": return "Google" + case "google-gemini-cli": return "Google Gemini CLI" + case "google-antigravity": return "Google Antigravity" + case "mistral": return "Mistral" + case "deepseek": return "DeepSeek" + case "github-copilot": return "GitHub Copilot" + case "xai": return "xAI" + case "groq": return "Groq" + default: + return normalized + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + } +} + private func workProviderSortOrder( groupKey: String, providerKey: String, @@ -1020,6 +1278,23 @@ private func workModelProviderKey(for model: AgentChatModelInfo, topLevelProvide let normalizedFamily = model.family?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" switch topLevelProvider { + case "pi": + if let metadata = workResolvedPiModelMetadata( + modelId: model.id, + profileId: model.piProfileId, + providerId: model.piProviderId, + piModelId: model.piModelId + ) ?? model.modelId.flatMap({ + workResolvedPiModelMetadata( + modelId: $0, + profileId: model.piProfileId, + providerId: model.piProviderId, + piModelId: model.piModelId + ) + }) { + return workPiProviderSectionKey(profileId: metadata.profileId, providerId: metadata.providerId) + } + return normalizedFamily.isEmpty ? "pi" : normalizedFamily case "claude": return "anthropic" case "codex": @@ -1084,6 +1359,7 @@ private func workModelProviderKey(for model: AgentChatModelInfo, topLevelProvide private func workModelBrandKey(topLevelProvider: String, providerKey: String) -> String { if topLevelProvider == "claude" { return "claude" } if topLevelProvider == "codex" { return "codex" } + if topLevelProvider == "pi" { return "pi" } switch providerKey { case "anthropic": return "claude" @@ -1098,6 +1374,19 @@ private func workDynamicModelOption( providerKey: String, curated: WorkModelOption? ) -> WorkModelOption { + let piMetadata = workResolvedPiModelMetadata( + modelId: model.id, + profileId: model.piProfileId, + providerId: model.piProviderId, + piModelId: model.piModelId + ) ?? model.modelId.flatMap { + workResolvedPiModelMetadata( + modelId: $0, + profileId: model.piProfileId, + providerId: model.piProviderId, + piModelId: model.piModelId + ) + } let reasoningModelId = model.modelId.flatMap(workCanonicalCodexRegistryId(for:)) ?? model.id let displayName = model.displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -1128,7 +1417,9 @@ private func workDynamicModelOption( displayName: displayName, tier: workDynamicModelTier(for: model.id, curated: curated), tagline: tagline, - provider: curated?.provider ?? workModelBrandKey(topLevelProvider: topLevelProvider, providerKey: providerKey), + provider: topLevelProvider.lowercased() == "pi" + ? "pi" + : (curated?.provider ?? workModelBrandKey(topLevelProvider: topLevelProvider, providerKey: providerKey)), reasoningEfforts: workVisibleReasoningEfforts( modelId: reasoningModelId, advertised: model.reasoningEfforts, @@ -1140,7 +1431,13 @@ private func workDynamicModelOption( fallback: curated?.defaultReasoningEffort ), serviceTiers: model.serviceTiers ?? curated?.serviceTiers ?? [], - cursorAvailability: model.cursorAvailability + cursorAvailability: model.cursorAvailability, + runtimeModelId: topLevelProvider.lowercased() == "pi" + ? piMetadata.map { "\($0.providerId)/\($0.modelId)" } ?? model.modelId + : model.modelId, + piProfileId: piMetadata?.profileId, + piProviderId: piMetadata?.providerId, + piModelId: piMetadata?.modelId ) } @@ -1226,17 +1523,33 @@ private func injectCurrentWorkModelIfNeeded( if !alreadyPresent { let providerLower = currentProvider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let targetGroupKey = workModelCatalogGroupKey(for: currentModelId, currentProvider: currentProvider) - let providerKey = providerLower.isEmpty ? "other" : providerLower + let piMetadata = workResolvedPiModelMetadata(modelId: currentModelId) + let providerKey = targetGroupKey == "pi" + ? (piMetadata.map { workPiProviderSectionKey(profileId: $0.profileId, providerId: $0.providerId) } + ?? (providerLower.isEmpty ? "pi" : providerLower)) + : (providerLower.isEmpty ? "other" : providerLower) let injected = WorkModelOption( id: currentModelId, displayName: currentModelId, tier: .balanced, tagline: "In use on the paired machine", - provider: workModelBrandKey(topLevelProvider: targetGroupKey, providerKey: providerKey) + provider: workModelBrandKey(topLevelProvider: targetGroupKey, providerKey: providerKey), + runtimeModelId: targetGroupKey == "pi" ? currentModelId : nil, + piProfileId: piMetadata?.profileId, + piProviderId: piMetadata?.providerId, + piModelId: piMetadata?.modelId ) if let groupIndex = groups.firstIndex(where: { $0.key == targetGroupKey }) { let providers = groups[groupIndex].providers - let providerIndex = providers.firstIndex(where: { $0.key == providerKey }) ?? providers.startIndex + let providerIndex = providers.firstIndex(where: { $0.key == providerKey }) + ?? (targetGroupKey == "pi" && piMetadata != nil + ? providers.firstIndex(where: { provider in + workPiProviderSectionParts(provider.key).map { + $0.profileId == piMetadata?.profileId && $0.providerId == piMetadata?.providerId + } ?? false + }) + : nil) + ?? providers.startIndex if !providers.isEmpty { var rebuilt = providers let targetProvider = rebuilt[providerIndex] @@ -1274,6 +1587,12 @@ func workModelCatalogGroupKey(for currentModelId: String, currentProvider: Strin let provider = currentProvider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let modelId = currentModelId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + // Pi is a routed runtime, not an upstream provider. Check its canonical + // namespace before model-name heuristics so `pi/.../gpt...` never becomes + // Codex and `pi/.../claude...` never becomes Claude. + if provider == "pi" || modelId.hasPrefix("pi/") { + return "pi" + } if provider == "lmstudio" || modelId.hasPrefix("opencode/lmstudio/") { return "lmstudio" } diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index 25d2784ceb..bae0f87ff9 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -72,6 +72,8 @@ struct WorkModelPickerSheet: View { @State private var fallbackLoginLanes: [LaneSummary] = [] @State private var claudeLoginBusy = false @State private var claudeLoginError: String? + @State private var piLoginBusy = false + @State private var piLoginError: String? private var catalog: [WorkModelCatalogGroup] { if let liveCatalog { @@ -135,6 +137,10 @@ struct WorkModelPickerSheet: View { ?? candidateLanes.first } + private var piLoginLane: LaneSummary? { + claudeLoginLane + } + var body: some View { NavigationStack { VStack(spacing: 0) { @@ -183,7 +189,10 @@ struct WorkModelPickerSheet: View { }, onClaudeLogin: commandScope == .project ? { Task { await openClaudeLoginTerminal() } } : nil, isClaudeLoginBusy: claudeLoginBusy, - claudeLoginError: claudeLoginError + claudeLoginError: claudeLoginError, + onPiLogin: commandScope == .project ? { Task { await openPiLoginTerminal() } } : nil, + isPiLoginBusy: piLoginBusy, + piLoginError: piLoginError ) } Divider().overlay(ADEColor.glassBorder) @@ -443,15 +452,18 @@ struct WorkModelPickerSheet: View { @ViewBuilder private var catalogEmptyState: some View { + let piSelected = providerFamilyKey(currentProvider) == "pi" VStack(spacing: 12) { Spacer(minLength: 24) - Image(systemName: "tray") + Image(systemName: piSelected ? "terminal.fill" : "tray") .font(.title3.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - Text("No models are currently available.") + .foregroundStyle(piSelected ? providerTint("pi") : ADEColor.textMuted) + Text(piSelected ? "No Pi models are available yet." : "No models are currently available.") .font(.subheadline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) - Text("Connect a provider on the paired machine or load a local model provider, then reopen the picker.") + Text(piSelected + ? "Use Pi’s native /login or configure a Pi provider on the paired machine, then reopen the picker." + : "Connect a provider on the paired machine or load a local model provider, then reopen the picker.") .font(.footnote) .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) @@ -493,7 +505,7 @@ struct WorkModelPickerSheet: View { private func refreshCatalog(for groupKey: String) async { let refreshProvider: String? switch groupKey { - case "opencode", "cursor", "droid", "lmstudio", "ollama": + case "opencode", "cursor", "droid", "pi", "lmstudio", "ollama": refreshProvider = groupKey default: refreshProvider = nil @@ -565,6 +577,33 @@ struct WorkModelPickerSheet: View { } } + @MainActor + private func openPiLoginTerminal() async { + guard !piLoginBusy else { return } + piLoginBusy = true + piLoginError = nil + defer { piLoginBusy = false } + + do { + if piLoginLane == nil { + fallbackLoginLanes = try await syncService.fetchLanes(includeArchived: false) + } + guard let lane = piLoginLane else { + piLoginError = "No active lane is available." + return + } + // Pi owns its OAuth/device-code exchange. ADE only opens the native Pi + // CLI and sends `/login` through the paired PTY; credentials stay in + // the user's Pi profile on the desktop. + let result = try await syncService.startPiLoginTerminal(laneId: lane.id) + let sessionId = result.session?.id ?? result.sessionId + syncService.requestedWorkSessionNavigation = WorkSessionNavigationRequest(sessionId: sessionId) + dismiss() + } catch { + piLoginError = error.localizedDescription + } + } + private func select(model: WorkModelOption) { guard model.isAvailable else { return } @@ -824,6 +863,9 @@ struct ModelPickerContentPane: View { let onClaudeLogin: (() -> Void)? let isClaudeLoginBusy: Bool let claudeLoginError: String? + let onPiLogin: (() -> Void)? + let isPiLoginBusy: Bool + let piLoginError: String? private var favoritesSet: Set { Set(favorites) } @@ -851,6 +893,17 @@ struct ModelPickerContentPane: View { return rows.contains { !$0.isAvailable && providerFamilyKey($0.provider) == "claude" } } + private var showsPiLoginAction: Bool { + guard onPiLogin != nil else { return false } + let rows = groupedRows.flatMap(\.models) + if case .providerGroup(let key, _) = selection, key == "pi" { + return rows.contains { !$0.isAvailable } + } + return rows.contains { + !$0.isAvailable && (providerFamilyKey($0.provider) == "pi" || $0.id.lowercased().hasPrefix("pi/")) + } + } + var body: some View { VStack(alignment: .leading, spacing: 0) { header @@ -858,6 +911,9 @@ struct ModelPickerContentPane: View { if showsClaudeLoginAction { claudeLoginBanner } + if showsPiLoginAction { + piLoginBanner + } Divider().overlay(ADEColor.glassBorder) if groupedRows.allSatisfy({ $0.models.isEmpty }) { emptyState @@ -889,7 +945,9 @@ struct ModelPickerContentPane: View { onToggleFastMode: { enabled in onToggleFastMode(model, enabled) }, onToggleFavorite: { onToggleFavorite(model.id) }, onClaudeLogin: onClaudeLogin, - isClaudeLoginBusy: isClaudeLoginBusy + isClaudeLoginBusy: isClaudeLoginBusy, + onPiLogin: onPiLogin, + isPiLoginBusy: isPiLoginBusy ) } } @@ -1007,6 +1065,71 @@ struct ModelPickerContentPane: View { .padding(.bottom, 8) } + @ViewBuilder + private var piLoginBanner: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 10) { + Image(systemName: "terminal.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(providerTint("pi")) + .frame(width: 24, height: 24) + .background(providerTint("pi").opacity(0.14), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + VStack(alignment: .leading, spacing: 2) { + Text("Pi is signed out") + .font(.footnote.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Open Pi’s native login flow on the paired machine. ADE never stores Pi credentials.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Button { + onPiLogin?() + } label: { + HStack(spacing: 6) { + if isPiLoginBusy { + ProgressView() + .controlSize(.small) + .tint(ADEColor.textPrimary) + } else { + Image(systemName: "arrow.right.circle.fill") + .font(.caption.weight(.bold)) + } + Text("Open Pi /login") + .font(.caption.weight(.bold)) + .lineLimit(1) + } + .foregroundStyle(ADEColor.textPrimary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(providerTint("pi").opacity(0.22), in: Capsule()) + .overlay( + Capsule(style: .continuous) + .stroke(providerTint("pi").opacity(0.28), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .disabled(isPiLoginBusy) + .accessibilityLabel("Open Pi native login") + } + if let piLoginError, !piLoginError.isEmpty { + Text(piLoginError) + .font(.caption) + .foregroundStyle(ADEColor.danger) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(10) + .background(ADEColor.surfaceBackground.opacity(0.5), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(providerTint("pi").opacity(0.32), lineWidth: 0.7) + ) + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + @ViewBuilder private var header: some View { ZStack { @@ -1102,7 +1225,7 @@ struct ModelPickerContentPane: View { switch selection { case .favorites: return "star" case .recents: return "clock" - case .providerGroup: return "cpu" + case .providerGroup(let key, _): return providerFamilyKey(key) == "pi" ? "terminal.fill" : "cpu" } } @@ -1111,7 +1234,8 @@ struct ModelPickerContentPane: View { switch selection { case .favorites: return "No favorites yet." case .recents: return "No recent models." - case .providerGroup: return "No models in this provider." + case .providerGroup(let key, _): + return providerFamilyKey(key) == "pi" ? "No Pi models are available." : "No models in this provider." } } @@ -1124,7 +1248,10 @@ struct ModelPickerContentPane: View { return "Tap the star on any model to pin it here. Favorites sync between desktop, TUI, and mobile." case .recents: return "Models you pick here will appear in the recents list, on every paired surface." - case .providerGroup: + case .providerGroup(let key, _): + if providerFamilyKey(key) == "pi" { + return "Use Pi’s native /login or configure a local Pi provider on the paired machine, then refresh." + } return "Sign in to this provider on the paired machine to load its models." } } @@ -1152,6 +1279,8 @@ struct ModelPickerListRow: View { let onToggleFavorite: () -> Void let onClaudeLogin: (() -> Void)? let isClaudeLoginBusy: Bool + let onPiLogin: (() -> Void)? + let isPiLoginBusy: Bool private var isHighlighted: Bool { isSelected @@ -1174,6 +1303,16 @@ struct ModelPickerListRow: View { !model.isAvailable && providerFamilyKey(model.provider) == "claude" && onClaudeLogin != nil } + private var showsPiLoginAction: Bool { + !model.isAvailable + && (providerFamilyKey(model.provider) == "pi" || model.id.lowercased().hasPrefix("pi/")) + && onPiLogin != nil + } + + private var piContextLabel: String? { + workPiModelContextLabel(for: model) + } + var body: some View { VStack(alignment: .leading, spacing: 0) { Button { @@ -1212,6 +1351,10 @@ struct ModelPickerListRow: View { claudeLoginButton .padding(.top, style == .detailed ? 7 : 5) } + if showsPiLoginAction { + piLoginButton + .padding(.top, style == .detailed ? 7 : 5) + } } .padding(.horizontal, style == .compact ? 10 : 11) .padding(.vertical, style == .compact ? 7 : 8) @@ -1245,10 +1388,16 @@ struct ModelPickerListRow: View { .font(.subheadline.weight(.semibold)) .foregroundStyle(model.isAvailable ? ADEColor.textPrimary : ADEColor.textMuted) .lineLimit(1) + if let piContextLabel { + Text(piContextLabel) + .font(.caption2.weight(.medium)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } } Spacer(minLength: 4) } - .accessibilityLabel("\(model.displayName)\(isSelected ? ". Selected." : "")") + .accessibilityLabel(modelAccessibilityLabel) } @ViewBuilder @@ -1265,11 +1414,28 @@ struct ModelPickerListRow: View { .font(.subheadline.weight(.semibold)) .foregroundStyle(model.isAvailable ? ADEColor.textPrimary : ADEColor.textMuted) .lineLimit(1) + if let piContextLabel { + Text(piContextLabel) + .font(.caption2.weight(.medium)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } } Spacer(minLength: 6) favoriteButton } - .accessibilityLabel("\(model.displayName)\(isSelected ? ". Selected." : "")") + .accessibilityLabel(modelAccessibilityLabel) + } + + private var modelAccessibilityLabel: String { + [ + model.displayName, + piContextLabel, + model.isAvailable ? "Available" : "Unavailable", + isSelected ? "Selected" : nil, + ] + .compactMap { $0 } + .joined(separator: ". ") } @ViewBuilder @@ -1304,6 +1470,38 @@ struct ModelPickerListRow: View { .accessibilityLabel("Login to Claude") } + @ViewBuilder + private var piLoginButton: some View { + Button { + onPiLogin?() + } label: { + HStack(spacing: 6) { + if isPiLoginBusy { + ProgressView() + .controlSize(.small) + .tint(ADEColor.textPrimary) + } else { + Image(systemName: "terminal.fill") + .font(.caption.weight(.semibold)) + } + Text("Open Pi /login") + .font(.caption.weight(.bold)) + .lineLimit(1) + } + .foregroundStyle(ADEColor.textPrimary) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(providerTint("pi").opacity(0.18), in: Capsule()) + .overlay( + Capsule(style: .continuous) + .stroke(providerTint("pi").opacity(0.28), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .disabled(isPiLoginBusy) + .accessibilityLabel("Open Pi native login") + } + @ViewBuilder private var favoriteButton: some View { Button { @@ -1430,6 +1628,12 @@ struct ModelPickerCurrentModelBar: View { .font(.caption.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) + if let model, let piContextLabel = workPiModelContextLabel(for: model) { + Text(piContextLabel) + .font(.caption2.weight(.medium)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } } Spacer(minLength: 6) @@ -1458,7 +1662,16 @@ struct ModelPickerCurrentModelBar: View { ) } .accessibilityElement(children: .combine) - .accessibilityLabel("Current model \(model?.displayName ?? "none"), reasoning \(reasoningLabel), \(fastModeAccessibilityLabel)") + .accessibilityLabel( + [ + "Current model \(model?.displayName ?? "none")", + model.flatMap(workPiModelContextLabel(for:)), + "reasoning \(reasoningLabel)", + fastModeAccessibilityLabel, + ] + .compactMap { $0 } + .joined(separator: ", ") + ) } private var fastModeStatusLabel: String { diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 6b808e2202..30e1bb59f4 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -188,7 +188,7 @@ struct WorkPendingQuestionModel: Identifiable, Equatable { /// Shared provider-display-name mapping for chat-surface card headers, mirroring /// the desktop redesign's `chatSurfaceProviderName`: /// claude/anthropic → "Claude", codex/openai → "Codex", cursor → "Cursor", -/// droid/factory → "Droid", opencode → "OpenCode", else Title-case the source. +/// droid/factory → "Droid", opencode → "OpenCode", pi → "Pi", else Title-case the source. /// Distinct from `providerLabel(_:)` (which says "Anthropic" / "Cursor Composer" /// / "OpenAI") so the question/plan header verbs read with the short brand name. func workChatSurfaceProviderName(_ source: String?) -> String { @@ -200,6 +200,7 @@ func workChatSurfaceProviderName(_ source: String?) -> String { case "cursor": return "Cursor" case "droid", "factory": return "Droid" case "opencode": return "OpenCode" + case "pi": return "Pi" case "ade": return "ADE" default: return raw diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 4e1a8071ee..0accf9b0e2 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -490,7 +490,7 @@ private func workPriorityLaneNamingWords(cleanedPrompt: String) -> [String] { .replacingOccurrences(of: #"[^a-z0-9]+"#, with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty else { return [] } - let provider = ["claude", "codex", "cursor", "droid", "opencode"].first { + let provider = ["claude", "codex", "cursor", "droid", "opencode", "pi"].first { workRegexContains(normalized, pattern: #"\b\#($0)\b"#) } ?? (workRegexContains(normalized, pattern: #"\bopen code\b"#) ? "opencode" : nil) guard let provider else { return [] } @@ -824,7 +824,7 @@ struct WorkNewChatScreen: View { } .onChange(of: provider) { _, newProvider in runtimeMode = workDefaultRuntimeMode(provider: newProvider) - if !workNewChatModel(modelId, belongsTo: workNormalizedNewChatProvider(newProvider)) { + if !workNewChatModel(modelId, belongsTo: workNormalizedChatProvider(newProvider)) { modelId = workDefaultNewChatModelId(provider: newProvider) } if !modelSupportsReasoning(modelId: modelId, provider: newProvider) { @@ -864,7 +864,7 @@ struct WorkNewChatScreen: View { selectedModelOption = option modelId = option.id provider = sessionMode == .chat - ? workNormalizedNewChatProvider(runtimeProvider) + ? workNormalizedChatProvider(runtimeProvider) : workResolveCliProvider(for: option.id, provider: runtimeProvider) reasoningEffort = pickedReasoning ?? "" runtimeMode = workDefaultRuntimeMode(provider: provider) @@ -1088,6 +1088,12 @@ struct WorkNewChatScreen: View { busy = true errorMessage = nil let wire = workRuntimeWireFields(provider: provider, mode: runtimeMode) + let piMetadata = workResolvedPiModelMetadata( + modelId: modelId, + profileId: selectedModelOption?.piProfileId, + providerId: selectedModelOption?.piProviderId, + piModelId: selectedModelOption?.piModelId + ) let normalizedReasoning = reasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines) // Resolve the target lane. When auto-create is selected we mint a fresh @@ -1209,6 +1215,9 @@ struct WorkNewChatScreen: View { // user's choice (including an explicit OFF) is honored rather than // falling back to the host default; nil only when fast mode is N/A. codexFastMode: fastModeSupported ? codexFastMode : nil, + piProfileId: piMetadata?.profileId, + piProviderId: piMetadata?.providerId, + piModelId: piMetadata?.modelId, permissionMode: wire.permissionMode, interactionMode: wire.interactionMode, claudePermissionMode: wire.claudePermissionMode, @@ -1362,10 +1371,10 @@ struct WorkNewChatScreen: View { let replacement = workDefaultModelIdForAvailabilityMode(preferredProvider: provider, mode: availabilityMode) { modelId = replacement.modelId provider = mode == .chat - ? workNormalizedNewChatProvider(replacement.provider) + ? workNormalizedChatProvider(replacement.provider) : workResolveCliProvider(for: replacement.modelId, provider: replacement.provider) } else if mode == .chat { - provider = workNormalizedNewChatProvider(provider) + provider = workNormalizedChatProvider(provider) if !workNewChatModel(modelId, belongsTo: provider) { modelId = workDefaultNewChatModelId(provider: provider) } @@ -1382,16 +1391,6 @@ struct WorkNewChatScreen: View { } } -private func workNormalizedNewChatProvider(_ provider: String) -> String { - let family = providerFamilyKey(provider) - // Droid (Factory) is a first-class in-app chat runtime, and its Droid Core - // models (GLM / Kimi / MiniMax) only run under the droid provider — desktop - // and the TUI already derive provider from the model's family. Without droid - // in this allowlist, picking a Droid Core model silently collapsed the - // provider to "claude", sending a GLM model id to the Claude runtime. - return ["claude", "codex", "cursor", "opencode", "droid"].contains(family) ? family : "claude" -} - private func workNewChatModel(_ modelId: String, belongsTo provider: String) -> Bool { let trimmed = modelId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } @@ -1403,17 +1402,18 @@ private func workDefaultNewChatModelId(provider: String) -> String { if let defaultModel = workDefaultCatalogModelId(provider: family) { return defaultModel } - switch workNormalizedNewChatProvider(provider) { + switch workNormalizedChatProvider(provider) { case "codex": return workDefaultCatalogModelId(provider: "codex") ?? "gpt-5.6-sol" case "cursor": return "auto" case "opencode": return "opencode/anthropic/claude-sonnet-5" + case "pi": return "" default: return "claude-sonnet-5" } } private func workCliSupportsReasoningSelection(provider: String) -> Bool { let family = providerFamilyKey(provider) - return family == "claude" || family == "codex" || family == "droid" + return family == "claude" || family == "codex" || family == "droid" || family == "pi" } private func workCliInitialSessionTitle(provider: String, opener: String) -> String { @@ -1449,6 +1449,7 @@ private func workCliToolType(provider: String) -> String { case "codex": return "codex" case "cursor": return "cursor-cli" case "opencode": return "opencode" + case "pi": return "pi" case "droid": return "droid" case "shell": return "shell" default: return "opencode" diff --git a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift index d2df2de942..c2092b461d 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift @@ -65,6 +65,13 @@ struct WorkNewChatSheet: View { icon: providerIcon("codex"), tint: providerTint("codex") ), + WorkProviderOption( + id: "pi", + title: "Pi", + subtitle: "Pi-native models and sessions", + icon: providerIcon("pi"), + tint: providerTint("pi") + ), WorkProviderOption( id: "cursor", title: "Cursor", @@ -468,6 +475,12 @@ struct WorkNewChatSheet: View { } do { busy = true + let piMetadata = workResolvedPiModelMetadata( + modelId: selectedModelId, + profileId: selectedModel?.piProfileId, + providerId: selectedModel?.piProviderId, + piModelId: selectedModel?.piModelId + ) let summary = try await syncService.createChatSession( laneId: selectedLaneId, provider: provider, @@ -476,7 +489,10 @@ struct WorkNewChatSheet: View { guard !selectedReasoningEffort.isEmpty else { return nil } guard workVisibleReasoningEfforts(for: selectedModel).contains(where: { $0.effort == selectedReasoningEffort }) else { return nil } return selectedReasoningEffort - }() + }(), + piProfileId: piMetadata?.profileId, + piProviderId: piMetadata?.providerId, + piModelId: piMetadata?.modelId ) await onCreated(WorkDraftChatSession(summary: summary, initialMessage: openingMessage)) dismiss() diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift index f567e0f1f7..899824b435 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift @@ -117,6 +117,12 @@ extension WorkSessionSettingsSheet { permissionMode = wire.permissionMode cursorModeId = wire.cursorModeId } + case "pi": + if selectedRuntimeMode != initialRuntimeMode { + runtimeChanged = true + let wire = workRuntimeWireFields(provider: summary.provider, mode: selectedRuntimeMode) + permissionMode = wire.permissionMode + } default: break } diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift index c6769de6e1..26b8581801 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift @@ -355,6 +355,13 @@ private func workRuntimeModeSubtitle(provider: String, mode: String) -> String { case "agi": return "Droid orchestrator mode." default: return "Switch this session to \(mode.capitalized)." } + case "pi": + switch mode { + case "default", "plan", "read-only": return "Read-only Pi tools; review changes before enabling access." + case "edit": return "Allow Pi to edit and write files." + case "full-auto": return "Allow Pi's full native tool set without prompts." + default: return "Switch this session to \(mode.capitalized)." + } default: return "Switch this session to \(mode.capitalized)." } diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index ed60b3a3a2..98b010a227 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -237,6 +237,8 @@ func defaultWorkChatTitle(provider: String) -> String { switch provider.lowercased() { case "codex": return "Codex chat" + case "pi": + return "Pi chat" case "opencode": return "OpenCode chat" case "cursor": @@ -249,6 +251,7 @@ func defaultWorkChatTitle(provider: String) -> String { func toolTypeForProvider(_ provider: String) -> String { switch provider.lowercased() { case "codex": return "codex-chat" + case "pi": return "pi-chat" case "opencode": return "opencode-chat" case "cursor": return "cursor" default: return "claude-chat" @@ -264,6 +267,7 @@ func providerLabel(_ provider: String) -> String { case "opencode": return "OpenCode" case "cursor": return "Cursor Composer" case "droid", "factory": return "Droid" + case "pi": return "Pi" case "google": return "Google" case "ollama": return "Ollama" case "lmstudio": return "LM Studio" @@ -285,6 +289,7 @@ func shortProviderLabel(_ toolType: String?) -> String { } if raw.hasPrefix("claude") { return "Claude" } if raw.hasPrefix("codex") { return "Codex" } + if raw == "pi" || raw.hasPrefix("pi-") || raw.hasPrefix("pi/") { return "Pi" } if raw.hasPrefix("opencode") { return "OpenCode" } return raw.replacingOccurrences(of: "-", with: " ").capitalized } @@ -311,6 +316,8 @@ func providerIcon(_ provider: String) -> String { return "sparkle" case "opencode": return "hammer.fill" + case "pi": + return "terminal.fill" case "cursor": return "cursorarrow" case "droid", "factory": @@ -369,6 +376,8 @@ func workRailLogoProvider(for catalogGroupKey: String) -> String { return "droid" case "opencode": return "opencode" + case "pi": + return "pi" case "ollama": return "ollama" case "lmstudio": @@ -448,6 +457,38 @@ func workModelRowLogoProvider(for model: WorkModelOption, catalogGroupKey: Strin return "opencode" } + // Pi is a runtime group, but its model rows retain the upstream provider + // brand from the canonical `pi///` id. Keep the + // Pi rail distinct while still making OpenAI/Anthropic/Google models easy to + // recognize in the detailed row. + if group == "pi" || modelId.hasPrefix("pi/") { + let piProvider = model.piProviderId + ?? workPiModelMetadata(for: model.id)?.providerId + if let piProvider { + switch providerFamilyKey(piProvider) { + case "claude": return "claude" + case "codex": return "codex" + case "google": return "google" + default: break + } + } + let parts = modelId.split(separator: "/", omittingEmptySubsequences: true) + if parts.count >= 3 { + switch String(parts[2]) { + case "anthropic": return "claude" + case "openai", "openai-codex": return "codex" + case "google", "google-gemini-cli", "google-antigravity": return "google" + default: break + } + } + switch providerFamilyKey(model.provider) { + case "claude": return "claude" + case "codex": return "codex" + case "google": return "google" + default: return "pi" + } + } + return model.provider } @@ -460,6 +501,8 @@ func providerTint(_ provider: String?) -> Color { return .blue case "opencode": return .teal + case "pi": + return .orange case "cursor": return .indigo case "droid": @@ -487,6 +530,12 @@ func providerFamilyKey(_ provider: String) -> String { if raw == "openai" || raw.hasPrefix("codex") { return "codex" } + if raw == "openai-codex" { + return "codex" + } + if raw == "pi" || raw.hasPrefix("pi/") || raw.hasPrefix("pi-") { + return "pi" + } if raw.hasPrefix("opencode") { return "opencode" } @@ -499,6 +548,15 @@ func providerFamilyKey(_ provider: String) -> String { return raw } +/// Collapse a free-form provider key to a chat-capable runtime family. +/// Routed Pi models must stay on Pi rather than falling through to Claude. +func workNormalizedChatProvider(_ provider: String) -> String { + let family = providerFamilyKey(provider) + return ["claude", "codex", "cursor", "opencode", "droid", "pi"].contains(family) + ? family + : "claude" +} + func sessionSymbol(_ session: TerminalSessionSummary, provider: String?) -> String { if isChatSession(session) { return providerIcon(provider ?? session.toolType ?? "") @@ -677,6 +735,12 @@ func workRuntimeModeOptions(provider: String) -> [WorkRuntimeModeOption] { WorkRuntimeModeOption(id: "auto-high", title: "Auto high"), WorkRuntimeModeOption(id: "agi", title: "AGI"), ] + case "pi": + return [ + WorkRuntimeModeOption(id: "default", title: "Read-only"), + WorkRuntimeModeOption(id: "edit", title: "Edit access"), + WorkRuntimeModeOption(id: "full-auto", title: "Full access"), + ] default: return [] } @@ -724,6 +788,13 @@ func workRuntimeModeLabel(provider: String, mode: String) -> String { case "agi": return "AGI" default: return "Auto low" } + case "pi": + switch mode { + case "edit": return "Edit access" + case "full-auto": return "Full access" + case "plan", "read-only", "default": return "Read-only" + default: return "Read-only" + } default: return mode.isEmpty ? "Access" : mode.capitalized } @@ -766,6 +837,12 @@ func workRuntimeModeTint(provider: String, mode: String) -> Color { case "agi": return ADEColor.purpleAccent default: return ADEColor.success } + case "pi": + switch mode { + case "full-auto": return ADEColor.danger + case "edit": return ADEColor.warning + default: return ADEColor.success + } default: return workRuntimeModeTint(mode) } @@ -788,6 +865,7 @@ func workRuntimeModeTint(_ mode: String) -> Color { func workDefaultRuntimeMode(provider: String) -> String { switch provider.lowercased() { case "claude", "codex": return "default" + case "pi": return "default" case "opencode": return "edit" case "cursor": return "default" case "droid", "factory": return "auto-low" @@ -902,6 +980,20 @@ func workRuntimeWireFields(provider: String, mode: String) -> WorkRuntimeWireFie fields.droidPermissionMode = "auto-low" fields.permissionMode = "edit" } + case "pi": + switch mode { + case "edit": + fields.permissionMode = "edit" + case "full-auto": + fields.permissionMode = "full-auto" + case "plan": + fields.permissionMode = "plan" + default: + // Pi's default SDK tool allowlist is read-only. Keep the generic + // permission field so older hosts can apply the same policy without a + // provider-specific iOS wire field. + fields.permissionMode = "default" + } default: break } @@ -919,13 +1011,17 @@ func modelSupportsReasoning(modelId: String, provider: String) -> Bool { case "codex": return true case "claude": return lower.contains("opus") || lower.contains("sonnet") + case "pi": + // Pi exposes the complete thinking-level menu through its SDK/CLI runtime; + // the picker still limits the visible values to the host-advertised tiers. + return true default: return false } } func workInitialRuntimeMode(_ summary: AgentChatSessionSummary) -> String { - switch summary.provider { + switch providerFamilyKey(summary.provider) { case "claude": if summary.interactionMode == "plan" || summary.permissionMode == "plan" { return "plan" @@ -968,6 +1064,12 @@ func workInitialRuntimeMode(_ summary: AgentChatSessionSummary) -> String { droidPermissionMode: summary.droidPermissionMode, permissionMode: summary.permissionMode ) ?? "auto-low" + case "pi": + switch summary.permissionMode { + case "edit": return "edit" + case "full-auto": return "full-auto" + default: return "default" + } default: return "" } diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 64f79aebab..4ae1a766e1 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -3429,9 +3429,13 @@ private func workTurnModelMetadata( let rawModelId = [modelId, model] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .first { !$0.isEmpty } + let displayModel = [modelId, model] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { workPiModelMetadata(for: $0) != nil } + ?? rawModel return WorkTurnModelMetadata( provider: workModelCatalogGroupKey(for: rawModelId ?? rawModel, currentProvider: fallbackProvider), - modelLabel: rawModel.isEmpty ? fallbackModelLabel : prettyWorkChatModelName(rawModel), + modelLabel: displayModel.isEmpty ? fallbackModelLabel : prettyWorkChatModelName(displayModel), modelId: rawModelId ?? fallbackModelId ) } @@ -3442,6 +3446,22 @@ private func workTurnModelMetadata( func prettyWorkChatModelName(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "Model" } + if let piMetadata = workPiModelMetadata(for: trimmed) { + return workPiModelDisplayName(piMetadata) + } + return prettyWorkChatBaseModelName(trimmed) +} + +/// Readable model identity for Pi's routed ids. Keep the runtime visible as +/// "Pi" in the surrounding UI, while this label carries the upstream provider +/// and profile so two profiles never look like the same model. +func workPiModelDisplayName(_ metadata: WorkPiModelMetadata) -> String { + let modelLabel = prettyWorkChatBaseModelName(metadata.modelId) + let context = "\(workPiProviderLabel(metadata.providerId)) · \(metadata.profileId)" + return "\(modelLabel) · \(context)" +} + +private func prettyWorkChatBaseModelName(_ trimmed: String) -> String { if let known = workKnownModelDisplayName(trimmed) { return known } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index f0665ba45e..ea981c8597 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1381,6 +1381,10 @@ final class ADETests: XCTestCase { XCTAssertEqual(workRuntimeModeOptions(provider: "cursor").map(\.title), ["Agent", "Plan", "Ask", "Full auto"]) XCTAssertEqual(workRuntimeModeLabel(provider: "cursor", mode: "full-auto"), "Full auto") XCTAssertEqual(workRuntimeModeOptions(provider: "droid").map(\.id), ["read-only", "auto-low", "auto-medium", "auto-high", "agi"]) + XCTAssertEqual(workRuntimeModeOptions(provider: "pi").map(\.id), ["default", "edit", "full-auto"]) + XCTAssertEqual(workRuntimeModeOptions(provider: "pi").map(\.title), ["Read-only", "Edit access", "Full access"]) + XCTAssertEqual(workRuntimeModeLabel(provider: "pi", mode: "default"), "Read-only") + XCTAssertEqual(workRuntimeModeLabel(provider: "pi", mode: "full-auto"), "Full access") let claudeAuto = workRuntimeWireFields(provider: "claude", mode: "auto") XCTAssertEqual(claudeAuto.permissionMode, "auto") @@ -1418,6 +1422,20 @@ final class ADETests: XCTestCase { XCTAssertEqual(droidAgi.droidPermissionMode, "agi") XCTAssertEqual(workDroidRuntimeMode(droidPermissionMode: "agi", permissionMode: "plan"), "agi") XCTAssertEqual(workDroidModeFromPermissionMode("edit"), "auto-low") + + let piReadOnly = workRuntimeWireFields(provider: "pi", mode: "default") + XCTAssertEqual(piReadOnly.permissionMode, "default") + XCTAssertNil(piReadOnly.claudePermissionMode) + XCTAssertNil(piReadOnly.opencodePermissionMode) + + let piEdit = workRuntimeWireFields(provider: "pi", mode: "edit") + XCTAssertEqual(piEdit.permissionMode, "edit") + XCTAssertEqual(workInitialRuntimeMode(makeAgentChatSessionSummary( + provider: "pi", + model: "pi/work/openai-codex/gpt-5.4", + status: "active", + permissionMode: "full-auto" + )), "full-auto") } func testResolvedWorkArchivedSessionIdsKeepsLocalOverrideForKnownChat() { @@ -19369,6 +19387,229 @@ final class ADETests: XCTestCase { XCTAssertEqual(workResolveCliProvider(for: "gpt-5.5", provider: "codex"), "codex") XCTAssertEqual(workResolveCliProvider(for: "auto", provider: "cursor"), "cursor") XCTAssertEqual(workResolveCliProvider(for: "opencode/anthropic/claude-sonnet-5", provider: "opencode"), "opencode") + let piModelId = "pi/work/openai-codex/gpt-5.4" + XCTAssertEqual(workModelCatalogGroupKey(for: piModelId, currentProvider: "claude"), "pi") + XCTAssertEqual(workResolveCliProvider(for: piModelId, provider: "pi"), "pi") + XCTAssertEqual(workComposerRuntimeProvider(forModelId: piModelId, currentProvider: "pi"), "pi") + XCTAssertEqual(workNormalizedChatProvider("pi"), "pi") + XCTAssertEqual(workNormalizedChatProvider("pi/work/openai-codex"), "pi") + XCTAssertEqual(providerFamilyKey("openai-codex"), "codex") + XCTAssertEqual(providerLabel("pi"), "Pi") + XCTAssertEqual(defaultWorkChatTitle(provider: "pi"), "Pi chat") + XCTAssertEqual(toolTypeForProvider("pi"), "pi-chat") + XCTAssertEqual(shortProviderLabel("pi-chat"), "Pi") + XCTAssertEqual(workChatSurfaceProviderName("pi"), "Pi") + XCTAssertEqual(workPiModelMetadata(for: piModelId), WorkPiModelMetadata( + profileId: "work", + providerId: "openai-codex", + modelId: "gpt-5.4" + )) + XCTAssertEqual(workPiModelMetadata(for: "pi/work/openai-codex/gpt-5.4%2Fpreview"), WorkPiModelMetadata( + profileId: "work", + providerId: "openai-codex", + modelId: "gpt-5.4/preview" + )) + } + + func testPiHostCatalogKeepsCanonicalAndRuntimeModelMetadata() { + let canonicalId = "pi/work/openai-codex/gpt-5.4" + let runtimeModelId = "openai-codex/gpt-5.4" + let model = AgentChatModelCatalogModel( + id: canonicalId, + runtimeModelId: runtimeModelId, + provider: "pi", + providerKey: "openai-codex", + groupKey: "pi", + displayName: "GPT-5.4", + description: "Pi-routed Codex model", + isDefault: true, + reasoningEfforts: [ + AgentChatModelReasoningEffort(effort: "medium", description: "Balanced") + ], + defaultReasoningEffort: "medium", + serviceTiers: nil, + aliases: nil, + maxThinkingTokens: nil, + modelId: runtimeModelId, + family: "openai-codex", + supportsReasoning: true, + supportsTools: true, + cursorAvailability: nil, + color: nil, + isAvailable: true, + connected: true, + requiresConfiguration: false, + sourceRuntime: "pi", + providerId: "openai-codex", + providerName: "OpenAI Codex", + stale: false, + piProfileId: "work", + piProviderId: "openai-codex", + piModelId: "gpt-5.4" + ) + let catalog = AgentChatModelCatalog( + groups: [ + AgentChatModelCatalogGroup( + key: "pi", + displayName: "Pi", + providers: [ + AgentChatModelCatalogProvider( + key: "openai-codex", + displayName: "OpenAI Codex", + badgeColor: "#10A37F", + modelCount: 1, + subsections: [ + AgentChatModelCatalogSubsection( + key: "__piprov__:work:openai-codex", + label: "OpenAI Codex · work", + models: [model] + ) + ] + ) + ] + ) + ], + fetchedAt: "2026-08-08T00:00:00.000Z", + stale: false + ) + + let groups = workModelCatalogGroups( + hostCatalog: catalog, + currentModelId: canonicalId, + currentProvider: "pi" + ) + let option = groups + .first(where: { $0.key == "pi" })? + .providers + .first(where: { $0.key == workPiProviderSectionKey(profileId: "work", providerId: "openai-codex") })? + .models + .first + + XCTAssertEqual(option?.id, canonicalId) + XCTAssertEqual(option?.runtimeModelId, runtimeModelId) + XCTAssertEqual(option?.piProfileId, "work") + XCTAssertEqual(option?.piProviderId, "openai-codex") + XCTAssertEqual(option?.piModelId, "gpt-5.4") + XCTAssertEqual(option?.provider, "pi") + XCTAssertEqual(option.flatMap { workPiModelContextLabel(for: $0) }, "OpenAI Codex · work") + XCTAssertEqual(workModelRowLogoProvider(for: option!, catalogGroupKey: "pi"), "codex") + + let dynamicGroups = workModelCatalogGroups( + availableModelsByProvider: [ + "pi": [ + AgentChatModelInfo( + id: canonicalId, + displayName: "GPT-5.4", + description: "GPT-5.4 (Pi SDK)", + isDefault: true, + reasoningEfforts: nil, + serviceTiers: nil, + maxThinkingTokens: nil, + modelId: canonicalId, + family: "pi", + supportsReasoning: true, + supportsTools: true, + color: nil + ), + ], + ], + currentModelId: canonicalId, + currentProvider: "pi" + ) + let dynamicOption = dynamicGroups + .first(where: { $0.key == "pi" })? + .providers + .first(where: { $0.key == workPiProviderSectionKey(profileId: "work", providerId: "openai-codex") })? + .models + .first + XCTAssertEqual(dynamicOption?.id, canonicalId) + XCTAssertEqual(dynamicOption?.runtimeModelId, runtimeModelId) + XCTAssertEqual(dynamicOption?.provider, "pi") + XCTAssertEqual(dynamicOption?.piModelId, "gpt-5.4") + } + + func testPiHostCatalogKeepsProfilesInDistinctReadablePickerTabs() { + func model(profile: String) -> AgentChatModelCatalogModel { + let canonicalId = "pi/\(profile)/openai-codex/gpt-5.4" + return AgentChatModelCatalogModel( + id: canonicalId, + runtimeModelId: "openai-codex/gpt-5.4", + provider: "pi", + providerKey: "openai-codex", + groupKey: "pi", + displayName: "GPT-5.4", + description: nil, + isDefault: false, + reasoningEfforts: nil, + defaultReasoningEffort: nil, + serviceTiers: nil, + aliases: nil, + maxThinkingTokens: nil, + modelId: "openai-codex/gpt-5.4", + family: "openai-codex", + supportsReasoning: true, + supportsTools: true, + cursorAvailability: nil, + color: nil, + isAvailable: true, + connected: true, + requiresConfiguration: false, + sourceRuntime: "pi", + providerId: "openai-codex", + providerName: "OpenAI Codex", + stale: false, + piProfileId: profile, + piProviderId: "openai-codex", + piModelId: "gpt-5.4" + ) + } + + let catalog = AgentChatModelCatalog( + groups: [ + AgentChatModelCatalogGroup( + key: "pi", + displayName: "Pi", + providers: [ + AgentChatModelCatalogProvider( + key: "openai-codex", + displayName: "OpenAI Codex", + badgeColor: "#10A37F", + modelCount: 2, + subsections: [ + AgentChatModelCatalogSubsection( + key: workPiProviderSectionKey(profileId: "work", providerId: "openai-codex"), + label: "OpenAI Codex · work", + models: [model(profile: "work")] + ), + AgentChatModelCatalogSubsection( + key: workPiProviderSectionKey(profileId: "personal", providerId: "openai-codex"), + label: "OpenAI Codex · personal", + models: [model(profile: "personal")] + ), + ] + ), + ] + ), + ], + fetchedAt: "2026-08-08T00:00:00.000Z", + stale: false + ) + + let piGroup = workModelCatalogGroups( + hostCatalog: catalog, + currentModelId: "", + currentProvider: "pi" + ).first(where: { $0.key == "pi" }) + + XCTAssertEqual( + piGroup?.providers.map(\.key), + [ + workPiProviderSectionKey(profileId: "work", providerId: "openai-codex"), + workPiProviderSectionKey(profileId: "personal", providerId: "openai-codex"), + ] + ) + XCTAssertEqual(piGroup?.providers.map(\.displayName), ["OpenAI Codex · work", "OpenAI Codex · personal"]) + XCTAssertEqual(piGroup?.providers.compactMap { $0.models.first?.piProfileId }, ["work", "personal"]) } func testWorkModelCatalogTreatsCodexRuntimeAndRegistryIdsAsSameModel() { @@ -19407,6 +19648,26 @@ final class ADETests: XCTestCase { XCTAssertEqual(prettyWorkChatModelName("openai/gpt-5.5"), "GPT-5.5") } + func testPiModelDisplayIncludesProfileAndUpstreamProvider() { + let canonicalId = "pi/work/openai-codex/gpt-5.4" + XCTAssertEqual( + prettyWorkChatModelName(canonicalId), + "GPT-5.4 · OpenAI Codex · work" + ) + XCTAssertEqual( + workPiModelDisplayName(WorkPiModelMetadata( + profileId: "personal", + providerId: "anthropic", + modelId: "claude-opus-5" + )), + "Claude Opus 5 · Anthropic · personal" + ) + XCTAssertEqual( + workPiProviderSectionKey(profileId: "team/work", providerId: "openai-codex"), + "__piprov__:team%2Fwork:openai-codex" + ) + } + func testWorkModelCatalogMapsCurrentAndMigratedOpusAliases() { XCTAssertTrue(workModelIdsEquivalent("opus", "claude-opus-5")) XCTAssertTrue(workModelIdsEquivalent("anthropic/claude-opus-5-api", "claude-opus-5"))