From ca7ad20873f646dcb90566564ade03ee88fb8516 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:26:46 -0400 Subject: [PATCH 1/6] feat(pi): in-app sign-in, ask_user + approval gating, extension UI bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes Pi a first-class chat provider rather than a runtime with rough edges. Sign-in now runs in ADE. A dedicated worker drives Pi's native ModelRuntime.login, and its prompts — auth URL, device code, text, secret, select — render as ADE cards. Pi's own AuthStorage still owns auth.json; ADE never reads, stores, or logs a credential. The terminal /login path stays as a secondary fallback. Pi can now ask the user a question mid-turn via an ask_user tool, and bash, edit, and write are rebuilt from Pi's own definition factories behind an approval card. ADE's default permission mode therefore means "ask before changing things" instead of silently withholding bash. The user's Pi extensions load in chat, bound to a limited UI bridge: select/confirm/input become cards, notify/setStatus become notices, and terminal-only widgets no-op with a one-time warning. The worker pins Pi's projectTrusted to false, so only the user's own profile extensions load and a checked-out repository's .pi/extensions is never executed. Extensions stay off in plan mode, whose read-only promise cannot survive dropping Pi's allowlist. Also fixes two defects shipped by the previous Pi PR: the iOS target did not compile (two Swift type-inference errors), and Pi turns were attributed to the catch-all analytics provider instead of Pi. Co-Authored-By: Claude Opus 5 --- apps/ade-cli/README.md | 25 + apps/ade-cli/src/adeRpcServer.test.ts | 39 ++ apps/ade-cli/src/cli.test.ts | 23 + apps/ade-cli/src/cli.ts | 30 +- .../__tests__/piSdk.integration.test.ts | 198 +++++- .../src/main/services/adeActions/registry.ts | 73 +- .../main/services/ai/piAuthService.test.ts | 314 +++++++++ .../src/main/services/ai/piAuthService.ts | 332 +++++++++ .../services/analytics/dailyUsageAnalytics.ts | 3 + .../analytics/productAnalyticsPolicy.ts | 2 +- .../analytics/productAnalyticsService.test.ts | 24 + .../main/services/chat/agentChatService.ts | 149 +++- .../services/chat/piSdkEventMapper.test.ts | 117 ++++ .../main/services/chat/piSdkEventMapper.ts | 142 +++- .../src/main/services/chat/piSdkPool.ts | 59 +- .../main/services/chat/piSdkProtocol.test.ts | 69 ++ .../src/main/services/chat/piSdkProtocol.ts | 214 +++++- .../main/services/chat/piSdkUiBridge.test.ts | 276 ++++++++ .../src/main/services/chat/piSdkUiBridge.ts | 640 ++++++++++++++++++ .../src/main/services/chat/piSdkWorker.ts | 343 +++++++++- .../src/main/services/ipc/ipcTimeouts.test.ts | 14 + .../src/main/services/ipc/ipcTimeouts.ts | 7 + .../src/main/services/ipc/registerIpc.ts | 63 ++ .../localRuntime/localRuntimeTimeoutPolicy.ts | 8 + apps/desktop/src/preload/global.d.ts | 15 + apps/desktop/src/preload/preload.ts | 68 ++ apps/desktop/src/renderer/browserMock.ts | 5 + .../settings/ProvidersSection.test.tsx | 295 +++++++- .../components/settings/ProvidersSection.tsx | 524 ++++++++++++-- .../components/terminals/cliLaunch.test.ts | 51 ++ .../src/renderer/webclient/adapter/misc.ts | 71 +- apps/desktop/src/shared/cliLaunch.ts | 33 +- apps/desktop/src/shared/ipc.ts | 5 + apps/desktop/src/shared/types/chat.ts | 2 +- apps/desktop/src/shared/types/config.ts | 51 ++ .../PersonalChats/PersonalChatsScreen.swift | 8 +- .../Work/WorkErrorAndMessageHelpers.swift | 69 +- .../ios/ADE/Views/Work/WorkModelCatalog.swift | 4 +- apps/ios/ADE/Views/Work/WorkModels.swift | 4 + .../Views/Work/WorkPlanComposerViews.swift | 22 +- .../Work/WorkStatusAndFormattingHelpers.swift | 16 +- apps/ios/ADETests/ADETests.swift | 217 +++++- docs/ARCHITECTURE.md | 12 +- docs/features/ade-code/README.md | 2 +- docs/features/agents/README.md | 12 + docs/features/chat/README.md | 101 +++ docs/features/chat/agent-routing.md | 90 ++- docs/features/chat/tool-system.md | 37 + .../onboarding-and-settings/README.md | 17 +- .../configuration-schema.md | 12 + docs/features/web-client/README.md | 12 +- 51 files changed, 4735 insertions(+), 184 deletions(-) create mode 100644 apps/desktop/src/main/services/ai/piAuthService.test.ts create mode 100644 apps/desktop/src/main/services/ai/piAuthService.ts create mode 100644 apps/desktop/src/main/services/chat/piSdkEventMapper.test.ts create mode 100644 apps/desktop/src/main/services/chat/piSdkUiBridge.test.ts create mode 100644 apps/desktop/src/main/services/chat/piSdkUiBridge.ts diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index f4005b7b6..6cfb9a959 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -609,6 +609,11 @@ ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade actions run pty.resumeSession --arg sessionId=session-id ade actions run external-sessions.list --input-json '{"scope":"project","limit":20}' --text # claude/codex/cursor/droid/opencode sessions on this machine; discovery that cannot run — `opencode` is not installed, say — fails the call when that provider is the only one asked for, rather than reporting an empty list; in a multi-provider scan it is skipped and logged ade actions run external-sessions.import --input-json '{"provider":"codex","sessionId":"thread-id","laneId":"lane-1","target":"cli","mode":"resume"}' --text +ade actions run ai.piLoginProviders --text # Pi providers that can be signed into, with the auth methods each accepts and whether it is already configured +ade --role cto actions run ai.piLoginStart --input-json '{"providerId":"anthropic"}' --json # blocks until the human finishes Pi's own OAuth/device-code flow +ade actions call stream_events --arg category=runtime --json # drain piAuthStatus prompts/notices raised by an in-flight sign-in +ade --role cto actions run ai.piLoginSubmit --input-json "$(jq -n --arg v "$PI_API_KEY" '{providerId:"anthropic",requestId:"req-1",value:$v}')" # answer a prompt; keep the value out of argv and shell history +ade --role cto actions run ai.piLoginCancel --input-json '{"providerId":"anthropic"}' ade cursor cloud agents list --text ade cursor cloud agents create --repo https://github.com/owner/repo --prompt "fix flaky test" --auto-pr ade --role cto github app-auth login # device-flow authorize the machine ADE GitHub App (headless/brain) @@ -635,6 +640,26 @@ stored PAT order. Writes skip the read-only GitHub App. `github.getStatus` reports the active read/write sources, per-credential failure/cooldown state, fallback details, and any background-refresh pause without exposing tokens. +Pi sign-in has no typed command, the same way OpenCode's `ai.opencodeOAuth*` +actions do not. `ai.piLoginStart` blocks until a human finishes Pi's own browser +or device-code flow, and any prompt it raises is answered by a *second* call +(`ai.piLoginSubmit`) carrying a `requestId` that only appears on the +`piAuthStatus` runtime event — so the workflow is two processes plus an event +drain, not one command. From a terminal the shorter path is `ade code`'s +`/login`, which drops into `pi` and uses Pi's native interactive sign-in. + +Three details when driving it through `ade actions run` anyway. The `piLogin` +start/submit/cancel actions are CTO-only, so they need `--role cto`; +`ai.piLoginProviders` is not. The two-process shape only works against a running +brain or desktop socket — a headless invocation builds its own runtime, so a +second `ade` process cannot see the first's in-flight sign-in. And +`ai.piLoginStart` carries its own transport floor (11 minutes, the same budget +the desktop client uses) so the CLI does not report a timeout while the daemon +is still waiting on the user; `--timeout-ms` still applies when it asks for +more. `ai.piLoginSubmit`'s `value` can be a raw API key: pass it through +`--input-json` built from an environment variable rather than typing it inline, +and never echo the result. + `ade tools` is deliberately not backed by a service action. The pinned-tool cache is a property of the machine's filesystem, not of a project runtime, so the command calls `src/services/tools/` in-process and works on a headless box with diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 756ae5e98..ec52e1ccf 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1100,6 +1100,45 @@ describe("adeRpcServer", () => { expect(runtime.ptyService.dispose).not.toHaveBeenCalled(); }); + it("exposes the Pi sign-in actions through ade actions, with the write half gated on cto", async () => { + // The CLI has no typed `pi login` command, so `ade actions run ai.piLogin*` + // is the only path to it. That makes these four names part of the CLI's + // contract: if the registry stops resolving them the escape hatch is gone. + const { runtime } = createRuntime(); + + const agentHandler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(agentHandler, { role: "agent" }); + const agentActions = await callTool(agentHandler, "list_ade_actions", { domain: "ai" }); + const agentNames = agentActions.structuredContent.actions.map( + (entry: { name: string }) => entry.name, + ); + expect(agentNames).toContain("ai.piLoginProviders"); + expect(agentNames).not.toContain("ai.piLoginStart"); + + // Rejected on the role gate, before the service is reached — an agent can + // never spawn a Pi worker waiting on a human that is not there. + const denied = await callTool(agentHandler, "run_ade_action", { + domain: "ai", + action: "piLoginStart", + args: { providerId: "anthropic" }, + }); + expect(denied.isError).toBe(true); + expect(JSON.stringify(denied.error)).toMatch(/elevated role/i); + + const ctoHandler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(ctoHandler, { callerId: "cto-1", role: "cto" }); + const ctoNames = (await callTool(ctoHandler, "list_ade_actions", { domain: "ai" })) + .structuredContent.actions.map((entry: { name: string }) => entry.name); + expect(ctoNames).toEqual( + expect.arrayContaining([ + "ai.piLoginProviders", + "ai.piLoginStart", + "ai.piLoginSubmit", + "ai.piLoginCancel", + ]), + ); + }); + it("routes app/navigate through the runtime navigation service", async () => { const { runtime } = createRuntime(); const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 })); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 99b25a34d..9a2c69602 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -46,6 +46,7 @@ import { DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, } from "../../desktop/src/shared/accountDirectory"; import { isAdeRuntimeNamedPipePath } from "../../desktop/src/shared/adeRuntimeIpc"; +import { PI_LOGIN_IPC_TIMEOUT_MS } from "../../desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { generateRpcAuthToken } from "./rpcAuth"; import { JsonRpcClient } from "./tuiClient/jsonRpcClient"; @@ -2572,6 +2573,28 @@ describe("ADE CLI", () => { ).toThrow(/account actions accept object input/); }); + it("gives a Pi sign-in the daemon's own transport budget instead of the CLI default", () => { + // ai.piLoginStart blocks on a human finishing Pi's OAuth/device-code flow. + // The default 10-minute request budget expires inside that window, so the + // plan has to carry the shared long-running floor or the CLI reports a + // timeout for a sign-in the daemon is still legitimately running. + const login = expectExecutePlan( + buildCliPlan(["actions", "run", "ai.piLoginStart", "--arg", "providerId=anthropic"]), + ); + expect(login.minTimeoutMs).toBe(PI_LOGIN_IPC_TIMEOUT_MS); + expect(login.minTimeoutMs!).toBeGreaterThan(parseCliArgs([]).options.timeoutMs); + + // Everything else keeps the default budget; the floor is table-driven, not + // a blanket raise. + expect( + expectExecutePlan(buildCliPlan(["actions", "run", "ai.piLoginProviders"])).minTimeoutMs, + ).toBeUndefined(); + expect( + expectExecutePlan(buildCliPlan(["actions", "run", "git.push", "--arg", "laneId=lane-1"])) + .minTimeoutMs, + ).toBeUndefined(); + }); + it("builds chat create with both model and modelId plus explicit reasoning and fast-mode args", () => { // This strict-equality assertion must not absorb the ambient parent // default when the test itself runs inside an ADE-tracked agent shell. diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 1066dd817..d18e4027d 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -55,6 +55,7 @@ import { readAutomationsEnvOverride, } from "../../desktop/src/shared/automationAvailability"; import { parseLinearGraphQLInput } from "../../desktop/src/main/services/cto/linearGraphQLInput"; +import { longRunningLocalRuntimeActionTimeoutMs } from "../../desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy"; import { browseProjectDirectories } from "../../desktop/src/main/services/projects/projectBrowserService"; import { createProjectScaffoldService } from "../../desktop/src/main/services/projects/projectScaffoldService"; import { resolveRepoRoot } from "../../desktop/src/main/services/projects/projectService"; @@ -324,6 +325,18 @@ type CliPlan = * account action is CTO-only, so it must connect as the machine operator. */ connectRole?: GlobalOptions["role"]; + /** + * Floor for this plan's request timeout, in milliseconds. `--timeout-ms` + * (and its 10-minute default) still applies when it is the larger of the + * two, so a caller can always ask for more patience but never less than + * the daemon's own budget for the action being run. + * + * Sourced from the shared long-running action table the desktop client + * uses, so the CLI cannot report a transport timeout while the daemon is + * still legitimately working — `ai.piLoginStart` blocks on a human + * finishing Pi's sign-in and is budgeted well past the CLI default. + */ + minTimeoutMs?: number; historyOperationId?: string; historyStatusFilter?: string; historyListFilters?: { @@ -11076,12 +11089,21 @@ function buildActionsPlan(args: string[]): CliPlan { } if (sub === "run") { const target = parseActionRunTarget(args); + // Actions the daemon is allowed to spend longer on than the client's + // default budget carry their own transport floor, straight from the table + // the desktop client reads. Without it `ade actions run ai.piLoginStart` + // reports a timeout at 10 minutes while the sign-in it started is still + // waiting on the human. + const minTimeoutMs = longRunningLocalRuntimeActionTimeoutMs( + `${target.domain}.${target.action}`, + ); return { kind: "execute", label: "action run", ...(target.domain === "chat" && target.action === "createScheduledWork" ? { formatter: "scheduled-work-create" as const } : {}), + ...(minTimeoutMs != null ? { minTimeoutMs } : {}), steps: [buildActionRunStep(args, target)], }; } @@ -21232,10 +21254,16 @@ async function executePlan( // A plan may force a specific runtime role for its connection (e.g. `ade // logout`, whose signOut account action is CTO-only). Honor it so the caller // asserts the operator role and the machine account gate resolves to cto. - const connectionOptions = + const roledConnectionOptions = plan.connectRole ? { ...baseConnectionOptions, role: plan.connectRole } : baseConnectionOptions; + // An explicit --timeout-ms above the floor still wins; the floor only stops + // the client from giving up while the daemon is still inside its own budget. + const connectionOptions = + plan.minTimeoutMs != null && plan.minTimeoutMs > roledConnectionOptions.timeoutMs + ? { ...roledConnectionOptions, timeoutMs: plan.minTimeoutMs } + : roledConnectionOptions; try { connection = await createConnection(connectionOptions, { autoRegisterProject: shouldAutoRegisterProjectForPlan(plan), diff --git a/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts b/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts index 0787c3e3d..7cb6f9dc9 100644 --- a/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts +++ b/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts @@ -4,6 +4,7 @@ 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 { PI_APPROVAL_ALLOW } from "../chat/piSdkEventMapper"; import { acquirePiSdkConnection, releasePiSdkConnection, @@ -57,6 +58,47 @@ function writeCompletion(response: http.ServerResponse, model: string, text = "P response.end("data: [DONE]\n\n"); } +/** Emit an OpenAI-style tool call so the installed Pi actually invokes a tool. */ +function writeToolCall( + response: http.ServerResponse, + model: string, + toolName: string, + args: Record, +): 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-tool-call", + object: "chat.completion.chunk", + created: 1, + model, + ...payload, + })}\n\n`); + }; + emit({ + choices: [{ + index: 0, + delta: { + role: "assistant", + tool_calls: [{ + index: 0, + id: "ade-pi-test-call-1", + type: "function", + function: { name: toolName, arguments: JSON.stringify(args) }, + }], + }, + finish_reason: null, + }], + }); + emit({ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }); + response.end("data: [DONE]\n\n"); +} + function queueRequest(request: CompletionRequest): void { const waiter = requestWaiters.shift(); if (waiter) waiter(request); @@ -112,6 +154,9 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: { modelId?: string; tools?: string[]; session?: { sessionFile?: string; sessionId?: string }; + askUserTool?: boolean; + approvalTools?: string[]; + extensions?: boolean; }): { poolKey: string; packageRoot: string; @@ -124,6 +169,9 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: { systemPrompt: string; tools?: string[]; session?: { sessionFile?: string; sessionId?: string }; + askUserTool?: boolean; + approvalTools?: string[]; + extensions?: boolean; baseEnv: NodeJS.ProcessEnv; } { const installation = resolvePiInstallation({ @@ -146,6 +194,9 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: { systemPrompt: "You are the isolated ADE Pi integration test model.", ...(options?.tools ? { tools: options.tools } : {}), ...(options?.session ? { session: options.session } : {}), + ...(options?.askUserTool ? { askUserTool: true } : {}), + ...(options?.approvalTools ? { approvalTools: options.approvalTools } : {}), + ...(options?.extensions ? { extensions: true } : {}), baseEnv: { PATH: process.env.PATH ?? "", HOME: fixture.root, @@ -194,7 +245,11 @@ describeInstalledPi("installed Pi SDK worker", () => { response.end(JSON.stringify({ error: { message: "invalid local test credential" } })); return; } - if (!textFromMessages(body).includes("abort-me")) { + // `manual-stream` hands the response to the test so it can emit a tool + // call instead of the canned reply. The marker lives in the user turn, + // so it still applies on the follow-up request after a tool result. + const latestUserText = textFromMessages(body); + if (!latestUserText.includes("abort-me") && !latestUserText.includes("manual-stream")) { writeCompletion(response, typeof body.model === "string" ? body.model : "test-model"); } }); @@ -367,4 +422,145 @@ describeInstalledPi("installed Pi SDK worker", () => { await connection.pooled.sendPrompt({ prompt: "after abort" }); await nextRequest(); }); + + it("exposes ask_user as a real tool and returns the user's answer to the model", async () => { + const fixture = createFixture(); + const connection = await acquireTracked(fixture, `ask-user:${Date.now()}`, { askUserTool: true }); + + const seen: Array<{ requestId: string; payload: { origin: string; kind: string; message: string; options?: Array<{ value: string; label: string }> } }> = []; + connection.pooled.bridge.onUiRequest = (requestId, payload) => { + seen.push({ requestId, payload }); + // Answer with the second option to prove the value round-trips. + connection.pooled.respondToUi(requestId, { ok: true, value: payload.options?.[1]?.value ?? "" }); + }; + + const prompt = connection.pooled.sendPrompt({ prompt: "ask me something (manual-stream)" }); + const first = await nextRequest(); + const offered = ((first.body.tools as Array<{ function?: { name?: string } }> | undefined) ?? []) + .map((tool) => tool.function?.name); + expect(offered).toContain("ask_user"); + + writeToolCall(first.response, "test-model", "ask_user", { + question: "Which database should I use?", + header: "Database", + options: [{ label: "Postgres" }, { label: "SQLite" }], + }); + + // Pi calls the tool, the bridge raises a card, and the answer comes back as + // a tool result on the follow-up request. + const second = await nextRequest(); + expect(seen).toHaveLength(1); + expect(seen[0]!.payload).toMatchObject({ origin: "tool", kind: "select", message: "Which database should I use?" }); + expect(JSON.stringify(second.body.messages)).toContain("SQLite"); + writeCompletion(second.response, "test-model", "Using SQLite then."); + await prompt; + }); + + it("gates an approved tool behind a card and fails the call when the user denies it", async () => { + const fixture = createFixture(); + const marker = path.join(fixture.cwd, "approval-marker.txt"); + const connection = await acquireTracked(fixture, `approval:${Date.now()}`, { + tools: ["read", "bash", "edit", "write"], + approvalTools: ["bash"], + }); + + const requests: Array<{ origin: string; message: string }> = []; + connection.pooled.bridge.onUiRequest = (requestId, payload) => { + requests.push({ origin: payload.origin, message: payload.message }); + connection.pooled.respondToUi(requestId, { ok: false }); + }; + + const prompt = connection.pooled.sendPrompt({ prompt: "run a command (manual-stream)" }); + const first = await nextRequest(); + writeToolCall(first.response, "test-model", "bash", { command: `touch ${marker}` }); + + const second = await nextRequest(); + // The gate ran before the command did. + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ origin: "approval" }); + expect(requests[0]!.message).toContain("touch"); + expect(fs.existsSync(marker)).toBe(false); + expect(JSON.stringify(second.body.messages)).toMatch(/denied this bash call/iu); + writeCompletion(second.response, "test-model", "Understood."); + await prompt; + }); + + it("runs an approved tool for real once the user allows it", async () => { + const fixture = createFixture(); + const marker = path.join(fixture.cwd, "allowed-marker.txt"); + const connection = await acquireTracked(fixture, `approval-allow:${Date.now()}`, { + tools: ["read", "bash", "edit", "write"], + approvalTools: ["bash"], + }); + const approvals: string[] = []; + connection.pooled.bridge.onUiRequest = (requestId, payload) => { + approvals.push(payload.origin); + connection.pooled.respondToUi(requestId, { ok: true, value: PI_APPROVAL_ALLOW }); + }; + + const prompt = connection.pooled.sendPrompt({ prompt: "run a command (manual-stream)" }); + const first = await nextRequest(); + writeToolCall(first.response, "test-model", "bash", { command: `touch ${marker}` }); + const second = await nextRequest(); + // The command ran because the gate was asked and answered, not bypassed. + expect(approvals).toEqual(["approval"]); + expect(fs.existsSync(marker)).toBe(true); + writeCompletion(second.response, "test-model", "Done."); + await prompt; + }); + + it("answers a blocked worker request when no ADE surface is listening", async () => { + const fixture = createFixture(); + const connection = await acquireTracked(fixture, `no-surface:${Date.now()}`, { askUserTool: true }); + // Deliberately leave bridge.onUiRequest unset. + const prompt = connection.pooled.sendPrompt({ prompt: "ask with nobody home (manual-stream)" }); + const first = await nextRequest(); + writeToolCall(first.response, "test-model", "ask_user", { question: "Anyone there?" }); + // Without the pool's fail-closed reply this would hang until the test timed out. + const second = await nextRequest(); + expect(JSON.stringify(second.body.messages)).toMatch(/did not answer/iu); + writeCompletion(second.response, "test-model", "Proceeding."); + await prompt; + }); + + it("loads extensions behind the UI bridge and reports them on the ready payload", async () => { + const fixture = createFixture(); + const extensionDir = path.join(fixture.agentDir, "extensions"); + fs.mkdirSync(extensionDir, { recursive: true }); + fs.writeFileSync( + path.join(extensionDir, "ade-test-extension.js"), + "export default function(pi) { pi.registerCommand?.({ name: 'ade-test', description: 'ADE test', handler: () => {} }); }\n", + ); + const connection = await acquireTracked(fixture, `extensions:${Date.now()}`, { extensions: true }); + // Naming the fixture proves the bridge bound real extensions rather than + // reporting an empty list that an opted-out session would also produce. + const loaded = connection.pooled.ready?.extensions ?? []; + expect(connection.pooled.ready?.extensionsError ?? null).toBeNull(); + expect(loaded.map((extension) => extension.name ?? extension.id).join(" ")) + .toContain("ade-test-extension"); + }); + + it("never loads extensions from the checkout, only from the user's own profile", async () => { + const fixture = createFixture(); + // Pi auto-loads project extensions when the project is trusted, which its + // CLI only does after prompting. ADE opens repositories the user has not + // vouched for, so this must stay out of the session. + const projectExtensions = path.join(fixture.cwd, ".pi", "extensions"); + fs.mkdirSync(projectExtensions, { recursive: true }); + fs.writeFileSync( + path.join(projectExtensions, "repo-supplied.js"), + "export default function(pi) { pi.registerCommand?.({ name: 'repo-supplied', description: 'repo', handler: () => {} }); }\n", + ); + const userExtensions = path.join(fixture.agentDir, "extensions"); + fs.mkdirSync(userExtensions, { recursive: true }); + fs.writeFileSync( + path.join(userExtensions, "user-owned.js"), + "export default function(pi) { pi.registerCommand?.({ name: 'user-owned', description: 'user', handler: () => {} }); }\n", + ); + + const connection = await acquireTracked(fixture, `trust:${Date.now()}`, { extensions: true }); + const names = (connection.pooled.ready?.extensions ?? []).map((extension) => extension.name ?? extension.id); + expect(names.join(" ")).toContain("user-owned"); + expect(names.join(" ")).not.toContain("repo-supplied"); + }); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 6755a3148..72d087812 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -11,6 +11,13 @@ import { startOAuth as startOpenCodeOAuth, type OpenCodeAuthDeps, } from "../opencode/openCodeAuthService"; +import { + addPiAuthStatusListener, + cancelPiLogin, + listPiLoginProviders, + startPiLogin, + submitPiLoginPrompt, +} from "../ai/piAuthService"; import { getLastFetchedAt as getModelsDevLastFetchedAt, refreshNow as refreshModelsDevNow } from "../ai/modelsDevService"; import { BUILT_IN_BROWSER_DESKTOP_BRIDGE_METHODS } from "../../../../../ade-cli/src/services/builtInBrowser/desktopBridgeMethods"; import type { @@ -230,7 +237,7 @@ export const ADE_ACTION_CTO_ONLY: Partial(); -function ensureOpenCodeOAuthStatusRelayBridge(runtime: AdeRuntime): void { - if (!runtime.eventBuffer || oauthStatusBridgedRuntimes.has(runtime)) return; - oauthStatusBridgedRuntimes.add(runtime); - const unsubscribe = addOpenCodeOAuthStatusListener((event) => { +// runtime; the listeners are detached when the runtime is disposed. +const authStatusBridgedRuntimes = new WeakSet(); +function ensureAuthStatusRelayBridges(runtime: AdeRuntime): void { + if (!runtime.eventBuffer || authStatusBridgedRuntimes.has(runtime)) return; + authStatusBridgedRuntimes.add(runtime); + const push = (kind: string, event: unknown): void => { try { runtime.eventBuffer.push({ timestamp: new Date().toISOString(), category: "runtime", - payload: { kind: "opencodeOAuthStatus", event }, + payload: { kind, event }, }); } catch { - // A full/broken buffer must not break the OAuth flow. + // A full/broken buffer must not break the sign-in flow. } - }); + }; + const unsubscribeOpenCode = addOpenCodeOAuthStatusListener((event) => push("opencodeOAuthStatus", event)); + const unsubscribePi = addPiAuthStatusListener((event) => push("piAuthStatus", event)); const dispose = runtime.dispose; runtime.dispose = () => { - unsubscribe(); + unsubscribeOpenCode(); + unsubscribePi(); dispose(); }; } @@ -2750,7 +2764,7 @@ function ensureOpenCodeOAuthStatusRelayBridge(runtime: AdeRuntime): void { function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { const aiIntegrationService = runtime.aiIntegrationService; if (!aiIntegrationService) return null; - ensureOpenCodeOAuthStatusRelayBridge(runtime); + ensureAuthStatusRelayBridges(runtime); const buildOpenCodeAuthDeps = (): OpenCodeAuthDeps => ({ projectRoot: runtime.projectRoot, projectConfig: runtime.projectConfigService.getEffective(), @@ -2778,6 +2792,37 @@ function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { clearOpenCodeProviderKey(buildOpenCodeAuthDeps(), { providerId: requireNonEmptyString(args?.providerId, "providerId"), }), + piLoginProviders: () => listPiLoginProviders(), + piLoginStart: async (args?: { providerId?: string; method?: "oauth" | "api_key" }) => { + const providerId = requireNonEmptyString(args?.providerId, "providerId"); + const result = await startPiLogin({ + providerId, + ...(args?.method ? { method: args.method } : {}), + }); + // Signing in unlocks models, so the readiness cache is stale until it is + // dropped. The IPC handler does the same; this keeps the action path + // (remote runtime, `ade actions run`) consistent with it. + if (result.ok) { + try { + aiIntegrationService.invalidateProviderReadinessCaches(); + } catch (error) { + runtime.logger.warn("ai.pi_auth_cache_invalidation_failed", { + provider: providerId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return result; + }, + piLoginSubmit: (args?: { providerId?: string; requestId?: string; value?: string }) => + submitPiLoginPrompt({ + providerId: requireNonEmptyString(args?.providerId, "providerId"), + requestId: requireNonEmptyString(args?.requestId, "requestId"), + value: typeof args?.value === "string" ? args.value : "", + }), + piLoginCancel: (args?: { providerId?: string }) => { + cancelPiLogin({ providerId: requireNonEmptyString(args?.providerId, "providerId") }); + }, refreshModelsDev: async () => { try { await refreshModelsDevNow(); diff --git a/apps/desktop/src/main/services/ai/piAuthService.test.ts b/apps/desktop/src/main/services/ai/piAuthService.test.ts new file mode 100644 index 000000000..2a195dbde --- /dev/null +++ b/apps/desktop/src/main/services/ai/piAuthService.test.ts @@ -0,0 +1,314 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { JsonValue } from "../chat/piSdkProtocol"; +import type { PiSdkBridge, PiSdkPooled } from "../chat/piSdkPool"; +import type { PiAuthStatusEvent } from "../../../shared/types/config"; +import type { PiInstallation } from "./piInstallation"; +import { + __getActivePiLoginProviderIdsForTests, + __resetPiAuthServiceForTests, + __setPiAuthHooksForTests, + addPiAuthStatusListener, + cancelPiLogin, + listPiLoginProviders, + startPiLogin, + submitPiLoginPrompt, +} from "./piAuthService"; + +const SECRET = "sk-super-secret-value"; + +const installation: PiInstallation = { + cliPath: "/usr/local/bin/pi", + packageRoot: "/pi/package", + packageEntry: "/pi/package/dist/index.js", + version: "0.84.0", + nodeVersion: "22.19.0", + sdkAvailable: true, + cliAvailable: true, + agentDir: "/home/user/.pi/agent", + settingsPath: "/home/user/.pi/agent/settings.json", + authPath: "/home/user/.pi/agent/auth.json", + modelsPath: "/home/user/.pi/agent/models.json", + modelsStorePath: "/home/user/.pi/agent/models-store.json", + blocker: null, +}; + +type FakeWorker = { + pooled: PiSdkPooled; + bridge: PiSdkBridge; + release: ReturnType; + login: ReturnType; + cancelLogin: ReturnType; + respondToUi: ReturnType; + finishLogin: () => void; + failLogin: (error: Error) => void; +}; + +function createFakeWorker(authInventory: JsonValue = []): FakeWorker { + const bridge: PiSdkBridge = { + onEvent: null, + onLifecycle: null, + onError: null, + onReady: null, + onUiRequest: null, + onUiNotice: null, + onUiCancel: null, + }; + let finishLogin!: () => void; + let failLogin!: (error: Error) => void; + const loginPromise = new Promise((resolve, reject) => { + finishLogin = resolve; + failLogin = reject; + }); + // A rejection is only observed once the service attaches its handler. + loginPromise.catch(() => undefined); + const login = vi.fn(() => loginPromise); + const cancelLogin = vi.fn(); + const respondToUi = vi.fn(); + const pooled = { + bridge, + login, + cancelLogin, + respondToUi, + requestAuth: vi.fn(async () => authInventory), + } as unknown as PiSdkPooled; + return { pooled, bridge, release: vi.fn(), login, cancelLogin, respondToUi, finishLogin, failLogin }; +} + +function installWorker(worker: FakeWorker): void { + __setPiAuthHooksForTests({ + resolveInstallation: () => installation, + acquireWorker: async () => ({ pooled: worker.pooled, release: worker.release }), + }); +} + +function collectEvents(): PiAuthStatusEvent[] { + const events: PiAuthStatusEvent[] = []; + addPiAuthStatusListener((event) => events.push(event)); + return events; +} + +/** Let the service's awaited hooks and settled promises run. */ +async function flush(): Promise { + await vi.advanceTimersByTimeAsync(0); +} + +beforeEach(() => { + __resetPiAuthServiceForTests(); + vi.useFakeTimers(); +}); + +afterEach(() => { + __resetPiAuthServiceForTests(); + vi.useRealTimers(); +}); + +describe("piAuthService", () => { + it("lists only providers with an interactive login, sorted by name", async () => { + const worker = createFakeWorker([ + { id: "zed", name: "Zed", authTypes: ["api_key"] }, + { id: "ambient", name: "Ambient", configured: true }, + { id: "xai", name: "xAI", authTypes: ["oauth"], loginLabel: "Sign in with SuperGrok", isSubscription: true, configured: true }, + ]); + installWorker(worker); + + const providers = await listPiLoginProviders(); + + expect(providers).toEqual([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: true, loginLabel: "Sign in with SuperGrok", isSubscription: true }, + { id: "zed", name: "Zed", authTypes: ["api_key"], configured: false }, + ]); + expect(worker.release).toHaveBeenCalledTimes(1); + }); + + it("releases the worker when the auth inventory request fails", async () => { + const worker = createFakeWorker(); + (worker.pooled.requestAuth as unknown as ReturnType).mockRejectedValue(new Error("worker died")); + installWorker(worker); + + await expect(listPiLoginProviders()).rejects.toThrow("worker died"); + expect(worker.release).toHaveBeenCalledTimes(1); + }); + + it("refuses to start when Pi's SDK package is unavailable", async () => { + const events = collectEvents(); + __setPiAuthHooksForTests({ + resolveInstallation: () => ({ ...installation, sdkAvailable: false, blocker: "Pi is not installed." }), + acquireWorker: async () => { + throw new Error("should not acquire a worker"); + }, + }); + + await expect(startPiLogin({ providerId: "xai" })).resolves.toEqual({ + ok: false, + error: "Pi is not installed.", + }); + expect(events).toEqual([{ providerId: "xai", state: "error", error: "Pi is not installed." }]); + }); + + it("relays a prompt, sends the answer back to Pi, and never emits the value", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "anthropic", method: "api_key" }); + await flush(); + + expect(worker.login).toHaveBeenCalledWith({ providerId: "anthropic", method: "api_key" }); + expect(__getActivePiLoginProviderIdsForTests()).toEqual(["anthropic"]); + + worker.bridge.onUiNotice?.({ + origin: "auth", + level: "info", + message: "Enter code ABCD-1234 at https://x.ai/device", + detail: { kind: "device_code", userCode: "ABCD-1234", verificationUri: "https://x.ai/device" }, + }); + worker.bridge.onUiRequest?.("req-1", { + origin: "auth", + kind: "secret", + message: "Paste your Anthropic API key", + placeholder: "sk-ant-…", + }); + + expect(events).toEqual([ + { providerId: "anthropic", state: "pending" }, + { + providerId: "anthropic", + state: "pending", + notice: { + level: "info", + message: "Enter code ABCD-1234 at https://x.ai/device", + userCode: "ABCD-1234", + verificationUri: "https://x.ai/device", + }, + }, + { + providerId: "anthropic", + state: "prompt", + prompt: { + requestId: "req-1", + kind: "secret", + title: "Sign in to anthropic", + message: "Paste your Anthropic API key", + placeholder: "sk-ant-…", + }, + }, + ]); + + expect(submitPiLoginPrompt({ providerId: "anthropic", requestId: "req-1", value: SECRET })).toEqual({ ok: true }); + expect(worker.respondToUi).toHaveBeenCalledWith("req-1", { ok: true, value: SECRET }); + // A stale answer for the same prompt must not reach Pi twice. + expect(submitPiLoginPrompt({ providerId: "anthropic", requestId: "req-1", value: SECRET }).ok).toBe(false); + expect(worker.respondToUi).toHaveBeenCalledTimes(1); + + worker.finishLogin(); + await expect(started).resolves.toEqual({ ok: true }); + + expect(events.at(-1)).toEqual({ providerId: "anthropic", state: "success" }); + expect(worker.release).toHaveBeenCalledTimes(1); + expect(__getActivePiLoginProviderIdsForTests()).toEqual([]); + expect(JSON.stringify(events)).not.toContain(SECRET); + }); + + it("maps an auth URL notice onto the status event", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "xai" }); + await flush(); + worker.bridge.onUiNotice?.({ + origin: "auth", + level: "info", + message: "Open this URL to continue signing in.", + detail: { kind: "auth_url", url: "https://accounts.x.ai/authorize" }, + }); + + expect(events.at(-1)?.notice).toEqual({ + level: "info", + message: "Open this URL to continue signing in.", + url: "https://accounts.x.ai/authorize", + }); + + cancelPiLogin({ providerId: "xai" }); + await started; + }); + + it("cancel answers the pending prompt, stops Pi, and releases the worker", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "xai" }); + await flush(); + worker.bridge.onUiRequest?.("req-9", { origin: "auth", kind: "manual_code", message: "Paste the code" }); + + cancelPiLogin({ providerId: "xai" }); + + expect(worker.respondToUi).toHaveBeenCalledWith("req-9", { ok: false, error: "Sign-in cancelled." }); + expect(worker.cancelLogin).toHaveBeenCalledTimes(1); + await expect(started).resolves.toEqual({ ok: false, error: "Sign-in cancelled." }); + expect(events.at(-1)).toEqual({ providerId: "xai", state: "error", error: "Sign-in cancelled." }); + expect(worker.release).toHaveBeenCalledTimes(1); + expect(__getActivePiLoginProviderIdsForTests()).toEqual([]); + }); + + it("times out an abandoned flow and releases the worker", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "xai" }); + await flush(); + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + + await expect(started).resolves.toEqual({ + ok: false, + error: "Pi sign-in timed out. Start it again when you're ready.", + }); + expect(worker.cancelLogin).toHaveBeenCalledTimes(1); + expect(events.at(-1)?.state).toBe("error"); + expect(worker.release).toHaveBeenCalledTimes(1); + expect(__getActivePiLoginProviderIdsForTests()).toEqual([]); + }); + + it("releases the worker when Pi's login rejects", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "xai" }); + await flush(); + worker.failLogin(new Error("Pi SDK login failed: provider refused")); + + await expect(started).resolves.toEqual({ + ok: false, + error: "Pi SDK login failed: provider refused", + }); + expect(events.at(-1)).toEqual({ + providerId: "xai", + state: "error", + error: "Pi SDK login failed: provider refused", + }); + expect(worker.release).toHaveBeenCalledTimes(1); + }); + + it("starting a second flow for the same provider supersedes the first", async () => { + const first = createFakeWorker(); + installWorker(first); + const started = startPiLogin({ providerId: "xai" }); + await flush(); + + const second = createFakeWorker(); + installWorker(second); + const restarted = startPiLogin({ providerId: "xai" }); + await flush(); + + await expect(started).resolves.toEqual({ ok: false, error: "Sign-in cancelled." }); + expect(first.release).toHaveBeenCalledTimes(1); + expect(__getActivePiLoginProviderIdsForTests()).toEqual(["xai"]); + + second.finishLogin(); + await expect(restarted).resolves.toEqual({ ok: true }); + expect(second.release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/main/services/ai/piAuthService.ts b/apps/desktop/src/main/services/ai/piAuthService.ts new file mode 100644 index 000000000..2a0000781 --- /dev/null +++ b/apps/desktop/src/main/services/ai/piAuthService.ts @@ -0,0 +1,332 @@ +// --------------------------------------------------------------------------- +// Pi in-app sign-in +// +// Drives Pi's own `ModelRuntime.login()` from inside ADE: enumerate the +// providers that can actually be signed into, run one login on a dedicated +// inventory-only worker, and relay Pi's prompts/notices to whatever surface is +// listening. +// +// Credentials stay Pi's. The worker hands ADE prompt text and the user's answer +// travels straight back through `respondToUi`; nothing that could be a token is +// stored, logged, or put on a status event. +// --------------------------------------------------------------------------- + +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { + acquirePiSdkConnection, + releasePiSdkConnection, + type PiSdkPooled, +} from "../chat/piSdkPool"; +import type { + JsonValue, + PiSdkUiNoticePayload, + PiSdkUiRequestPayload, +} from "../chat/piSdkProtocol"; +import { resolvePiInstallation, type PiInstallation } from "./piInstallation"; +import type { + PiAuthNotice, + PiAuthPrompt, + PiAuthStatusEvent, + PiLoginMethod, + PiLoginProvider, +} from "../../../shared/types/config"; + +export type { PiAuthStatusEvent, PiLoginProvider } from "../../../shared/types/config"; + +/** + * Give up on a login after this long. A device-code grant legitimately takes + * minutes, so this is generous — it exists to stop an abandoned flow from + * holding a worker forever, not to pace the user. + */ +const PI_LOGIN_TIMEOUT_MS = 10 * 60 * 1000; + +export type PiLoginResult = { ok: true } | { ok: false; error: string }; + +type AcquiredWorker = { pooled: PiSdkPooled; release: () => void }; + +type PiAuthHooks = { + resolveInstallation(env: NodeJS.ProcessEnv): PiInstallation; + acquireWorker(installation: PiInstallation, purpose: string): Promise; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +const defaultHooks: PiAuthHooks = { + resolveInstallation(env) { + return resolvePiInstallation(env); + }, + async acquireWorker(installation, purpose) { + // Unique per flow so a sign-in can never join — or be evicted by — the + // pooled worker backing a chat session. + const poolKey = `pi-login:${installation.agentDir}:${purpose}:${randomUUID()}`; + const acquired = await acquirePiSdkConnection({ + poolKey, + packageRoot: installation.packageRoot!, + packageEntry: installation.packageEntry!, + cwd: path.resolve(process.cwd()), + agentDir: installation.agentDir, + inventoryOnly: true, + baseEnv: process.env, + }); + return { + pooled: acquired.pooled, + release: () => releasePiSdkConnection(poolKey, acquired.generation), + }; + }, +}; + +let hooks: PiAuthHooks = { ...defaultHooks }; +type StatusListener = (event: PiAuthStatusEvent) => void; +const statusListeners = new Set(); + +type ActiveFlow = { + pooled: PiSdkPooled; + /** The prompt Pi is currently blocked on, if any. */ + pendingRequestId: string | null; + finish: (result: PiLoginResult) => void; +}; +const activeFlows = new Map(); +/** Monotonic per-provider claim, so a superseded start can detect it lost. */ +const startGenerations = new Map(); + +/** + * Subscribe a sink to Pi sign-in transitions. Multiple sinks may coexist so the + * same transition can fan out to renderer windows and the runtime event buffer + * remote/web clients drain. Returns an unsubscribe function. + */ +export function addPiAuthStatusListener(listener: StatusListener): () => void { + statusListeners.add(listener); + return () => { + statusListeners.delete(listener); + }; +} + +function emit(event: PiAuthStatusEvent): void { + for (const listener of statusListeners) { + try { + listener(event); + } catch { + // A broken sink must not break the flow or starve other listeners. + } + } +} + +function jsonRecord(value: JsonValue | undefined): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value : null; +} + +function jsonString(value: JsonValue | undefined): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function toPrompt(requestId: string, payload: PiSdkUiRequestPayload, providerId: string): PiAuthPrompt { + return { + requestId, + kind: payload.kind, + title: payload.title?.trim() || `Sign in to ${providerId}`, + message: payload.message, + ...(payload.placeholder?.trim() ? { placeholder: payload.placeholder.trim() } : {}), + ...(payload.options?.length + ? { + options: payload.options.map((option) => ({ + value: option.value, + label: option.label, + ...(option.description?.trim() ? { description: option.description.trim() } : {}), + })), + } + : {}), + }; +} + +function toNotice(payload: PiSdkUiNoticePayload): PiAuthNotice { + const detail = jsonRecord(payload.detail); + return { + level: payload.level, + message: payload.message, + ...(jsonString(detail?.url) ? { url: jsonString(detail?.url)! } : {}), + ...(jsonString(detail?.userCode) ? { userCode: jsonString(detail?.userCode)! } : {}), + ...(jsonString(detail?.verificationUri) + ? { verificationUri: jsonString(detail?.verificationUri)! } + : {}), + }; +} + +function requireSdk(installation: PiInstallation): void { + if (installation.sdkAvailable && installation.packageRoot && installation.packageEntry) return; + throw new Error(installation.blocker ?? "Pi's SDK package is unavailable, so ADE cannot sign in for you."); +} + +function toLoginProvider(entry: JsonValue): PiLoginProvider | null { + const record = jsonRecord(entry); + const id = jsonString(record?.id); + if (!id) return null; + const rawTypes = Array.isArray(record?.authTypes) ? record.authTypes : []; + const authTypes = rawTypes.filter( + (value): value is PiLoginMethod => value === "oauth" || value === "api_key", + ); + if (!authTypes.length) return null; + return { + id, + name: jsonString(record?.name) ?? id, + authTypes, + configured: record?.configured === true + || record?.authenticated === true + || record?.isAuthenticated === true, + ...(jsonString(record?.loginLabel) ? { loginLabel: jsonString(record?.loginLabel)! } : {}), + ...(record?.isSubscription === true ? { isSubscription: true } : {}), + }; +} + +/** Providers ADE can run an interactive sign-in for, in display order. */ +export async function listPiLoginProviders( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const installation = hooks.resolveInstallation(env); + requireSdk(installation); + const { pooled, release } = await hooks.acquireWorker(installation, "inventory"); + try { + const inventory = await pooled.requestAuth(); + const providers = (Array.isArray(inventory) ? inventory : []) + .map(toLoginProvider) + .filter((provider): provider is PiLoginProvider => provider !== null); + return providers.sort((a, b) => a.name.localeCompare(b.name)); + } finally { + release(); + } +} + +/** + * Run one provider sign-in. Only one flow per provider is active at a time — + * starting another cancels the first. Resolves once the flow settles; the + * prompts in between arrive through `addPiAuthStatusListener`. + */ +export async function startPiLogin(args: { + providerId: string; + method?: PiLoginMethod | null; +}): Promise { + const providerId = args.providerId.trim(); + if (!providerId) return { ok: false, error: "Provider ID is required." }; + cancelPiLogin({ providerId }); + // Acquiring a worker is async, so two starts for the same provider can both + // get past the cancel above. Claim the provider synchronously and re-check + // after the await, or the loser would orphan a worker nobody releases. + const generation = (startGenerations.get(providerId) ?? 0) + 1; + startGenerations.set(providerId, generation); + + let acquired: AcquiredWorker; + try { + const installation = hooks.resolveInstallation(process.env); + requireSdk(installation); + acquired = await hooks.acquireWorker(installation, providerId); + } catch (error) { + const message = errorMessage(error); + emit({ providerId, state: "error", error: message }); + return { ok: false, error: message }; + } + + if (startGenerations.get(providerId) !== generation) { + acquired.pooled.cancelLogin(); + acquired.release(); + return { ok: false, error: "Sign-in was superseded by a newer attempt." }; + } + + const { pooled, release } = acquired; + return await new Promise((resolve) => { + let settled = false; + let timer: ReturnType | null = null; + const flow: ActiveFlow = { + pooled, + pendingRequestId: null, + finish: (result) => { + if (settled) return; + settled = true; + if (activeFlows.get(providerId) === flow) activeFlows.delete(providerId); + if (timer) clearTimeout(timer); + pooled.bridge.onUiRequest = null; + pooled.bridge.onUiNotice = null; + release(); + emit(result.ok + ? { providerId, state: "success" } + : { providerId, state: "error", error: result.error }); + resolve(result); + }, + }; + activeFlows.set(providerId, flow); + + pooled.bridge.onUiRequest = (requestId, payload) => { + flow.pendingRequestId = requestId; + emit({ providerId, state: "prompt", prompt: toPrompt(requestId, payload, providerId) }); + }; + pooled.bridge.onUiNotice = (payload) => { + emit({ providerId, state: "pending", notice: toNotice(payload) }); + }; + + emit({ providerId, state: "pending" }); + timer = setTimeout(() => { + pooled.cancelLogin(); + flow.finish({ ok: false, error: "Pi sign-in timed out. Start it again when you're ready." }); + }, PI_LOGIN_TIMEOUT_MS); + timer.unref?.(); + + void pooled + .login({ providerId, ...(args.method ? { method: args.method } : {}) }) + .then(() => flow.finish({ ok: true })) + .catch((error: unknown) => flow.finish({ ok: false, error: errorMessage(error) })); + }); +} + +/** Answer the prompt Pi is blocked on. The value is relayed, never retained. */ +export function submitPiLoginPrompt(args: { + providerId: string; + requestId: string; + value: string; +}): { ok: boolean; error?: string } { + const flow = activeFlows.get(args.providerId.trim()); + if (!flow) return { ok: false, error: "That sign-in is no longer running." }; + if (flow.pendingRequestId !== args.requestId) { + return { ok: false, error: "That prompt has already been answered." }; + } + flow.pendingRequestId = null; + flow.pooled.respondToUi(args.requestId, { ok: true, value: args.value }); + return { ok: true }; +} + +/** Stop an in-flight sign-in (if any), release its worker, and settle it. */ +export function cancelPiLogin(args: { providerId: string }): void { + const providerId = args.providerId.trim(); + // Claiming a fresh generation cancels a start that is still acquiring its + // worker: it has no flow to find yet, so without this the cancel is a no-op + // and the sign-in the user stopped runs on holding a worker. + startGenerations.set(providerId, (startGenerations.get(providerId) ?? 0) + 1); + const flow = activeFlows.get(providerId); + if (!flow) return; + if (flow.pendingRequestId) { + flow.pooled.respondToUi(flow.pendingRequestId, { ok: false, error: "Sign-in cancelled." }); + flow.pendingRequestId = null; + } + flow.pooled.cancelLogin(); + flow.finish({ ok: false, error: "Sign-in cancelled." }); +} + +// --- Test hooks ------------------------------------------------------------ + +export function __setPiAuthHooksForTests(partial: Partial): void { + hooks = { ...hooks, ...partial }; +} + +export function __resetPiAuthServiceForTests(): void { + for (const providerId of [...activeFlows.keys()]) { + activeFlows.get(providerId)?.finish({ ok: false, error: "Pi auth service reset." }); + } + activeFlows.clear(); + startGenerations.clear(); + hooks = { ...defaultHooks }; + statusListeners.clear(); +} + +export function __getActivePiLoginProviderIdsForTests(): string[] { + return [...activeFlows.keys()]; +} diff --git a/apps/desktop/src/main/services/analytics/dailyUsageAnalytics.ts b/apps/desktop/src/main/services/analytics/dailyUsageAnalytics.ts index 4ae3c54eb..c9acd69f5 100644 --- a/apps/desktop/src/main/services/analytics/dailyUsageAnalytics.ts +++ b/apps/desktop/src/main/services/analytics/dailyUsageAnalytics.ts @@ -23,6 +23,9 @@ export function completedDailyUsageAnalyticsTarget( function coarseProvider(value: string): string { const normalized = value.trim().toLowerCase(); + // Pi is the harness, so a Pi-routed model reports as Pi rather than as the + // upstream provider its id happens to name. + if (normalized === "pi" || normalized.startsWith("pi/")) return "pi"; if (normalized.includes("codex")) return "codex"; if (normalized.includes("openai")) return "openai"; if (normalized.includes("claude") || normalized.includes("anthropic")) return "claude"; diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts b/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts index b1dad387b..166f16401 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts @@ -170,7 +170,7 @@ const SAFE_STRING_VALUES: Partial>> = { // `ade_update_install_did_not_land`, so only the brain half is new signal. "service", "restart", "health", ]), - provider: new Set(["codex", "openai", "claude", "cursor", "droid", "opencode", "gemini", "local", "other"]), + provider: new Set(["codex", "openai", "claude", "cursor", "droid", "opencode", "pi", "gemini", "local", "other"]), model_family: new Set([ "gpt_5", "openai_reasoning", "claude_sonnet", "claude_opus", "claude_haiku", "cursor", "gemini", "grok", "local", "other", diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts b/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts index 6e7d0ee5b..1f4d47d40 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts @@ -12,6 +12,7 @@ import { type ProductAnalyticsCapture, } from "../../../shared/types/productAnalytics"; import { captureAgentTurnSettledAnalytics } from "./agentTurnProductAnalytics"; +import { sanitizeProductAnalyticsProperties } from "./productAnalyticsPolicy"; import { captureDailyUsageAnalytics, completedDailyUsageAnalyticsTarget, @@ -1072,6 +1073,29 @@ function settledEvent(overrides: Partial = {}): Agent } describe("product analytics producers", () => { + it("keeps a Pi turn attributed to Pi instead of dropping it to the catch-all provider", () => { + const captures: ProductAnalyticsCapture[] = []; + const analytics = settledAnalytics(captures); + + captureAgentTurnSettledAnalytics({ + analytics, + projectId: "project-1", + event: settledEvent({ provider: "pi" }), + }); + + // Pi is a first-class harness; an unlisted value would be sanitized away + // and its sessions would be invisible in product analytics. + const settled = captures.find((capture) => capture.event === "ade_work_session_completed"); + expect(settled?.properties).toMatchObject({ feature: "chat", provider: "pi", outcome: "completed" }); + // Prove it survives the public sanitizer, not just the producer. + expect(sanitizeProductAnalyticsProperties("ade_work_session_completed", { + feature: "chat", + provider: "pi", + outcome: "completed", + source: "runtime", + })).toMatchObject({ provider: "pi" }); + }); + it("maps automation completion and failed chat turns into canonical bounded outcomes", () => { const captures: ProductAnalyticsCapture[] = []; const analytics = settledAnalytics(captures); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 7e3044e2f..dfc419723 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -411,7 +411,7 @@ import { type ModelDescriptor, type ModelProviderGroup, } from "../../../shared/modelRegistry"; -import { piToolsForPermissionMode } from "../../../shared/cliLaunch"; +import { piSdkToolPolicyForPermissionMode } from "../../../shared/cliLaunch"; import { buildProviderGroupBlocks, createModelOrderMap, @@ -543,6 +543,7 @@ import { releasePiSdkConnection, type PiSdkPooled, } from "./piSdkPool"; +import type { PiSdkUiRequestPayload } from "./piSdkProtocol"; import { acquirePiSessionLease, piSessionCreationLeaseTarget, @@ -559,7 +560,13 @@ import { resolveCursorSdkModelSelectionParams, } from "./cursorModelsDiscovery"; import { discoverDroidSdkModelDescriptors } from "./droidModelsDiscovery"; -import { mapPiSdkEventToChatEvents } from "./piSdkEventMapper"; +import { + mapPiSdkEventToChatEvents, + piExtensionLoadNotice, + piUiNoticeToChatEvents, + piUiRequestToPendingInput, + piUiResponseFromAnswer, +} from "./piSdkEventMapper"; import { AUTO_LANE_IDENTITY_JSON_SCHEMA, AUTO_TITLE_SYSTEM_PROMPT, @@ -1787,6 +1794,8 @@ type PiRuntime = { modelId: string | null; activeCompactionId: string | null; lease: PiSessionLease | null; + /** Tool/extension policy this worker was built with; a change forces a restart. */ + toolPolicyKey: string; }; type ChatRuntime = CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime | PiRuntime; @@ -11106,6 +11115,38 @@ export function createAgentChatService(args: { throw new Error(`${descriptor.displayName} is reachable, but no models are currently loaded.`); }; + /** + * Render a blocking Pi request as an ADE question card and answer the worker + * with whatever the user chooses. + * + * The worker is holding a Pi callback open, so every path here must end in a + * `respondToUi` — including teardown, which `localPendingInputs` drains. + */ + const presentPiUiRequest = ( + managed: ManagedChatSession, + runtime: PiRuntime, + requestId: string, + payload: PiSdkUiRequestPayload, + ): void => { + const request = piUiRequestToPendingInput(requestId, payload, runtime.activeTurnId ?? null); + let answered = false; + managed.localPendingInputs.set(requestId, { + request, + resolve: (response) => { + // The worker holds a Pi callback open against this id, so it must be + // answered exactly once no matter which drain site got here first. + if (answered) return; + answered = true; + runtime.sdk.respondToUi(requestId, piUiResponseFromAnswer(payload, response)); + }, + }); + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: payload.message, + detail: { pi: true, origin: payload.origin, ...(payload.sourceId ? { sourceId: payload.sourceId } : {}) }, + }); + }; + const startPiRuntime = async (managed: ManagedChatSession): Promise => { if (piRuntimeSetupInterruptRequested.get(managed)) { piRuntimeSetupInterruptRequested.delete(managed); @@ -11123,8 +11164,22 @@ export function createAgentChatService(args: { if (piProviderId) managed.session.piProviderId = piProviderId; if (piModelId) managed.session.piModelId = piModelId; const poolKey = `pi:${projectRoot}:${path.resolve(managed.laneWorktreePath)}:${managed.session.id}:${piProfileId}`; + // Pi's tool allowlist and extension binding are fixed when the worker is + // created, so a session switched from default to plan would keep its write + // tools until something else restarted it. Restart on any policy change. + const piToolPolicy = piSdkToolPolicyForPermissionMode(managed.session.permissionMode); + const piExtensionsEnabled = piChatExtensionsEnabled(managed); + const toolPolicyKey = [ + piToolPolicy.tools.join(","), + piToolPolicy.approvalTools.join(","), + piExtensionsEnabled ? "ext" : "no-ext", + ].join("|"); if (managed.runtime?.kind === "pi") { - if (managed.runtime.poolKey === poolKey && isPiSdkPooledAlive(managed.runtime.sdk)) return managed.runtime; + if (managed.runtime.poolKey === poolKey + && managed.runtime.toolPolicyKey === toolPolicyKey + && isPiSdkPooledAlive(managed.runtime.sdk)) { + return managed.runtime; + } teardownRuntime(managed, "handle_close"); } else if (managed.runtime) { teardownRuntime(managed, "handle_close"); @@ -11190,10 +11245,13 @@ export function createAgentChatService(args: { 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); + // + // The SDK policy is read from the session's own mode rather than the + // collapsed harness mode: in chat ADE can gate each call behind an approval + // card, so `default` keeps the write tools available but asks first. + const piTools = piToolPolicy.tools; let acquired: Awaited>; try { acquired = await acquirePiSdkConnection({ @@ -11204,6 +11262,11 @@ export function createAgentChatService(args: { agentDir: installation.agentDir, sessionDir, tools: piTools, + ...(piToolPolicy.approvalTools.length ? { approvalTools: piToolPolicy.approvalTools } : {}), + // Lets Pi ask the user a question mid-turn, which no tool allowlist can + // express. Personal chats get it too — it is how the model checks in. + askUserTool: true, + ...(piExtensionsEnabled ? { extensions: true } : {}), ...(piProviderId && piModelId ? { modelRef: { provider: piProviderId, id: piModelId } } : {}), thinkingLevel: managed.session.reasoningEffort ?? null, systemPrompt, @@ -11277,6 +11340,7 @@ export function createAgentChatService(args: { modelId: piModelId, activeCompactionId: null, lease: piLease, + toolPolicyKey, }; managed.runtime = runtime; managed.runtimeInvalidated = false; @@ -11298,6 +11362,32 @@ export function createAgentChatService(args: { persistChatState(managed); } }; + acquired.pooled.bridge.onUiRequest = (requestId, payload) => { + if (managed.runtime !== runtime) { + acquired.pooled.respondToUi(requestId, { ok: false, error: "This Pi session is no longer active." }); + return; + } + presentPiUiRequest(managed, runtime, requestId, payload); + }; + acquired.pooled.bridge.onUiCancel = (requestId) => { + if (managed.runtime !== runtime) return; + const pending = managed.localPendingInputs.get(requestId); + if (!pending) return; + managed.localPendingInputs.delete(requestId); + pending.resolve({ decision: "cancel" }); + emitPendingInputResolved(managed, { + itemId: requestId, + decision: "cancel", + turnId: pending.request.turnId ?? null, + questions: pending.request.questions, + }); + }; + acquired.pooled.bridge.onUiNotice = (payload) => { + if (managed.runtime !== runtime) return; + for (const event of piUiNoticeToChatEvents(payload, runtime.activeTurnId ?? undefined)) { + emitChatEvent(managed, event); + } + }; 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 }); @@ -11310,6 +11400,16 @@ export function createAgentChatService(args: { persistChatState(managed); } }; + // Tell the user which of their extensions are live in this chat, and that + // the bridge is narrower than Pi's terminal UI. Only when they opted in — + // an unchanged default should not add a notice to every Pi chat. + for (const event of piExtensionLoadNotice( + piExtensionsEnabled ? acquired.pooled.ready?.extensions : undefined, + piExtensionsEnabled ? acquired.pooled.ready?.extensionsError : null, + acquired.pooled.ready?.ungateableTools ?? [], + )) { + emitChatEvent(managed, event); + } persistChatState(managed); sessionService.setResumeCommand(managed.session.id, `chat:pi:${managed.session.id}`); logger.info("agent_chat.pi_sdk_runtime_ready", { @@ -11461,6 +11561,22 @@ export function createAgentChatService(args: { return chat?.autoAllowAskUser !== false; }; + /** + * Pi extensions load in ADE chat by default, matching `pi` in a terminal. + * They are bound to ADE's limited UI bridge rather than Pi's TUI. + */ + const piChatExtensionsEnabled = (managed: ManagedChatSession): boolean => { + // A personal chat is not attached to a project worktree, so project-scoped + // extensions have no business loading into it. + if (isPersonalSession(managed.session)) return false; + // Enabling extensions means giving up the `tools` allowlist, because their + // tool names are not known until after the session exists. A plan-mode + // session promises read-only, and an extension tool could write — so plan + // mode keeps the allowlist and forgoes extensions. + if (piSdkToolPolicyForPermissionMode(managed.session.permissionMode).readOnly) return false; + return projectConfigService.get().effective.ai?.chat?.piExtensionsEnabled !== false; + }; + const isAskUserToolName = (toolName: string | null | undefined): boolean => { if (!toolName) return false; const normalized = normalizeToolNameForApproval(toolName); @@ -16392,6 +16508,27 @@ export function createAgentChatService(args: { } }; + /** + * Cancel Pi cards that are still waiting on the user. + * + * The worker drains its own side when a turn aborts, but the desktop entry + * outlives it: without this the chat keeps rendering a card nobody can answer + * and `hasPendingInput` keeps reporting the session as blocked. + */ + const cancelPendingPiInputs = (managed: ManagedChatSession): void => { + for (const [itemId, pending] of [...managed.localPendingInputs]) { + if (pending.request.source !== "pi") continue; + managed.localPendingInputs.delete(itemId); + pending.resolve({ decision: "cancel" }); + emitPendingInputResolved(managed, { + itemId, + decision: "cancel", + turnId: pending.request.turnId ?? null, + questions: pending.request.questions, + }); + } + }; + /** Tear down the active runtime, releasing all resources and cancelling pending approvals. */ const teardownRuntime = ( managed: ManagedChatSession, @@ -16588,6 +16725,7 @@ export function createAgentChatService(args: { const rt = managed.runtime; rt.interrupted = true; cancelQueuedSteers(managed, rt, "interrupted"); + cancelPendingPiInputs(managed); if (preserveProviderResumeState) persistChatState(managed); if (isPiSdkPooledAlive(rt.sdk)) { void rt.sdk.abort().catch(() => {}); @@ -37518,6 +37656,7 @@ export function createAgentChatService(args: { // ignore } cancelQueuedSteers(managed, rt, "interrupted"); + cancelPendingPiInputs(managed); persistChatState(managed); return result; } diff --git a/apps/desktop/src/main/services/chat/piSdkEventMapper.test.ts b/apps/desktop/src/main/services/chat/piSdkEventMapper.test.ts new file mode 100644 index 000000000..d04cf8e9d --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkEventMapper.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + PI_APPROVAL_ALLOW, + PI_APPROVAL_ALLOW_SESSION, + PI_UI_ANSWER_ID, + piExtensionLoadNotice, + piUiNoticeToChatEvents, + piUiRequestToPendingInput, + piUiResponseFromAnswer, +} from "./piSdkEventMapper"; +import type { PiSdkUiRequestPayload } from "./piSdkProtocol"; + +const question: PiSdkUiRequestPayload = { + origin: "tool", + kind: "select", + title: "Database", + message: "Which database?", + options: [{ value: "0", label: "Postgres", description: "Managed" }, { value: "1", label: "SQLite" }], +}; +const freeform: PiSdkUiRequestPayload = { origin: "tool", kind: "text", title: "Why", message: "Why that one?" }; +const approval: PiSdkUiRequestPayload = { origin: "approval", kind: "confirm", title: "Run bash?", message: "npm test" }; + +describe("piUiRequestToPendingInput", () => { + it("builds a pick-only card for a choice prompt", () => { + const request = piUiRequestToPendingInput("req-1", question, "turn-1"); + expect(request).toMatchObject({ requestId: "req-1", itemId: "req-1", source: "pi", kind: "structured_question", turnId: "turn-1" }); + // Free text cannot map back to an option id, which is what Pi expects. + expect(request.allowsFreeform).toBe(false); + expect(request.questions[0]).toMatchObject({ id: PI_UI_ANSWER_ID, header: "Database", allowsFreeform: false }); + expect(request.questions[0]!.options).toEqual([ + { label: "Postgres", value: "0", description: "Managed" }, + { label: "SQLite", value: "1" }, + ]); + }); + + it("builds a freeform card when the prompt has no options", () => { + const request = piUiRequestToPendingInput("req-2", freeform, null); + expect(request.kind).toBe("question"); + expect(request.allowsFreeform).toBe(true); + expect(request.questions[0]!.options).toBeUndefined(); + }); + + it("marks a secret prompt so the answer is not echoed", () => { + const request = piUiRequestToPendingInput("req-3", { ...freeform, kind: "secret" }, null); + expect(request.questions[0]!.isSecret).toBe(true); + }); + + it("renders an approval as an approval card", () => { + expect(piUiRequestToPendingInput("req-4", approval, null).kind).toBe("approval"); + }); +}); + +describe("piUiResponseFromAnswer", () => { + it("returns the chosen option value", () => { + expect(piUiResponseFromAnswer(question, { decision: "accept", answers: { [PI_UI_ANSWER_ID]: "1" } })) + .toEqual({ ok: true, value: "1" }); + }); + + it("unwraps a multi-select answer and falls back to free text", () => { + expect(piUiResponseFromAnswer(question, { decision: "accept", answers: { [PI_UI_ANSWER_ID]: ["0"] } })) + .toEqual({ ok: true, value: "0" }); + expect(piUiResponseFromAnswer(freeform, { decision: "accept", responseText: "because it is simple" })) + .toEqual({ ok: true, value: "because it is simple" }); + }); + + it("reports a dismissed or declined card as unanswered", () => { + expect(piUiResponseFromAnswer(question, { decision: "cancel" })).toEqual({ ok: false }); + expect(piUiResponseFromAnswer(question, { decision: "decline" })).toEqual({ ok: false }); + }); + + it("keeps an approval an allow even when the surface attaches a comment", () => { + expect(piUiResponseFromAnswer(approval, { decision: "accept", responseText: "looks fine" })) + .toEqual({ ok: true, value: PI_APPROVAL_ALLOW }); + }); + + it("maps an approval decision onto the value the gate reads", () => { + expect(piUiResponseFromAnswer(approval, { decision: "accept" })).toEqual({ ok: true, value: PI_APPROVAL_ALLOW }); + expect(piUiResponseFromAnswer(approval, { decision: "accept_for_session" })) + .toEqual({ ok: true, value: PI_APPROVAL_ALLOW_SESSION }); + // An approval with no decision at all is a denial, never a silent allow. + expect(piUiResponseFromAnswer(approval, {})).toEqual({ ok: false }); + }); +}); + +describe("piUiNoticeToChatEvents", () => { + it("renders progress as transient activity and anything else as a notice", () => { + expect(piUiNoticeToChatEvents({ origin: "extension", level: "progress", message: "indexing" }, "turn-1")) + .toEqual([{ type: "activity", activity: "working", detail: "indexing", turnId: "turn-1" }]); + expect(piUiNoticeToChatEvents({ origin: "extension", level: "warn", message: "skipped" })) + .toEqual([{ type: "system_notice", noticeKind: "warning", message: "skipped" }]); + }); + + it("drops an empty message rather than emitting a blank row", () => { + expect(piUiNoticeToChatEvents({ origin: "tool", level: "info", message: " " })).toEqual([]); + }); +}); + +describe("piExtensionLoadNotice", () => { + it("names the extensions that loaded", () => { + const [notice] = piExtensionLoadNotice([{ id: "/x/git-info/index.ts", name: "git-info" }], null); + expect(notice).toMatchObject({ type: "system_notice", noticeKind: "info" }); + expect((notice as { message: string }).message).toContain("git-info"); + }); + + it("says which tools this Pi build cannot gate", () => { + const [notice] = piExtensionLoadNotice(undefined, null, ["bash", "write"]); + expect(notice).toMatchObject({ noticeKind: "warning" }); + expect((notice as { message: string }).message).toContain("bash, write"); + }); + + it("reports a load failure separately from a clean empty list", () => { + expect(piExtensionLoadNotice([], null)).toEqual([]); + const events = piExtensionLoadNotice([], "bad.ts: boom"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ noticeKind: "warning" }); + }); +}); diff --git a/apps/desktop/src/main/services/chat/piSdkEventMapper.ts b/apps/desktop/src/main/services/chat/piSdkEventMapper.ts index fc6b55072..2a383a532 100644 --- a/apps/desktop/src/main/services/chat/piSdkEventMapper.ts +++ b/apps/desktop/src/main/services/chat/piSdkEventMapper.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import type { AgentChatEvent } from "../../../shared/types/chat"; +import type { AgentChatApprovalDecision, AgentChatEvent, PendingInputRequest } from "../../../shared/types/chat"; +import type { PiSdkExtensionInfo, PiSdkUiNoticePayload, PiSdkUiRequestPayload, PiSdkUiResponsePayload } from "./piSdkProtocol"; function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -81,3 +82,142 @@ export function mapPiSdkEventToChatEvents( } return []; } + +/** + * Render a non-blocking message from a Pi tool or extension. + * + * Sign-in notices never arrive here: login runs on `piAuthService`'s own + * worker and is rendered by Settings, not by a chat. + */ +export function piUiNoticeToChatEvents( + payload: PiSdkUiNoticePayload, + turnId?: string, +): AgentChatEvent[] { + const message = payload.message.trim(); + if (!message) return []; + + if (payload.level === "progress") { + return [{ type: "activity", activity: "working", detail: message, ...(turnId ? { turnId } : {}) }]; + } + return [{ + type: "system_notice", + noticeKind: payload.level === "info" ? "info" : "warning", + message, + ...(turnId ? { turnId } : {}), + }]; +} + +/** + * One-time summary of which Pi extensions loaded into this chat and what the + * UI bridge cannot render, so a missing terminal widget reads as a known + * limitation rather than a broken extension. + */ +export function piExtensionLoadNotice( + extensions: PiSdkExtensionInfo[] | undefined, + extensionsError: string | null | undefined, + ungateableTools: string[] = [], +): AgentChatEvent[] { + const events: AgentChatEvent[] = []; + if (ungateableTools.length) { + const list = ungateableTools.join(", "); + events.push({ + type: "system_notice", + noticeKind: "warning", + message: `This Pi build cannot ask before running ${list}, so ${ungateableTools.length === 1 ? "it is" : "they are"} unavailable in this chat.`, + }); + } + if (extensions?.length) { + const names = extensions.map((extension) => extension.name?.trim() || extension.id); + events.push({ + type: "system_notice", + noticeKind: "info", + // States the boundary plainly: an extension's own tools are outside + // ADE's permission modes, because their names are not knowable until + // after the session exists. + message: `Pi extensions active: ${names.join(", ")}. Their own tools run with the extension's permissions, not this chat's.`, + }); + } + if (extensionsError?.trim()) { + events.push({ + type: "system_notice", + noticeKind: "warning", + message: `Some Pi extensions did not load: ${extensionsError.trim()}`, + }); + } + return events; +} + +/** The single question id every Pi card uses; answers come back keyed by it. */ +export const PI_UI_ANSWER_ID = "answer"; + +/** Values `createPiApprovalGate` recognizes; shared so the two cannot drift. */ +export const PI_APPROVAL_ALLOW = "allow"; +export const PI_APPROVAL_ALLOW_SESSION = "allow_session"; + +/** Turn a blocking worker request into the ADE card that answers it. */ +export function piUiRequestToPendingInput( + requestId: string, + payload: PiSdkUiRequestPayload, + turnId: string | null, +): PendingInputRequest { + const options = payload.options?.map((option) => ({ + label: option.label, + value: option.value, + ...(option.description ? { description: option.description } : {}), + })); + const hasOptions = Boolean(options?.length); + return { + requestId, + itemId: requestId, + source: "pi", + kind: payload.origin === "approval" + ? "approval" + : hasOptions ? "structured_question" : "question", + title: payload.title ?? null, + description: payload.message, + questions: [{ + id: PI_UI_ANSWER_ID, + ...(payload.title ? { header: payload.title } : {}), + question: payload.message, + ...(hasOptions ? { options } : {}), + // A choice must be answered by picking: free text would not match an + // option id, which is what Pi expects back. + allowsFreeform: !hasOptions, + ...(payload.kind === "secret" ? { isSecret: true } : {}), + }], + allowsFreeform: !hasOptions, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { + pi: true, + origin: payload.origin, + promptKind: payload.kind, + ...(payload.sourceId ? { sourceId: payload.sourceId } : {}), + }, + turnId, + }; +} + +/** Translate the user's card answer into the reply the worker is waiting on. */ +export function piUiResponseFromAnswer( + payload: PiSdkUiRequestPayload, + response: { + decision?: AgentChatApprovalDecision; + answers?: Record; + responseText?: string | null; + }, +): PiSdkUiResponsePayload { + if (response.decision === "cancel" || response.decision === "decline") return { ok: false }; + const raw = response.answers?.[PI_UI_ANSWER_ID]; + const picked = Array.isArray(raw) ? raw[0] : raw; + const value = typeof picked === "string" && picked.length + ? picked + : typeof response.responseText === "string" ? response.responseText : ""; + if (payload.origin !== "approval") return { ok: true, value }; + // An approval card carries its verdict in the decision, not in text — a + // surface that attaches a comment to an accept must still read as an allow + // rather than as an unrecognized value the gate would deny. + if (response.decision === "accept_for_session") return { ok: true, value: PI_APPROVAL_ALLOW_SESSION }; + if (response.decision === "accept") return { ok: true, value: PI_APPROVAL_ALLOW }; + return value ? { ok: true, value } : { ok: false }; +} diff --git a/apps/desktop/src/main/services/chat/piSdkPool.ts b/apps/desktop/src/main/services/chat/piSdkPool.ts index c005b5fb1..cc069e439 100644 --- a/apps/desktop/src/main/services/chat/piSdkPool.ts +++ b/apps/desktop/src/main/services/chat/piSdkPool.ts @@ -17,7 +17,11 @@ import { type PiSdkPackageLocation, type PiSdkPromptPayload, type PiSdkImage, + type PiSdkLoginPayload, type PiSdkReady, + type PiSdkUiNoticePayload, + type PiSdkUiRequestPayload, + type PiSdkUiResponsePayload, type PiSdkWorkerInit, type PiSdkWorkerRequest, } from "./piSdkProtocol"; @@ -28,6 +32,16 @@ export type PiSdkBridge = { onLifecycle: ((event: string, requestId?: string, detail?: JsonValue) => void) | null; onError: ((error: Error, operation?: string, requestId?: string) => void) | null; onReady: ((ready: PiSdkReady) => void) | null; + /** + * The worker is blocked on a human answer. Reply with `respondToUi`. + * + * When no handler is installed the pool answers `ok: false` immediately, so + * an unattended worker fails closed instead of hanging a turn. + */ + onUiRequest: ((requestId: string, payload: PiSdkUiRequestPayload) => void) | null; + onUiNotice: ((payload: PiSdkUiNoticePayload) => void) | null; + /** The worker settled a request itself; drop the card raised for it. */ + onUiCancel: ((requestId: string) => void) | null; }; type PiSdkRequestType = PiSdkWorkerRequest["type"]; @@ -40,6 +54,8 @@ type PiSdkRequestArgs = K extends | "follow_up" | "set_model" | "set_thinking" + | "login" + | "ui_response" ? [payload: PiSdkRequestPayload] : K extends "compact" ? [payload?: PiSdkRequestPayload] @@ -71,6 +87,14 @@ export type PiSdkPooled = { compact: (payload?: PiSdkCompactPayload) => Promise; requestModels: () => Promise; requestAuth: () => Promise; + /** + * Run Pi's native sign-in for one provider. Deliberately untimed: a device + * code flow legitimately takes minutes. Use `cancelLogin` to stop it. + */ + login: (payload: PiSdkLoginPayload) => Promise; + cancelLogin: () => void; + /** Answer a `onUiRequest`. Fire-and-forget; the worker owns the waiter. */ + respondToUi: (requestId: string, response: PiSdkUiResponsePayload) => void; dispose: () => void; /** Resolves only after the worker process has actually exited. */ waitForExit: () => Promise; @@ -90,6 +114,9 @@ export type AcquirePiSdkConnectionArgs = PiSdkPackageLocation & { session?: PiSdkWorkerInit["session"]; tools?: string[]; noTools?: PiSdkWorkerInit["noTools"]; + extensions?: boolean; + askUserTool?: boolean; + approvalTools?: string[]; /** Usually process.env; never put auth.json or API keys in this payload. */ baseEnv?: NodeJS.ProcessEnv; logger?: Logger; @@ -203,6 +230,9 @@ function createPiSdkConnection(args: AcquirePiSdkConnectionArgs): Promise((resolve) => { resolveExit = resolve; }); - const bridge: PiSdkBridge = { onEvent: null, onLifecycle: null, onError: null, onReady: null }; + const bridge: PiSdkBridge = { onEvent: null, onLifecycle: null, onError: null, onReady: null, onUiRequest: null, onUiNotice: null, onUiCancel: null }; let terminalFailure: ((error: Error) => void) | null = null; const ipcClosed = (): Error => new Error("Pi SDK worker IPC channel is closed."); @@ -340,6 +370,15 @@ function createPiSdkConnection(args: AcquirePiSdkConnectionArgs): Promise worker.request("auth"), + login: (payload) => worker.request("login", payload), + cancelLogin: () => { + send({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "login_cancel", requestId: randomUUID() }); + }, + respondToUi: (requestId, response) => { + // The worker keys its waiter by this id, so the reply reuses it rather + // than opening a request of its own. + send({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "ui_response", requestId, payload: response }); + }, waitForExit: () => exitPromise, dispose: () => { disposing = true; @@ -408,6 +447,24 @@ function createPiSdkConnection(args: AcquirePiSdkConnectionArgs): Promise { }); }); }); + +describe("protocol v2 message validation", () => { + const base = { protocolVersion: PI_SDK_PROTOCOL_VERSION, requestId: "r1" }; + + it("accepts the new worker requests and rejects malformed ones", () => { + expect(validatePiSdkWorkerRequest({ ...base, type: "login", payload: { providerId: "anthropic" } })).toBeNull(); + expect(validatePiSdkWorkerRequest({ ...base, type: "login_cancel" })).toBeNull(); + expect(validatePiSdkWorkerRequest({ ...base, type: "ui_response", payload: { ok: true, value: "x" } })).toBeNull(); + + expect(validatePiSdkWorkerRequest({ ...base, type: "login", payload: { providerId: " " } })).toMatch(/providerId/u); + expect(validatePiSdkWorkerRequest({ ...base, type: "login", payload: { providerId: "a", method: "" } })).toMatch(/method/u); + expect(validatePiSdkWorkerRequest({ ...base, type: "ui_response", payload: { value: "x" } })).toMatch(/ok/u); + expect(validatePiSdkWorkerRequest({ ...base, type: "ui_response", payload: { ok: true, value: 5 } })).toMatch(/value/u); + }); + + it("rejects init options that would widen the tool surface", () => { + const init = { + ...base, + type: "init", + payload: { protocolVersion: PI_SDK_PROTOCOL_VERSION, packageRoot: "/pkg", cwd: "/w", agentDir: "/a" }, + }; + expect(validatePiSdkWorkerRequest(init)).toBeNull(); + expect(validatePiSdkWorkerRequest({ ...init, payload: { ...init.payload, extensions: "yes" } })).toMatch(/extensions/u); + expect(validatePiSdkWorkerRequest({ ...init, payload: { ...init.payload, askUserTool: 1 } })).toMatch(/askUserTool/u); + expect(validatePiSdkWorkerRequest({ ...init, payload: { ...init.payload, approvalTools: ["bash", ""] } })).toMatch(/approvalTools/u); + }); + + it("validates ui_request and ui_notice coming back from the worker", () => { + const ok = { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "ui_request", + requestId: "u1", + payload: { origin: "tool", kind: "select", message: "Which?", options: [{ value: "0", label: "A" }] }, + }; + expect(validatePiSdkWorkerResponse(ok)).toBeNull(); + expect(validatePiSdkWorkerResponse({ ...ok, payload: { ...ok.payload, origin: "elsewhere" } })).toMatch(/origin/u); + expect(validatePiSdkWorkerResponse({ ...ok, payload: { ...ok.payload, kind: "slider" } })).toMatch(/kind/u); + expect(validatePiSdkWorkerResponse({ ...ok, payload: { ...ok.payload, message: 7 } })).toMatch(/message/u); + expect(validatePiSdkWorkerResponse({ ...ok, payload: { ...ok.payload, options: [{ value: "0" }] } })).toMatch(/options/u); + expect(validatePiSdkWorkerResponse({ ...ok, requestId: "" })).toMatch(/requestId/u); + + const notice = { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "ui_notice", + payload: { origin: "extension", level: "warn", message: "hi" }, + }; + expect(validatePiSdkWorkerResponse(notice)).toBeNull(); + expect(validatePiSdkWorkerResponse({ ...notice, payload: { ...notice.payload, level: "fatal" } })).toMatch(/level/u); + }); + + it("keeps extension metadata on ready payloads JSON-safe", () => { + const ready = { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + packageRoot: "/pkg", + packageEntry: "/pkg/dist/index.js", + version: "0.84.0", + sessionFile: null, + sessionId: null, + currentModel: null, + thinkingLevel: null, + availableModels: [], + }; + expect(validatePiSdkWorkerResult("init", { ...ready, extensions: [{ id: "/a/b.js", name: "b" }] })).toBeNull(); + expect(validatePiSdkWorkerResult("init", { ...ready, extensions: [{ name: "b" }] })).toMatch(/extensions/u); + expect(validatePiSdkWorkerResult("init", { ...ready, extensionsError: 3 })).toMatch(/extensionsError/u); + expect(validatePiSdkWorkerResult("login", { ok: true, providerId: "anthropic" })).toBeNull(); + expect(validatePiSdkWorkerResult("login", { providerId: "anthropic" })).toMatch(/ok/u); + }); +}); diff --git a/apps/desktop/src/main/services/chat/piSdkProtocol.ts b/apps/desktop/src/main/services/chat/piSdkProtocol.ts index aa1a86612..347e68800 100644 --- a/apps/desktop/src/main/services/chat/piSdkProtocol.ts +++ b/apps/desktop/src/main/services/chat/piSdkProtocol.ts @@ -5,7 +5,7 @@ * the bridge even when the user has not installed Pi. */ -export const PI_SDK_PROTOCOL_VERSION = 1 as const; +export const PI_SDK_PROTOCOL_VERSION = 2 as const; export const PI_SDK_MIN_NODE = "22.19.0" as const; export type JsonPrimitive = string | number | boolean | null; @@ -50,6 +50,20 @@ export type PiSdkWorkerInit = PiSdkPackageLocation & { /** Restrict built-in tools when an integration needs a read-only session. */ tools?: string[]; noTools?: "all" | "builtin"; + /** + * Load the user's Pi extensions and bind them to ADE's UI bridge. Off by + * default: extensions run arbitrary code, so ADE chat opts in explicitly and + * the Pi CLI stays the unrestricted escape hatch. + */ + extensions?: boolean; + /** Expose ADE's `ask_user` tool so the model can ask a blocking question. */ + askUserTool?: boolean; + /** + * Built-in tools that must clear an ADE approval card before each call. + * Empty means the `tools` allowlist is the only gate (the pre-approval + * behaviour). + */ + approvalTools?: string[]; }; export type PiSdkImage = { @@ -76,8 +90,80 @@ export type PiSdkCompactPayload = { customInstructions?: string | null; }; +/** + * Interactive surfaces the worker can drive from inside the Pi SDK. + * + * Pi hands ADE three different callback-shaped APIs — `AuthInteraction` during + * login, custom tool handlers, and an extension's UI context — that all need + * the same thing: ask the human something and block until they answer. One + * reverse-RPC channel serves all three so the desktop process has a single + * place to render cards and a single place to fail them closed. + */ +export type PiSdkUiOrigin = "auth" | "tool" | "extension" | "approval"; +/** + * `manual_code` is Pi's paste-the-code-from-your-browser step. It is a text + * field, but it is kept distinct so the card can say so — and because Pi races + * it against its OAuth callback server and aborts it when the callback wins. + */ +export type PiSdkUiPromptKind = "text" | "secret" | "select" | "confirm" | "manual_code"; + +export type PiSdkUiOption = { + value: string; + label: string; + description?: string | null; +}; + +export type PiSdkUiRequestPayload = { + origin: PiSdkUiOrigin; + kind: PiSdkUiPromptKind; + /** Card heading. Falls back to a per-origin default in the desktop process. */ + title?: string | null; + message: string; + placeholder?: string | null; + defaultValue?: string | null; + options?: PiSdkUiOption[]; + /** Provider id, extension id, or tool name — shown so the user knows who is asking. */ + sourceId?: string | null; + detail?: JsonValue; +}; + +export type PiSdkUiResponsePayload = { + /** False when the user dismissed, the turn was aborted, or the card timed out. */ + ok: boolean; + value?: string | null; + error?: string | null; +}; + +export type PiSdkUiNoticePayload = { + origin: PiSdkUiOrigin; + level: "info" | "warn" | "error" | "progress"; + message: string; + sourceId?: string | null; + /** + * Structured extras the desktop process renders specially — notably + * `{ url }` for an OAuth URL and `{ userCode, verificationUri }` for a + * device-code grant. + */ + detail?: JsonValue; +}; + +export type PiSdkLoginPayload = { + providerId: string; + /** Provider-specific login method id (`oauth`, `api`, …). Omit for Pi's default. */ + method?: string | null; +}; + +export type PiSdkExtensionInfo = { + id: string; + name?: string | null; + version?: string | null; +}; + export type PiSdkWorkerRequest = | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "init"; requestId: string; payload: PiSdkWorkerInit } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "login"; requestId: string; payload: PiSdkLoginPayload } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "login_cancel"; requestId: string } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "ui_response"; requestId: string; payload: PiSdkUiResponsePayload } | { 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[] } } @@ -99,6 +185,12 @@ export type PiSdkReady = { currentModel: JsonValue | null; thinkingLevel: string | null; availableModels: JsonValue[]; + /** Extensions actually loaded into this session; empty when opted out. */ + extensions?: PiSdkExtensionInfo[]; + /** Why extensions could not be loaded, when the caller asked for them. */ + extensionsError?: string | null; + /** Tools withheld because this Pi build cannot supply a gateable definition. */ + ungateableTools?: string[]; }; export type PiSdkLifecycleName = @@ -124,6 +216,10 @@ export type PiSdkWorkerResponse = detail?: JsonValue; } | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "ready"; ready: PiSdkReady } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "ui_request"; requestId: string; payload: PiSdkUiRequestPayload } + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "ui_notice"; payload: PiSdkUiNoticePayload } + /** The worker settled this request itself (extension timeout, abort, teardown). */ + | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "ui_cancel"; requestId: string } | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; type: "sdk_event"; event: JsonValue; requestId?: string } | { protocolVersion: typeof PI_SDK_PROTOCOL_VERSION; @@ -165,6 +261,60 @@ function isJsonValue(value: unknown, depth = 0): value is JsonValue { return Object.values(value).every((item) => isJsonValue(item, depth + 1)); } +const UI_ORIGINS: PiSdkUiOrigin[] = ["auth", "tool", "extension", "approval"]; +const UI_PROMPT_KINDS: PiSdkUiPromptKind[] = ["text", "secret", "select", "confirm", "manual_code"]; + +function validateUiRequestPayload(payload: unknown): string | null { + if (!isRecord(payload)) return "Pi SDK ui_request payload must be an object."; + if (!UI_ORIGINS.includes(payload.origin as PiSdkUiOrigin)) return "Pi SDK ui_request origin is invalid."; + if (!UI_PROMPT_KINDS.includes(payload.kind as PiSdkUiPromptKind)) return "Pi SDK ui_request kind is invalid."; + if (typeof payload.message !== "string") return "Pi SDK ui_request message must be a string."; + for (const key of ["title", "placeholder", "defaultValue", "sourceId"] as const) { + if (payload[key] != null && typeof payload[key] !== "string") return `Pi SDK ui_request ${key} must be a string or null.`; + } + if (payload.options !== undefined) { + if (!Array.isArray(payload.options)) return "Pi SDK ui_request options must be an array."; + for (const option of payload.options) { + if (!isRecord(option) || typeof option.value !== "string" || typeof option.label !== "string") { + return "Pi SDK ui_request options must contain value and label strings."; + } + if (option.description != null && typeof option.description !== "string") { + return "Pi SDK ui_request option description must be a string or null."; + } + } + } + if (payload.detail !== undefined && !isJsonValue(payload.detail)) return "Pi SDK ui_request detail must be JSON-safe."; + return null; +} + +/** Shared by the `ready` event and by every result that returns ready data. */ +function validateReadyShape(ready: Record, label: string): string | null { + if (ready.protocolVersion !== PI_SDK_PROTOCOL_VERSION) return `Pi SDK ${label} has an invalid protocol version.`; + if (!nonEmptyString(ready.packageRoot) || !nonEmptyString(ready.packageEntry)) return `Pi SDK ${label} is missing package paths.`; + if (!("version" in ready) || (ready.version !== null && typeof ready.version !== "string")) return `Pi SDK ${label} version must be a string or null.`; + if (!("sessionFile" in ready) || (ready.sessionFile !== null && typeof ready.sessionFile !== "string")) return `Pi SDK ${label} sessionFile must be a string or null.`; + if (!("sessionId" in ready) || (ready.sessionId !== null && typeof ready.sessionId !== "string")) return `Pi SDK ${label} sessionId must be a string or null.`; + if (!("currentModel" in ready) || (ready.currentModel !== null && !isJsonValue(ready.currentModel))) return `Pi SDK ${label} currentModel must be JSON-safe.`; + if (!("thinkingLevel" in ready) || (ready.thinkingLevel !== null && typeof ready.thinkingLevel !== "string")) return `Pi SDK ${label} thinkingLevel must be a string or null.`; + if (!Array.isArray(ready.availableModels) || !ready.availableModels.every((model) => isJsonValue(model))) return `Pi SDK ${label} availableModels must be JSON-safe.`; + if (ready.extensions !== undefined && !isExtensionInfoList(ready.extensions)) return `Pi SDK ${label} extensions must be a list of {id}.`; + if (ready.extensionsError !== undefined && ready.extensionsError !== null && typeof ready.extensionsError !== "string") { + return `Pi SDK ${label} extensionsError must be a string or null.`; + } + if (ready.ungateableTools !== undefined + && (!Array.isArray(ready.ungateableTools) || !ready.ungateableTools.every((tool) => nonEmptyString(tool)))) { + return `Pi SDK ${label} ungateableTools must be an array of non-empty strings.`; + } + return null; +} + +function isExtensionInfoList(value: unknown): boolean { + return Array.isArray(value) && value.every((item) => isRecord(item) + && nonEmptyString(item.id) + && (item.name == null || typeof item.name === "string") + && (item.version == null || typeof item.version === "string")); +} + 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); @@ -210,6 +360,16 @@ export function validatePiSdkWorkerRequest(raw: unknown): string | null { if (payload.inventoryOnly != null && typeof payload.inventoryOnly !== "boolean") { return "Pi SDK inventoryOnly must be a boolean."; } + if (payload.extensions != null && typeof payload.extensions !== "boolean") { + return "Pi SDK extensions must be a boolean."; + } + if (payload.askUserTool != null && typeof payload.askUserTool !== "boolean") { + return "Pi SDK askUserTool must be a boolean."; + } + if (payload.approvalTools != null && (!Array.isArray(payload.approvalTools) + || payload.approvalTools.some((tool) => !nonEmptyString(tool)))) { + return "Pi SDK approvalTools must be an array of non-empty strings."; + } if (payload.session != null && !isSessionTarget(payload.session)) return "Pi SDK session target is invalid."; return null; } @@ -227,10 +387,17 @@ export function validatePiSdkWorkerRequest(raw: unknown): string | null { 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 === "login") { + if (!isRecord(payload) || !nonEmptyString(payload.providerId)) return "Pi SDK login requires a providerId."; + if (payload.method != null && !nonEmptyString(payload.method)) return "Pi SDK login method cannot be empty."; + } else if (type === "ui_response") { + if (!isRecord(payload) || typeof payload.ok !== "boolean") return "Pi SDK ui_response requires ok."; + if (payload.value != null && typeof payload.value !== "string") return "Pi SDK ui_response value must be a string or null."; + if (payload.error != null && typeof payload.error !== "string") return "Pi SDK ui_response error must be a string or null."; } 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)) { + } else if (!["init", "abort", "dispose", "models", "auth", "compact", "login", "login_cancel", "ui_response"].includes(type)) { return `Unsupported Pi SDK worker request: ${String(type)}.`; } return null; @@ -262,15 +429,26 @@ export function validatePiSdkWorkerResponse(raw: unknown): string | 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 validateReadyShape(raw.ready, "ready"); + } + + if (raw.type === "ui_request") { + if (!nonEmptyString(raw.requestId)) return "Pi SDK ui_request is missing requestId."; + return validateUiRequestPayload(raw.payload); + } + + if (raw.type === "ui_cancel") { + return nonEmptyString(raw.requestId) ? null : "Pi SDK ui_cancel is missing requestId."; + } + + if (raw.type === "ui_notice") { + const payload = raw.payload; + if (!isRecord(payload)) return "Pi SDK ui_notice payload must be an object."; + if (!UI_ORIGINS.includes(payload.origin as PiSdkUiOrigin)) return "Pi SDK ui_notice origin is invalid."; + if (!["info", "warn", "error", "progress"].includes(payload.level as string)) return "Pi SDK ui_notice level is invalid."; + if (typeof payload.message !== "string") return "Pi SDK ui_notice message must be a string."; + if (payload.sourceId != null && typeof payload.sourceId !== "string") return "Pi SDK ui_notice sourceId must be a string or null."; + if (payload.detail !== undefined && !isJsonValue(payload.detail)) return "Pi SDK ui_notice detail must be JSON-safe."; return null; } @@ -320,14 +498,12 @@ export function validatePiSdkWorkerResult( ): 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 validateReadyShape(result, `${type} result`); + } + if (type === "login") { + if (!isRecord(result)) return "Pi SDK login result must be an object."; + if (typeof result.ok !== "boolean") return "Pi SDK login result must report ok."; + if (result.providerId !== undefined && !nonEmptyString(result.providerId)) return "Pi SDK login result providerId must be a non-empty string."; return null; } if (type === "models") { diff --git a/apps/desktop/src/main/services/chat/piSdkUiBridge.test.ts b/apps/desktop/src/main/services/chat/piSdkUiBridge.test.ts new file mode 100644 index 000000000..adb524833 --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkUiBridge.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PI_LOGIN_CANCELLED_MESSAGE, + createPiApprovalGate, + createPiAuthInteraction, + createPiExtensionUiContext, + createPiUiBridge, + piAskUserRequestFromArgs, + piAskUserResultText, + piApprovalSummary, + piUiOptionsFromLabels, + withPiApproval, + type PiToolDefinitionLike, +} from "./piSdkUiBridge"; +import { PI_APPROVAL_ALLOW, PI_APPROVAL_ALLOW_SESSION } from "./piSdkEventMapper"; +import type { PiSdkWorkerResponse } from "./piSdkProtocol"; + +function harness() { + const sent: PiSdkWorkerResponse[] = []; + let counter = 0; + const bridge = createPiUiBridge((message) => sent.push(message), () => `req-${++counter}`); + const requests = () => sent.filter((message) => message.type === "ui_request") as Array< + Extract + >; + const notices = () => sent.filter((message) => message.type === "ui_notice") as Array< + Extract + >; + return { bridge, sent, requests, notices }; +} + +describe("createPiUiBridge", () => { + it("round-trips an answer and stops tracking the request", async () => { + const { bridge, requests } = harness(); + const pending = bridge.request({ origin: "tool", kind: "text", message: "Which one?" }); + expect(bridge.pendingCount()).toBe(1); + + expect(bridge.resolve(requests()[0]!.requestId, { ok: true, value: "left" })).toBe(true); + await expect(pending).resolves.toBe("left"); + expect(bridge.pendingCount()).toBe(0); + }); + + it("resolves to null instead of rejecting when the user dismisses the card", async () => { + const { bridge, requests } = harness(); + const pending = bridge.request({ origin: "tool", kind: "text", message: "Which one?" }); + bridge.resolve(requests()[0]!.requestId, { ok: false }); + await expect(pending).resolves.toBeNull(); + }); + + it("ignores a second answer for the same request", async () => { + const { bridge, requests } = harness(); + const pending = bridge.request({ origin: "tool", kind: "text", message: "Which one?" }); + const requestId = requests()[0]!.requestId; + bridge.resolve(requestId, { ok: true, value: "first" }); + expect(bridge.resolve(requestId, { ok: true, value: "second" })).toBe(false); + await expect(pending).resolves.toBe("first"); + }); + + it("drains only the requested origin so a cancelled sign-in leaves a tool card alone", async () => { + const { bridge } = harness(); + const auth = bridge.request({ origin: "auth", kind: "text", message: "code?" }); + const tool = bridge.request({ origin: "tool", kind: "text", message: "which?" }); + + bridge.drain("auth"); + await expect(auth).resolves.toBeNull(); + expect(bridge.pendingCount()).toBe(1); + + bridge.drain(); + await expect(tool).resolves.toBeNull(); + }); + + it("refuses new requests once closed so teardown cannot hang a Pi callback", async () => { + const { bridge } = harness(); + const pending = bridge.request({ origin: "tool", kind: "text", message: "which?" }); + bridge.close(); + await expect(pending).resolves.toBeNull(); + await expect(bridge.request({ origin: "tool", kind: "text", message: "again?" })).resolves.toBeNull(); + }); + + it("tells the desktop when it settles a request on its own, so the card can close", async () => { + const { bridge, sent, requests } = harness(); + const controller = new AbortController(); + const pending = bridge.request({ origin: "extension", kind: "text", message: "which?" }, { signal: controller.signal }); + const requestId = requests()[0]!.requestId; + controller.abort(); + await pending; + expect(sent.some((message) => message.type === "ui_cancel" && message.requestId === requestId)).toBe(true); + }); + + it("does not echo a cancel for an answer the desktop supplied", async () => { + const { bridge, sent, requests } = harness(); + const pending = bridge.request({ origin: "tool", kind: "text", message: "which?" }); + bridge.resolve(requests()[0]!.requestId, { ok: true, value: "x" }); + await pending; + expect(sent.some((message) => message.type === "ui_cancel")).toBe(false); + }); + + it("survives a signal whose listener registration throws", async () => { + const { bridge } = harness(); + const hostile = { + aborted: false, + addEventListener: () => { throw new Error("hostile extension"); }, + removeEventListener: () => undefined, + }; + await expect(bridge.request({ origin: "extension", kind: "text", message: "x" }, { signal: hostile })) + .resolves.toBeNull(); + expect(bridge.pendingCount()).toBe(0); + }); + + it("honours an abort signal supplied by the caller", async () => { + const { bridge } = harness(); + const controller = new AbortController(); + const pending = bridge.request({ origin: "extension", kind: "text", message: "which?" }, { signal: controller.signal }); + controller.abort(); + await expect(pending).resolves.toBeNull(); + }); +}); + +describe("piUiOptionsFromLabels", () => { + it("disambiguates duplicate labels while preserving the original value", () => { + const mapped = piUiOptionsFromLabels(["Yes", "Yes", " padded "]); + expect(mapped.map((option) => option.label)).toEqual(["Yes", "Yes (2)", "padded"]); + // Pi expects the exact string it handed in, whitespace included. + expect(mapped[2]!.original).toBe(" padded "); + }); +}); + +describe("extension UI context", () => { + it("returns the extension's own option string for a selection", async () => { + const { bridge, requests } = harness(); + const ui = createPiExtensionUiContext({ bridge }); + const select = (ui.select as (t: string, o: string[]) => Promise)("Pick", ["alpha", "beta"]); + bridge.resolve(requests()[0]!.requestId, { ok: true, value: "1" }); + await expect(select).resolves.toBe("beta"); + }); + + it("denies a confirm the user never answered", async () => { + const { bridge, requests } = harness(); + const ui = createPiExtensionUiContext({ bridge }); + const confirm = (ui.confirm as (t: string, m: string) => Promise)("Delete?", "This cannot be undone."); + bridge.resolve(requests()[0]!.requestId, { ok: false }); + await expect(confirm).resolves.toBe(false); + }); + + it("warns once per unsupported terminal-only API", () => { + const { bridge, notices } = harness(); + const onUnsupported = vi.fn(); + const ui = createPiExtensionUiContext({ bridge, onUnsupported }); + (ui.setWidget as () => void)(); + (ui.setWidget as () => void)(); + expect(onUnsupported).toHaveBeenCalledTimes(1); + expect(notices()).toHaveLength(1); + }); + + it("suppresses a repeated status value but reports a changed one", () => { + const { bridge, notices } = harness(); + const ui = createPiExtensionUiContext({ bridge }); + const setStatus = ui.setStatus as (key: string, text?: string) => void; + setStatus("build", "compiling"); + setStatus("build", "compiling"); + setStatus("build", "linking"); + expect(notices().map((notice) => notice.payload.message)).toEqual(["build: compiling", "build: linking"]); + }); + + it("exposes a theme whose helpers return plain text", () => { + const { bridge } = harness(); + const ui = createPiExtensionUiContext({ bridge }); + const theme = ui.theme as { bold: (text: string) => string }; + expect(theme.bold("hello")).toBe("hello"); + }); +}); + +describe("auth interaction", () => { + it("answers a select prompt with the option id Pi expects", async () => { + const { bridge, requests } = harness(); + const interaction = createPiAuthInteraction(bridge, "anthropic"); + const pending = interaction.prompt({ + type: "select", + message: "How do you want to sign in?", + options: [{ id: "max", label: "Claude Max" }, { id: "key", label: "API key" }], + }); + const request = requests()[0]!; + expect(request.payload.options?.map((option) => option.value)).toEqual(["max", "key"]); + bridge.resolve(request.requestId, { ok: true, value: "max" }); + await expect(pending).resolves.toBe("max"); + }); + + it("rejects when the user cancels, which is how Pi ends a login", async () => { + const { bridge, requests } = harness(); + const interaction = createPiAuthInteraction(bridge, "anthropic"); + const pending = interaction.prompt({ type: "secret", message: "API key" }); + bridge.resolve(requests()[0]!.requestId, { ok: false }); + await expect(pending).rejects.toThrow(PI_LOGIN_CANCELLED_MESSAGE); + }); + + it("surfaces a device code with the value the user has to type", () => { + const { bridge, notices } = harness(); + createPiAuthInteraction(bridge, "github-copilot").notify({ + type: "device_code", + userCode: "ABCD-1234", + verificationUri: "https://github.com/login/device", + }); + const notice = notices()[0]!.payload; + expect(notice.message).toContain("ABCD-1234"); + expect(notice.detail).toMatchObject({ kind: "device_code", userCode: "ABCD-1234" }); + }); +}); + +describe("approval gating", () => { + const definition = (execute = vi.fn().mockResolvedValue({ content: [], details: null })): PiToolDefinitionLike => ({ + name: "bash", + label: "Bash", + description: "Run a command", + parameters: {}, + execute, + }); + + it("runs the tool once allowed", async () => { + const { bridge, requests } = harness(); + const execute = vi.fn().mockResolvedValue({ content: [], details: null }); + const wrapped = withPiApproval(definition(execute), createPiApprovalGate(bridge)); + const call = wrapped.execute("call-1", { command: "ls" }, undefined, undefined, {}); + bridge.resolve(requests()[0]!.requestId, { ok: true, value: PI_APPROVAL_ALLOW }); + await call; + expect(execute).toHaveBeenCalledTimes(1); + }); + + it("throws without running the tool when denied, so Pi marks the call failed", async () => { + const { bridge, requests } = harness(); + const execute = vi.fn(); + const wrapped = withPiApproval(definition(execute), createPiApprovalGate(bridge)); + const call = wrapped.execute("call-1", { command: "rm -rf /" }, undefined, undefined, {}); + bridge.resolve(requests()[0]!.requestId, { ok: false }); + await expect(call).rejects.toThrow(/denied this bash call/iu); + expect(execute).not.toHaveBeenCalled(); + }); + + it("stops asking after the user allows the tool for the session", async () => { + const { bridge, requests } = harness(); + const execute = vi.fn().mockResolvedValue({ content: [], details: null }); + const wrapped = withPiApproval(definition(execute), createPiApprovalGate(bridge)); + + const first = wrapped.execute("call-1", { command: "ls" }, undefined, undefined, {}); + bridge.resolve(requests()[0]!.requestId, { ok: true, value: PI_APPROVAL_ALLOW_SESSION }); + await first; + + await wrapped.execute("call-2", { command: "pwd" }, undefined, undefined, {}); + expect(requests()).toHaveLength(1); + expect(execute).toHaveBeenCalledTimes(2); + }); + + it("summarizes a call by its command or path rather than raw JSON", () => { + expect(piApprovalSummary("bash", { command: "npm test" })).toBe("npm test"); + expect(piApprovalSummary("write", { filePath: "/tmp/a.txt" })).toBe("/tmp/a.txt"); + expect(piApprovalSummary("edit", { unexpected: 1 })).toBe('{"unexpected":1}'); + }); +}); + +describe("ask_user arguments", () => { + it("builds a select card from options and a text card without them", () => { + const withOptions = piAskUserRequestFromArgs({ + question: "Which database?", + header: "Database", + options: [{ label: "Postgres", description: "Managed" }, { label: "" }], + }); + expect(withOptions.kind).toBe("select"); + // The blank label is dropped rather than rendering an unpickable choice. + expect(withOptions.options).toEqual([{ value: "0", label: "Postgres", description: "Managed" }]); + expect(piAskUserRequestFromArgs({ question: "Why?" }).kind).toBe("text"); + }); + + it("reports the chosen label back to the model, and says so when unanswered", () => { + const request = piAskUserRequestFromArgs({ question: "Which?", options: [{ label: "Postgres" }] }); + expect(piAskUserResultText(request, "0")).toBe("The user answered: Postgres"); + expect(piAskUserResultText(request, null)).toMatch(/did not answer/iu); + }); +}); diff --git a/apps/desktop/src/main/services/chat/piSdkUiBridge.ts b/apps/desktop/src/main/services/chat/piSdkUiBridge.ts new file mode 100644 index 000000000..b822f0b1d --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkUiBridge.ts @@ -0,0 +1,640 @@ +/** + * Worker-side bridge between Pi's callback-shaped UI APIs and ADE's chat cards. + * + * Pi asks the human questions from three unrelated places — `AuthInteraction` + * during login, a custom tool's `execute`, and an extension's UI context — and + * all three are plain callbacks that block until they get a value back. This + * module funnels them into one reverse-RPC channel so the desktop process has a + * single place to render a card and a single place to fail one closed. + * + * Deliberately free of Pi imports: the desktop process bundles this file into + * the worker, which loads the user's Pi installation only after init validation. + */ +import { PI_APPROVAL_ALLOW, PI_APPROVAL_ALLOW_SESSION } from "./piSdkEventMapper"; +import { + PI_SDK_PROTOCOL_VERSION, + type PiSdkUiNoticePayload, + type PiSdkUiOption, + type PiSdkUiOrigin, + type PiSdkUiPromptKind, + type PiSdkUiRequestPayload, + type PiSdkUiResponsePayload, + type PiSdkWorkerResponse, +} from "./piSdkProtocol"; + +export type PiUiBridge = { + /** + * Ask the desktop process a question. Never rejects — a dismissed card, an + * aborted turn, or a disposed worker all resolve to `null`, so a Pi callback + * awaiting an answer degrades to "no answer" instead of throwing through the + * SDK's own error channel. + */ + request: (payload: PiSdkUiRequestPayload, options?: PiUiRequestOptions) => Promise; + notify: (payload: PiSdkUiNoticePayload) => void; + /** Settle a pending request with the desktop process's answer. */ + resolve: (requestId: string, response: PiSdkUiResponsePayload) => boolean; + /** + * Resolve outstanding requests to `null` without closing the bridge — used + * when a turn is aborted or a login is cancelled. Pass an origin to drain + * only that surface, so cancelling a sign-in leaves a tool's card alone. + */ + drain: (origin?: PiSdkUiOrigin) => void; + /** Drain everything and refuse further requests. Terminal. */ + close: () => void; + pendingCount: () => number; +}; + +export type PiUiRequestOptions = { + /** Honours the caller's own deadline; Pi extensions supply this themselves. */ + timeoutMs?: number; + signal?: { aborted: boolean; addEventListener: (type: "abort", listener: () => void, options?: { once?: boolean }) => void; removeEventListener: (type: "abort", listener: () => void) => void } | null; +}; + +type PendingUiRequest = { + origin: PiSdkUiOrigin; + /** `fromDesktop` suppresses the `ui_cancel` echo for an answer we were given. */ + settle: (value: string | null, fromDesktop?: boolean) => void; +}; + +export function createPiUiBridge( + post: (message: PiSdkWorkerResponse) => void, + makeId: () => string, +): PiUiBridge { + const pending = new Map(); + let closed = false; + + const request = (payload: PiSdkUiRequestPayload, options?: PiUiRequestOptions): Promise => { + if (closed || options?.signal?.aborted) return Promise.resolve(null); + const requestId = makeId(); + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + const onAbort = (): void => settle(null); + const settle = (value: string | null, fromDesktop = false): void => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + options?.signal?.removeEventListener("abort", onAbort); + pending.delete(requestId); + // A settle the desktop did not ask for — an extension's own timeout or + // abort signal — would otherwise leave its card on screen forever. + if (!fromDesktop && !closed) { + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "ui_cancel", requestId }); + } + resolve(value); + }; + + // Register before emitting so a synchronous answer cannot race the map. + pending.set(requestId, { origin: payload.origin, settle }); + try { + if (options?.timeoutMs !== undefined && options.timeoutMs > 0) { + timer = setTimeout(onAbort, options.timeoutMs); + timer.unref?.(); + } + options?.signal?.addEventListener("abort", onAbort, { once: true }); + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "ui_request", requestId, payload }); + } catch { + // An extension can hand over a signal-shaped object whose methods + // throw. Settling keeps the never-rejects contract and stops the entry + // leaking into `pending`. + settle(null); + } + }); + }; + + return { + request, + notify: (payload) => { + if (closed) return; + post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "ui_notice", payload }); + }, + resolve: (requestId, response) => { + const waiter = pending.get(requestId); + if (!waiter) return false; + waiter.settle(response.ok ? response.value ?? "" : null, true); + return true; + }, + drain: (origin) => { + for (const waiter of [...pending.values()]) { + if (origin === undefined || waiter.origin === origin) waiter.settle(null); + } + }, + close: () => { + closed = true; + for (const waiter of [...pending.values()]) waiter.settle(null); + pending.clear(); + }, + pendingCount: () => pending.size, + }; +} + +function trimmed(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +/** + * Pi hands extensions a bare `string[]` of choices and expects the chosen + * string back verbatim. ADE's cards need a stable value and a unique label, so + * duplicates are disambiguated for display while `value` keeps the original — + * including any whitespace the extension deliberately included. + */ +export function piUiOptionsFromLabels(labels: readonly string[]): Array { + const seen = new Map(); + return labels.map((label, index) => { + const base = trimmed(label) ?? `Option ${index + 1}`; + const count = (seen.get(base) ?? 0) + 1; + seen.set(base, count); + return { + value: String(index), + label: count === 1 ? base : `${base} (${count})`, + description: base, + original: label, + }; + }); +} + +/** + * A no-op ANSI theme. + * + * Extensions call `ui.theme.bold(...)` unconditionally while rendering, so the + * property has to exist and return plain text rather than be absent. + */ +const PLAIN_THEME = { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + underline: (text: string) => text, + inverse: (text: string) => text, + strikethrough: (text: string) => text, + dim: (text: string) => text, + getFgAnsi: () => "", + getBgAnsi: () => "", + getColorMode: () => "truecolor", + getThinkingBorderColor: () => (text: string) => text, + getBashModeBorderColor: () => (text: string) => text, +}; + +export type PiExtensionUiContextOptions = { + bridge: PiUiBridge; + /** Emitted once per unsupported API so a chatty extension cannot spam the thread. */ + onUnsupported?: (method: string) => void; +}; + +/** + * Implement Pi's `ExtensionUIContext` over ADE's card bridge. + * + * Three tiers: `select`/`confirm`/`input`/`editor` become real ADE cards, + * `notify`/`setStatus`/`setWorkingMessage`/`setTitle` become thread notices, + * and the TUI-only surface (widgets, footers, editor hooks, themes) no-ops — + * warning once for anything whose absence the user could actually notice. + */ +export function createPiExtensionUiContext(options: PiExtensionUiContextOptions): Record { + const { bridge } = options; + const warned = new Set(); + const statuses = new Map(); + let workingMessage: string | null = null; + + const warnUnsupported = (method: string): void => { + if (warned.has(method)) return; + warned.add(method); + options.onUnsupported?.(method); + bridge.notify({ + origin: "extension", + level: "warn", + message: `This Pi extension used "${method}", which only works in Pi's terminal UI. ADE ignored it.`, + detail: { method }, + }); + }; + const progress = (message: string): void => { + const normalized = trimmed(message); + if (normalized) bridge.notify({ origin: "extension", level: "progress", message: normalized }); + }; + const promptOptions = (opts: unknown): PiUiRequestOptions => { + const record = opts && typeof opts === "object" ? opts as Record : {}; + // An extension supplies these values, so a malformed `signal` must not + // reach `bridge.request` — calling addEventListener on it would throw and + // break the promise's never-rejecting contract. + const candidate = record.signal as { addEventListener?: unknown; removeEventListener?: unknown } | null | undefined; + const usableSignal = candidate + && typeof candidate.addEventListener === "function" + && typeof candidate.removeEventListener === "function" + ? candidate as PiUiRequestOptions["signal"] + : null; + return { + ...(typeof record.timeout === "number" && record.timeout > 0 ? { timeoutMs: record.timeout } : {}), + ...(usableSignal ? { signal: usableSignal } : {}), + }; + }; + + const context: Record = { + async select(title: string, choices: string[], opts?: unknown): Promise { + const mapped = piUiOptionsFromLabels(Array.isArray(choices) ? choices : []); + const answer = await bridge.request({ + origin: "extension", + kind: "select", + title: trimmed(title) ?? "Pi extension", + message: trimmed(title) ?? "Choose an option.", + options: mapped.map(({ value, label, description }) => ({ value, label, description })), + }, promptOptions(opts)); + if (answer === null) return undefined; + // Match on value first (what ADE's card sends back), then on the visible + // label so a surface that echoes the label still resolves. + const chosen = mapped.find((option) => option.value === answer) + ?? mapped.find((option) => option.label === answer); + return chosen?.original; + }, + async confirm(title: string, message?: string, opts?: unknown): Promise { + const answer = await bridge.request({ + origin: "extension", + kind: "confirm", + title: trimmed(title) ?? "Pi extension", + message: trimmed(message) ?? trimmed(title) ?? "Confirm this action?", + options: [ + { value: "yes", label: "Yes" }, + { value: "no", label: "No" }, + ], + }, promptOptions(opts)); + // Dismissal, timeout, and abort all land here as `null` — deny. + return answer === "yes"; + }, + async input(title: string, placeholder?: string, opts?: unknown): Promise { + const answer = await bridge.request({ + origin: "extension", + kind: "text", + title: trimmed(title) ?? "Pi extension", + message: trimmed(title) ?? trimmed(placeholder) ?? "Type a response.", + ...(trimmed(placeholder) ? { placeholder: trimmed(placeholder) } : {}), + }, promptOptions(opts)); + return answer === null ? undefined : answer; + }, + editor(title: string, prefill?: string): Promise { + return (context.input as (t: string, p?: string) => Promise)(title, prefill); + }, + notify(message: string, type?: string): void { + const normalized = trimmed(message); + if (!normalized) return; + if (type === "warning" || type === "error") { + bridge.notify({ origin: "extension", level: type === "error" ? "error" : "warn", message: normalized }); + return; + } + progress(normalized); + }, + setStatus(key: string, text?: string): void { + const normalizedKey = trimmed(key) ?? "status"; + const normalizedText = trimmed(text); + if (!normalizedText) { + statuses.delete(normalizedKey); + return; + } + // Extensions re-set the same status on every render tick. + if (statuses.get(normalizedKey) === normalizedText) return; + statuses.set(normalizedKey, normalizedText); + progress(`${normalizedKey}: ${normalizedText}`); + }, + setWorkingMessage(message: string): void { + const normalized = trimmed(message); + if (!normalized || normalized === workingMessage) return; + workingMessage = normalized; + progress(normalized); + }, + setTitle(title: string): void { + if (trimmed(title)) progress(trimmed(title)!); + }, + + // Visual-only in Pi's TUI; silently ignored because nothing is lost. + setWorkingVisible(): void {}, + setWorkingIndicator(): void {}, + setHiddenThinkingLabel(): void {}, + setToolsExpanded(): void {}, + getToolsExpanded(): boolean { return false; }, + + // Surfaces whose absence the user could notice — warn once each. + setWidget(): void { warnUnsupported("setWidget"); }, + setFooter(): void { warnUnsupported("setFooter"); }, + setHeader(): void { warnUnsupported("setHeader"); }, + async custom(): Promise { warnUnsupported("custom"); return undefined; }, + onTerminalInput(): () => void { warnUnsupported("onTerminalInput"); return () => undefined; }, + pasteToEditor(): void { warnUnsupported("pasteToEditor"); }, + setEditorText(): void { warnUnsupported("setEditorText"); }, + getEditorText(): string { return ""; }, + addAutocompleteProvider(): void { warnUnsupported("addAutocompleteProvider"); }, + setEditorComponent(): void { warnUnsupported("setEditorComponent"); }, + getEditorComponent(): undefined { return undefined; }, + + theme: PLAIN_THEME, + getAllThemes(): unknown[] { return []; }, + getTheme(): undefined { return undefined; }, + setTheme(): { success: false; error: string } { + return { success: false, error: "ADE renders Pi extensions with its own chat styling." }; + }, + }; + return context; +} + +/** Pi's `AuthPrompt`, narrowed to the fields ADE reads. */ +type PiAuthPrompt = { + type: "text" | "secret" | "select" | "manual_code"; + message: string; + placeholder?: string; + options?: ReadonlyArray<{ id: string; label: string; description?: string }>; + signal?: PiUiRequestOptions["signal"]; +}; + +/** Pi's `AuthEvent`, narrowed to the fields ADE reads. */ +type PiAuthEvent = + | { type: "info"; message: string; links?: ReadonlyArray<{ url: string; label?: string }> } + | { type: "auth_url"; url: string; instructions?: string } + | { type: "device_code"; userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number } + | { type: "progress"; message: string }; + +/** Message Pi's own TUI uses for a user-cancelled login; Pi suppresses its error toast on this text. */ +export const PI_LOGIN_CANCELLED_MESSAGE = "Login cancelled"; + +/** + * Build the `AuthInteraction` ADE hands to `ModelRuntime.login`. + * + * ADE never sees a token: Pi's own `AuthStorage` writes `auth.json` under its + * cross-process lock. This only renders Pi's prompts and returns what the user + * typed or picked. + */ +export function createPiAuthInteraction( + bridge: PiUiBridge, + providerId: string, + signal?: AbortSignal, +): { signal?: AbortSignal; prompt: (prompt: PiAuthPrompt) => Promise; notify: (event: PiAuthEvent) => void } { + return { + ...(signal ? { signal } : {}), + prompt: async (prompt: PiAuthPrompt): Promise => { + const kind: PiSdkUiPromptKind = prompt.type === "select" + ? "select" + : prompt.type === "secret" + ? "secret" + : prompt.type === "manual_code" + ? "manual_code" + : "text"; + const answer = await bridge.request({ + origin: "auth", + kind, + title: `Sign in to ${providerId}`, + message: trimmed(prompt.message) ?? "Pi needs a value to continue signing in.", + sourceId: providerId, + ...(trimmed(prompt.placeholder) ? { placeholder: trimmed(prompt.placeholder) } : {}), + // Pi resolves a `select` prompt by option id, so the card's value must + // be the id rather than the visible label. + ...(prompt.type === "select" && Array.isArray(prompt.options) + ? { + options: prompt.options.map((option) => ({ + value: option.id, + label: trimmed(option.label) ?? option.id, + ...(trimmed(option.description) ? { description: trimmed(option.description) } : {}), + })), + } + : {}), + }, { + // Pi aborts an individual prompt when an out-of-band step wins the race + // (a manual-code entry superseded by the OAuth callback server). + ...(prompt.signal ? { signal: prompt.signal } : {}), + }); + // Pi's contract is reject-on-cancel, not resolve-with-undefined. + if (answer === null) throw new Error(PI_LOGIN_CANCELLED_MESSAGE); + return answer; + }, + notify: (event: PiAuthEvent): void => { + if (event.type === "auth_url") { + bridge.notify({ + origin: "auth", + level: "info", + message: trimmed(event.instructions) ?? "Open this URL to continue signing in.", + sourceId: providerId, + detail: { kind: "auth_url", url: event.url }, + }); + return; + } + if (event.type === "device_code") { + bridge.notify({ + origin: "auth", + level: "info", + message: `Enter code ${event.userCode} at ${event.verificationUri}`, + sourceId: providerId, + detail: { + kind: "device_code", + userCode: event.userCode, + verificationUri: event.verificationUri, + ...(event.expiresInSeconds != null ? { expiresInSeconds: event.expiresInSeconds } : {}), + }, + }); + return; + } + if (event.type === "info") { + bridge.notify({ + origin: "auth", + level: "info", + message: trimmed(event.message) ?? "", + sourceId: providerId, + ...(event.links?.length ? { detail: { kind: "info", links: event.links.map((link) => ({ url: link.url, ...(link.label ? { label: link.label } : {}) })) } } : {}), + }); + return; + } + bridge.notify({ + origin: "auth", + level: "progress", + message: trimmed(event.message) ?? "Working…", + sourceId: providerId, + }); + }, + }; +} + +/** JSON Schema for the `ask_user` tool ADE injects into Pi sessions. */ +export const PI_ASK_USER_TOOL_NAME = "ask_user"; + +export const PI_ASK_USER_PARAMETERS = { + type: "object", + properties: { + question: { + type: "string", + description: "The question to ask. Be specific and self-contained.", + }, + header: { + type: "string", + description: "Short label for the question card, 12 characters or fewer.", + }, + options: { + type: "array", + description: "Optional choices. Omit for a free-text answer.", + items: { + type: "object", + properties: { + label: { type: "string", description: "Short choice text shown to the user." }, + description: { type: "string", description: "What picking this choice means." }, + }, + required: ["label"], + }, + }, + }, + required: ["question"], +} as const; + +export const PI_ASK_USER_DESCRIPTION = [ + "Ask the user a question and wait for their answer.", + "Use this only when you are blocked on a decision that is genuinely theirs —", + "one you cannot resolve from the request, the code, or a sensible default.", + "Provide `options` when the answer is a choice between known alternatives;", + "omit them for free-text. The tool returns the user's answer, or a note that", + "they declined, in which case proceed with your best judgement.", +].join(" "); + +/** Normalize the model's `ask_user` arguments into an ADE card request. */ +export function piAskUserRequestFromArgs(args: unknown): PiSdkUiRequestPayload { + const record = args && typeof args === "object" ? args as Record : {}; + const question = trimmed(record.question) ?? "The agent needs your input before it can continue."; + const rawOptions = Array.isArray(record.options) ? record.options : []; + const options = rawOptions.flatMap((option, index): PiSdkUiOption[] => { + const entry = option && typeof option === "object" ? option as Record : {}; + const label = trimmed(entry.label); + if (!label) return []; + return [{ + value: String(index), + label, + ...(trimmed(entry.description) ? { description: trimmed(entry.description) } : {}), + }]; + }); + return { + origin: "tool", + kind: options.length ? "select" : "text", + title: trimmed(record.header) ?? "Question", + message: question, + sourceId: PI_ASK_USER_TOOL_NAME, + ...(options.length ? { options } : {}), + }; +} + +/** Map an ADE card answer back to the text the model receives. */ +export function piAskUserResultText(request: PiSdkUiRequestPayload, answer: string | null): string { + if (answer === null) { + return "The user did not answer. Continue with your best judgement and state the assumption you made."; + } + const chosen = request.options?.find((option) => option.value === answer); + return `The user answered: ${chosen ? chosen.label : answer}`; +} + +/** + * Human-readable one-liner describing a tool call awaiting approval. + * + * Pi's built-in tools use stable argument names, so the common cases read as a + * command or a path rather than as a JSON blob. + */ +export function piApprovalSummary(toolName: string, args: unknown): string { + const record = args && typeof args === "object" ? args as Record : {}; + const command = trimmed(record.command); + if (command) return command; + const filePath = trimmed(record.filePath) ?? trimmed(record.path) ?? trimmed(record.file); + if (filePath) return filePath; + try { + const encoded = JSON.stringify(record); + return encoded.length > 300 ? `${encoded.slice(0, 300)}…` : encoded; + } catch { + return toolName; + } +} + +/** + * Minimal shape of a Pi `ToolDefinition`. + * + * The worker loads Pi dynamically and cannot import its types, so tool + * definitions are described structurally. `parameters` is a TypeBox schema in + * Pi's typings, which at runtime is a plain JSON Schema object. + */ +export type PiToolDefinitionLike = { + name: string; + label: string; + description: string; + parameters: unknown; + promptSnippet?: string; + execute: ( + toolCallId: string, + params: unknown, + signal: AbortSignal | undefined, + onUpdate: unknown, + ctx: unknown, + ) => Promise<{ content: Array<{ type: "text"; text: string }>; details: unknown }>; +}; + +/** ADE's `ask_user` tool, resolved through the chat's question card. */ +export function createPiAskUserTool(bridge: PiUiBridge): PiToolDefinitionLike { + return { + name: PI_ASK_USER_TOOL_NAME, + label: "Ask user", + description: PI_ASK_USER_DESCRIPTION, + promptSnippet: "ask_user: ask the user a question and wait for their answer", + parameters: PI_ASK_USER_PARAMETERS, + execute: async (_toolCallId, params, signal) => { + const request = piAskUserRequestFromArgs(params); + const answer = await bridge.request(request, { ...(signal ? { signal } : {}) }); + const text = piAskUserResultText(request, answer); + return { content: [{ type: "text", text }], details: { answered: answer !== null, answer } }; + }, + }; +} + +export type PiApprovalGate = { + /** Resolves true when the call may proceed. */ + check: (toolName: string, args: unknown, signal: AbortSignal | undefined) => Promise; +}; + +/** + * Ask before each call to a gated tool, remembering an "allow for this chat" + * answer so the user is not asked about the same tool repeatedly. + */ +export function createPiApprovalGate(bridge: PiUiBridge): PiApprovalGate { + const alwaysAllow = new Set(); + return { + check: async (toolName, args, signal) => { + if (alwaysAllow.has(toolName)) return true; + const answer = await bridge.request(piApprovalRequest(toolName, args), { ...(signal ? { signal } : {}) }); + if (answer === PI_APPROVAL_ALLOW_SESSION) { + alwaysAllow.add(toolName); + return true; + } + // A dismissed card or an aborted turn denies the call. + return answer === PI_APPROVAL_ALLOW; + }, + }; +} + +/** + * Wrap a Pi tool definition so the call clears an ADE approval card first. + * + * The wrapper keeps the original name, so registering it through `customTools` + * replaces Pi's built-in of the same name while preserving its schema, prompt + * text, and rendering. + */ +export function withPiApproval( + definition: PiToolDefinitionLike, + gate: PiApprovalGate, +): PiToolDefinitionLike { + return { + ...definition, + execute: async (toolCallId, params, signal, onUpdate, ctx) => { + if (!await gate.check(definition.name, params, signal)) { + // Pi marks a tool call failed only when execute throws. + throw new Error(`The user denied this ${definition.name} call. Ask before trying it again, or take a different approach.`); + } + return definition.execute(toolCallId, params, signal, onUpdate, ctx); + }, + }; +} + +export function piApprovalRequest(toolName: string, args: unknown): PiSdkUiRequestPayload { + // No options: ADE renders an approval card with its own accept/decline + // controls and never reads `questions[].options`, so supplying them would + // ship labels nothing displays. + return { + origin: "approval", + kind: "confirm", + title: `Run ${toolName}?`, + message: piApprovalSummary(toolName, args), + sourceId: toolName, + }; +} diff --git a/apps/desktop/src/main/services/chat/piSdkWorker.ts b/apps/desktop/src/main/services/chat/piSdkWorker.ts index f216c9dc4..834dd11be 100644 --- a/apps/desktop/src/main/services/chat/piSdkWorker.ts +++ b/apps/desktop/src/main/services/chat/piSdkWorker.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -19,6 +20,17 @@ import { piSessionHeaderMatchesCwd, readPiSessionHeader, } from "./piSessionLease"; +import { + PI_ASK_USER_TOOL_NAME, + createPiApprovalGate, + createPiAskUserTool, + createPiAuthInteraction, + createPiExtensionUiContext, + createPiUiBridge, + withPiApproval, + type PiToolDefinitionLike, + type PiUiBridge, +} from "./piSdkUiBridge"; // 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. @@ -39,9 +51,22 @@ 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"]); +/** Root-exported factory per built-in tool, used to rebuild it behind an approval gate. */ +const PI_TOOL_DEFINITION_FACTORIES: Record = { + read: "createReadToolDefinition", + bash: "createBashToolDefinition", + edit: "createEditToolDefinition", + write: "createWriteToolDefinition", +}; +/** Derived so the gateable set and the built-in set cannot drift apart. */ +const PI_BUILTIN_TOOLS = new Set(Object.keys(PI_TOOL_DEFINITION_FACTORIES)); let unsubscribe: (() => void) | null = null; let disposed = false; +let loadedExtensions: Array<{ id: string; name?: string | null }> = []; +let ungateableTools: string[] = []; +let extensionsError: string | null = null; +let activeLogin: { providerId: string; controller: AbortController } | null = null; +const uiBridge: PiUiBridge = createPiUiBridge(post, () => randomUUID()); function post(message: PiSdkWorkerResponse): void { if (!process.send) return; @@ -210,9 +235,21 @@ async function authInventory(): Promise { const provider = record(providerValue); const id = nonEmpty(provider?.id); if (!id) continue; + const auth = record(provider?.auth); + const oauth = record(auth?.oauth); + const apiKey = record(auth?.apiKey); + // An api-key provider with no interactive `login` resolves ambiently (env + // var, cloud profile), so ADE must not offer to sign into it. + const authTypes = [ + ...(oauth ? ["oauth"] : []), + ...(apiKey && typeof apiKey.login === "function" ? ["api_key"] : []), + ]; const item: Record = { id, ...(nonEmpty(provider?.name) ? { name: nonEmpty(provider?.name)! } : {}), + ...(authTypes.length ? { authTypes } : {}), + ...(nonEmpty(oauth?.loginLabel) ? { loginLabel: nonEmpty(oauth?.loginLabel)! } : {}), + ...(oauth?.isSubscription === true ? { isSubscription: true } : {}), }; try { const status = typeof runtime.getProviderAuthStatus === "function" @@ -348,7 +385,7 @@ async function openSessionManager(init: PiSdkWorkerInit, sdk: PiModule): Promise } } -function makeResourceLoader(init: PiSdkWorkerInit, sdk: PiModule): unknown { +function makeResourceLoader(init: PiSdkWorkerInit, sdk: PiModule, settingsManager: unknown): 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 ?? ""; @@ -359,10 +396,14 @@ function makeResourceLoader(init: PiSdkWorkerInit, sdk: PiModule): unknown { 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, + // Without this the loader builds its own settings manager, which trusts + // the project and would auto-load extensions from the checkout. + ...(settingsManager ? { settingsManager } : {}), + // Pi extensions execute arbitrary code and install their own UI handlers, + // so ADE chat keeps them out unless the caller opts in. When enabled they + // are bound to ADE's limited UI bridge; the Pi CLI remains the escape hatch + // for extension experiences the bridge cannot render. + noExtensions: init.extensions !== 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. @@ -372,6 +413,63 @@ function makeResourceLoader(init: PiSdkWorkerInit, sdk: PiModule): unknown { }); } +/** + * Assemble ADE's custom tools: `ask_user`, plus approval-gated rebuilds of the + * built-ins the caller wants gated. + * + * A custom tool whose name matches a built-in replaces it in Pi's registry, so + * rebuilding `bash` from Pi's own factory keeps its schema, prompt text, and + * rendering while adding the gate. Pi's shell settings are passed through so a + * gated bash still honours the user's configured shell. + */ +function buildCustomTools( + init: PiSdkWorkerInit, + sdk: PiModule, + settingsManager: unknown, +): { tools: PiToolDefinitionLike[]; ungateable: string[] } { + const tools: PiToolDefinitionLike[] = []; + const ungateable: string[] = []; + const define = typeof sdk.defineTool === "function" ? sdk.defineTool as Callable : null; + const finish = (definition: PiToolDefinitionLike): PiToolDefinitionLike => + (define ? define.call(null, definition) as PiToolDefinitionLike : definition); + + if (init.askUserTool) tools.push(finish(createPiAskUserTool(uiBridge))); + + // Only gate a tool this session actually grants. Relying on Pi's own + // allowlist to drop an unrequested wrapper would make the gate depend on + // resolution order rather than on ADE's permission mode. + const requested = new Set(init.tools ?? ["read"]); + const gated = (init.approvalTools ?? []).filter((tool) => PI_BUILTIN_TOOLS.has(tool) && requested.has(tool)); + if (!gated.length) return { tools, ungateable }; + const gate = createPiApprovalGate(uiBridge); + const settings = record(settingsManager); + const shellPath = typeof settings?.getShellPath === "function" + ? nonEmpty((settings.getShellPath as Callable).call(settings)) + : null; + const commandPrefix = typeof settings?.getShellCommandPrefix === "function" + ? nonEmpty((settings.getShellCommandPrefix as Callable).call(settings)) + : null; + for (const toolName of gated) { + const factoryName = PI_TOOL_DEFINITION_FACTORIES[toolName]; + const factory = factoryName ? sdk[factoryName] : undefined; + if (typeof factory !== "function") { + // Nothing to wrap, so the caller withholds the tool. Granting it ungated + // would silently downgrade the permission mode. + ungateable.push(toolName); + continue; + } + const options = toolName === "bash" + ? { + ...(shellPath ? { shellPath } : {}), + ...(commandPrefix ? { commandPrefix } : {}), + } + : undefined; + const definition = (factory as Callable).call(null, init.cwd, options) as PiToolDefinitionLike; + tools.push(finish(withPiApproval(definition, gate))); + } + return { tools, ungateable }; +} + function ready(): PiSdkReady { if (!initState || !piRoot || !piEntry || (!session && !initState.inventoryOnly)) { throw new Error("Pi SDK worker is not initialized."); @@ -391,9 +489,81 @@ function ready(): PiSdkReady { currentModel: currentModelDescriptor(), thinkingLevel: typeof session?.thinkingLevel === "string" ? session.thinkingLevel : null, availableModels: modelInventory, + extensions: loadedExtensions, + extensionsError, + ungateableTools, }; } +/** + * Bind the user's extensions to ADE's UI bridge and report what loaded. + * + * `bindExtensions` is also what fires Pi's `session_start` event, and it is + * where extension-registered providers are flushed into the model runtime — so + * it must run before the model inventory is read. + */ +async function bindExtensions(active: PiSession, loadResult: unknown): Promise { + loadedExtensions = []; + extensionsError = null; + const bind = (active as Record).bindExtensions; + if (typeof bind !== "function") { + extensionsError = "This Pi build does not support binding extensions to a host UI."; + return; + } + try { + await (bind as Callable).call(active, { + uiContext: createPiExtensionUiContext({ bridge: uiBridge }), + // Pi's own non-terminal host mode: dialogs and notifications are + // supported, terminal-only widgets are not. + mode: "rpc", + onError: (error: unknown) => { + const detail = record(error); + uiBridge.notify({ + origin: "extension", + level: "error", + message: `A Pi extension failed: ${nonEmpty(detail?.error) ?? errorMessage(error)}`, + ...(nonEmpty(detail?.extensionPath) ? { sourceId: nonEmpty(detail?.extensionPath)! } : {}), + }); + }, + }); + } catch (error) { + extensionsError = errorMessage(error); + return; + } + + // `createAgentSession` hands back the load result directly; prefer it. The + // resource-loader accessor stays only as a fallback for Pi builds that do + // not return one — reading it first reported "no extensions" whenever that + // accessor was shaped differently. + const loader = record((active as Record).resourceLoader); + const getExtensions = loader?.getExtensions; + const result = record(loadResult) + ?? (typeof getExtensions === "function" ? record((getExtensions as Callable).call(loader)) : null); + if (!result) return; + const entries = Array.isArray(result?.extensions) ? result.extensions : []; + loadedExtensions = entries.flatMap((entry) => { + const item = record(entry); + // Pi identifies an extension by path. `sourceInfo.source` names the origin + // ("auto", "local", a package name), so it only doubles as a label when it + // is a package; otherwise the path is what the user recognizes, and an + // `index.*` entry is named by its folder rather than "index". + const id = nonEmpty(item?.resolvedPath) ?? nonEmpty(item?.path); + if (!id || item?.hidden === true) return []; + const base = path.basename(id).replace(/\.(?:ts|js|mjs|cjs)$/, ""); + const name = base === "index" ? path.basename(path.dirname(id)) : base; + return [{ id, name }]; + }); + const failures = Array.isArray(result?.errors) ? result.errors : []; + if (failures.length) { + extensionsError = failures + .map((failure) => { + const item = record(failure); + return `${nonEmpty(item?.path) ?? "extension"}: ${nonEmpty(item?.error) ?? "failed to load"}`; + }) + .join("; "); + } +} + 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.`); @@ -448,25 +618,69 @@ async function initWorker(init: PiSdkWorkerInit): Promise { sessionManager = await openSessionManager(init, pi); const selectedModel = init.modelRef == null ? undefined : await resolveModel(init.modelRef); - const resourceLoader = makeResourceLoader(init, pi); + // One settings manager governs both the resource loader and the session. + // + // `projectTrusted` is deliberately false. Pi treats a trusted project as + // permission to auto-load and execute extensions from the checkout's own + // `.pi/` directory, which its CLI only does after prompting. ADE opens + // repositories the user has not vouched for, so only the user's own profile + // extensions may load here; without this the loader would build its own + // settings manager that defaults to trusted. + const SettingsManagerCtor = typeof pi.SettingsManager === "function" + ? pi.SettingsManager as unknown as Record + : null; + const settingsManager = SettingsManagerCtor && typeof SettingsManagerCtor.create === "function" + ? (SettingsManagerCtor.create as Callable).call(SettingsManagerCtor, init.cwd, init.agentDir, { projectTrusted: false }) + : null; + // Without a settings manager ADE cannot pin `projectTrusted`, and Pi's own + // default would trust the checkout — so extensions stay off rather than + // loading repository code on a Pi build ADE cannot constrain. + const extensionsAllowed = init.extensions === true && settingsManager !== null; + if (init.extensions && !extensionsAllowed) { + extensionsError = "This Pi build does not let ADE mark the project untrusted, so extensions stayed off."; + } + const resourceLoader = makeResourceLoader({ ...init, extensions: extensionsAllowed }, pi, settingsManager); if (resourceLoader && typeof (resourceLoader as Record).reload === "function") { await method(resourceLoader, "reload").call(resourceLoader); } + const { tools: customTools, ungateable } = buildCustomTools(init, pi, settingsManager); + // A Pi build that cannot supply a tool's definition factory cannot have that + // tool gated, so it is withheld rather than granted ungated — and the chat + // still starts. + const grantedTools = requestedTools.filter((tool) => !ungateable.includes(tool)); + // Reported on the ready payload rather than as a notice: nothing is listening + // for notices until the desktop process finishes acquiring this worker. + ungateableTools = ungateable; const createSession = callable(pi.createAgentSession, "createAgentSession"); const options: Record = { cwd: init.cwd, agentDir: init.agentDir, modelRuntime, sessionManager, + ...(settingsManager ? { settingsManager } : {}), ...(selectedModel ? { model: selectedModel } : {}), ...(init.thinkingLevel ? { thinkingLevel: init.thinkingLevel } : {}), ...(resourceLoader ? { resourceLoader } : {}), - tools: init.tools ?? ["read"], + // Pi's `tools` option is one flat allowlist covering built-ins, extension + // tools, and custom tools, and anything unlisted is dropped. With + // extensions enabled ADE cannot name their tools in advance, so it denies + // the unwanted built-ins instead and leaves the rest of the namespace + // alone. Without extensions the tighter allowlist still applies. + ...(extensionsAllowed + ? { excludeTools: [...PI_BUILTIN_TOOLS].filter((tool) => !grantedTools.includes(tool)) } + : { + tools: [ + ...grantedTools, + ...(init.askUserTool ? [PI_ASK_USER_TOOL_NAME] : []), + ], + }), + ...(customTools.length ? { customTools } : {}), ...(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."); + if (extensionsAllowed) await bindExtensions(session, created?.extensionsResult); const subscribe = method(session, "subscribe"); const listener = (event: unknown): void => { const eventRecord = record(event); @@ -546,9 +760,100 @@ async function compact(customInstructions?: string | null): Promise { return toPiSdkJson(await (compactMethod as Callable).call(active, customInstructions ?? undefined)); } +/** + * Run Pi's native login for one provider. + * + * Credentials stay Pi's: `ModelRuntime.login` persists them through Pi's own + * `AuthStorage`, under its cross-process lock. ADE only renders the prompts and + * relays what the user typed, and never receives or stores a token. + */ +async function loginProvider(providerId: string, method?: string | null): Promise { + if (!modelRuntime) throw new Error("Pi SDK model runtime is not initialized."); + const runtime = modelRuntime as Record; + const login = runtime.login; + if (typeof login !== "function") { + throw new Error("This Pi build does not expose ModelRuntime.login(). Update Pi, or sign in with the Pi CLI."); + } + + const provider = typeof runtime.getProvider === "function" + ? record((runtime.getProvider as Callable).call(runtime, providerId)) + : null; + const auth = record(provider?.auth); + // Pi only has two login types. An api-key provider without an interactive + // `login` is ambient-only (env var, cloud profile) and has nothing to run. + const requested = nonEmpty(method); + const authType = requested === "api_key" || requested === "oauth" + ? requested + : auth?.oauth + ? "oauth" + : "api_key"; + if (authType === "oauth" && auth && !auth.oauth) { + throw new Error(`Pi provider "${providerId}" does not support OAuth sign-in.`); + } + if (authType === "api_key" && auth) { + const apiKeyAuth = record(auth.apiKey); + if (!apiKeyAuth) throw new Error(`Pi provider "${providerId}" does not support API key sign-in.`); + if (typeof apiKeyAuth.login !== "function") { + throw new Error(`Pi provider "${providerId}" reads its API key from the environment, so there is nothing to sign in to.`); + } + } + + if (activeLogin) { + // Aborting the old flow rejects its in-flight prompt, but a card already + // on screen belongs to the bridge — settle it so the superseded sign-in + // cannot leave an unanswerable prompt next to the new one. + activeLogin.controller.abort(); + uiBridge.drain("auth"); + } + const controller = new AbortController(); + activeLogin = { providerId, controller }; + try { + await (login as Callable).call( + modelRuntime, + providerId, + authType, + createPiAuthInteraction(uiBridge, providerId, controller.signal), + ); + } catch (error) { + // Credentials were written but Pi could not resync its local snapshot. + // That is a successful sign-in with a stale catalog, not a failed login. + if (error instanceof Error && error.name === "CredentialSynchronizationError") { + uiBridge.notify({ + origin: "auth", + level: "warn", + message: "Signed in, but Pi could not refresh its model list. Reopen settings to retry.", + sourceId: providerId, + }); + } else { + throw error; + } + } finally { + if (activeLogin?.controller === controller) activeLogin = null; + } + + // login() settles once local credential state is consistent, but not once + // remote catalogs are fresh. Refresh so newly unlocked models appear. + if (typeof runtime.refresh === "function") { + try { + await (runtime.refresh as Callable).call(modelRuntime, { allowNetwork: true, providers: [providerId] }); + } catch { + // Refresh failures are reported through the model list, not the login. + } + } + // Deliberately does not return the model inventory: the credential is + // already written, so letting an inventory read throw here would report a + // completed sign-in as a failure. `refresh` above owns catalog freshness. + return toPiSdkJson({ ok: true, providerId, authType }); +} + async function disposeWorker(): Promise { if (disposed) return; disposed = true; + activeLogin?.controller.abort(); + activeLogin = null; + // Every awaiting Pi callback resolves to "no answer" rather than hanging a + // teardown that is already underway. + uiBridge.close(); unsubscribe?.(); unsubscribe = null; try { @@ -583,7 +888,27 @@ async function dispatch(request: PiSdkWorkerRequest): Promise { + it("gives a Pi sign-in longer than the flow it waits on, on every transport", () => { + // The flow blocks on a human finishing OAuth in a browser. At the 30s + // default the renderer reported failure while the daemon was still signing + // in, so in-app sign-in could never complete. + const flowBudgetMs = 10 * 60 * 1000; + + expect(ipcInvokeTimeoutMs(IPC.aiPiLoginStart)).toBeGreaterThan(flowBudgetMs); + expect(longRunningLocalRuntimeActionTimeoutMs("ai.piLoginStart")).toBeGreaterThan(flowBudgetMs); + // The runtime-action route the preload actually prefers, local and remote. + const routed = [IPC.localRuntimeCallAction, IPC.remoteRuntimeCallAction].map((channel) => + ipcInvokeTimeoutMs(channel, [{ request: { domain: "ai", action: "piLoginStart", args: { providerId: "anthropic" } } }])); + for (const timeoutMs of routed) expect(timeoutMs).toBeGreaterThan(flowBudgetMs); + }); + it("keeps local lane delete IPC alive through cold setup and the daemon action", () => { const innerTimeoutMs = longRunningLocalRuntimeActionTimeoutMs("lane.delete")!; const outerTimeoutMs = ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts index 695b36441..d2cabcb0d 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts @@ -5,6 +5,7 @@ import { LOCAL_RUNTIME_IPC_ACTION_REGISTRY_TIMEOUT_MS, LOCAL_RUNTIME_IPC_EVENT_POLL_TIMEOUT_MS, LOCAL_RUNTIME_IPC_PROJECT_COMPLETION_TIMEOUT_MS, + PI_LOGIN_IPC_TIMEOUT_MS, LOCAL_RUNTIME_IPC_SYNC_TIMEOUT_MS, } from "../localRuntime/localRuntimeTimeoutPolicy"; @@ -13,6 +14,9 @@ function isRecord(value: unknown): value is Record { } const RUNTIME_ACTION_CHANNEL: Record> = { + ai: { + piLoginStart: IPC.aiPiLoginStart, + }, lane: { create: IPC.lanesCreate, createChild: IPC.lanesCreateChild, @@ -83,6 +87,9 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ // renderer's outcome known through setup and result delivery. case IPC.projectSwitchToPath: return LOCAL_RUNTIME_IPC_PROJECT_COMPLETION_TIMEOUT_MS; + // Awaits the user finishing Pi's own sign-in in a browser. + case IPC.aiPiLoginStart: + return PI_LOGIN_IPC_TIMEOUT_MS; case IPC.remoteRuntimeConnect: case IPC.remoteRuntimeListProjects: case IPC.remoteRuntimeAddProject: diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 442a84a70..35505ccab 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -531,6 +531,9 @@ import type { OpenCodeOAuthStatusEvent, OpenCodeProviderAuthMethods, OpenCodeRuntimeSnapshot, + PiAuthStatusEvent, + PiLoginMethod, + PiLoginProvider, SyncDesktopConnectionDraft, SyncCloudRelayStatus, SyncDeviceRecord, @@ -657,6 +660,14 @@ import { startOAuth as startOpenCodeOAuth, type OpenCodeAuthDeps, } from "../opencode/openCodeAuthService"; +import { + addPiAuthStatusListener, + cancelPiLogin, + listPiLoginProviders, + startPiLogin, + submitPiLoginPrompt, + type PiLoginResult, +} from "../ai/piAuthService"; import { getLastFetchedAt as getModelsDevLastFetchedAt, refreshNow as refreshModelsDevNow } from "../ai/modelsDevService"; import type { createTestService } from "../tests/testService"; import type { createGitOperationsService } from "../git/gitOperationsService"; @@ -1940,6 +1951,9 @@ export function registerIpc({ [IPC.accountRemoveMachine]: new Set(["machineKey"]), [IPC.attentionNotchPublishSnapshot]: new Set(["items"]), [IPC.attentionNotchPublishToast]: new Set(["title", "subtitle"]), + // A Pi sign-in prompt answer is the credential itself when Pi asks for an + // API key, so it must never reach a verbose IPC trace. + [IPC.aiPiLoginSubmit]: new Set(["value"]), }; const redactIpcArgsForChannel = (channel: string, args: unknown[]): unknown[] => { @@ -4848,6 +4862,55 @@ export function registerIpc({ }, ); + // Broadcast Pi sign-in transitions to all renderer windows. The relay/web + // fan-out is registered separately in the adeActions AI domain. + addPiAuthStatusListener((event: PiAuthStatusEvent) => { + for (const win of BrowserWindow.getAllWindows()) { + try { + win.webContents.send(IPC.aiPiAuthStatus, event); + } catch { + // ignore broadcast failures + } + } + }); + + ipcMain.handle(IPC.aiPiLoginProviders, async (): Promise => { + return await listPiLoginProviders(); + }); + + ipcMain.handle( + IPC.aiPiLoginStart, + async (_event, arg: { providerId: string; method?: PiLoginMethod }): Promise => { + const ctx = getCtx(); + const result = await startPiLogin(arg); + if (result.ok) { + try { + ctx.aiIntegrationService?.invalidateProviderReadinessCaches(); + } catch (error) { + // Matches the action-path twin so both halves of one incident are + // searchable under a single event name. + ctx.logger.warn("ai.pi_auth_cache_invalidation_failed", { + provider: arg.providerId, + error: getErrorMessage(error), + }); + } + } + return result; + }, + ); + + ipcMain.handle( + IPC.aiPiLoginSubmit, + async ( + _event, + arg: { providerId: string; requestId: string; value: string }, + ): Promise<{ ok: boolean; error?: string }> => submitPiLoginPrompt(arg), + ); + + ipcMain.handle(IPC.aiPiLoginCancel, async (_event, arg: { providerId: string }): Promise => { + cancelPiLogin(arg); + }); + ipcMain.handle(IPC.aiRefreshModelsDev, async (): Promise<{ lastFetchedAt: number | null }> => { const ctx = getCtx(); try { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts index ff434c260..2a0f8e806 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts @@ -31,7 +31,15 @@ export const LOCAL_RUNTIME_IPC_ACTION_REGISTRY_TIMEOUT_MS = export const LOCAL_RUNTIME_IPC_EVENT_POLL_TIMEOUT_MS = localRuntimeCallIpcTimeoutMs(LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS); +/** + * A Pi sign-in blocks on a human completing an OAuth or device-code flow, which + * `piAuthService` bounds at 10 minutes. The transport budget has to outlive + * that, or the renderer reports failure while the daemon is still signing in. + */ +export const PI_LOGIN_IPC_TIMEOUT_MS = 11 * 60_000; + const LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS: ReadonlyMap = new Map([ + ["ai.piLoginStart", PI_LOGIN_IPC_TIMEOUT_MS], // Lane deletion can legitimately include a 60s worktree removal followed by // a 45s remote-branch deletion. The old 30s client budget reported failure // while the daemon kept mutating state to a successful completion. diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index a14e2b5d3..5c27403d3 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -239,6 +239,9 @@ import type { OpenCodeOAuthStartResult, OpenCodeOAuthStatusEvent, OpenCodeProviderAuthMethods, + PiAuthStatusEvent, + PiLoginMethod, + PiLoginProvider, CursorCloudAgentSummary, CursorCloudArtifactDownload, CursorCloudArtifactSummary, @@ -1025,6 +1028,18 @@ declare global { }) => Promise<{ ok: boolean; error?: string }>; refreshModelsDev: () => Promise<{ lastFetchedAt: number | null }>; onOpencodeOAuthStatus: (cb: (event: OpenCodeOAuthStatusEvent) => void) => () => void; + piLoginProviders: () => Promise; + piLoginStart: (args: { + providerId: string; + method?: PiLoginMethod; + }) => Promise<{ ok: boolean; error?: string }>; + piLoginSubmit: (args: { + providerId: string; + requestId: string; + value: string; + }) => Promise<{ ok: boolean; error?: string }>; + piLoginCancel: (args: { providerId: string }) => Promise; + onPiAuthStatus: (cb: (event: PiAuthStatusEvent) => void) => () => void; cursorCloudListRepositories: () => Promise; cursorCloudListAgents: (args?: { includeArchived?: boolean; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index af637c5bd..5c44e2051 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -118,6 +118,9 @@ import type { OpenCodeOAuthStartResult, OpenCodeOAuthStatusEvent, OpenCodeProviderAuthMethods, + PiAuthStatusEvent, + PiLoginMethod, + PiLoginProvider, CursorCloudAgentSummary, CursorCloudArtifactDownload, CursorCloudArtifactSummary, @@ -1751,6 +1754,7 @@ const remoteLaneOAuthEventCallbacks = new Set< const remoteOpenCodeOAuthStatusCallbacks = new Set< (payload: OpenCodeOAuthStatusEvent) => void >(); +const remotePiAuthStatusCallbacks = new Set<(payload: PiAuthStatusEvent) => void>(); const remoteLaneDiagnosticsEventCallbacks = new Set< (payload: RuntimeDiagnosticsEvent) => void >(); @@ -1871,6 +1875,11 @@ const subscribeLocalOpenCodeOAuthStatusEvents = IPC.aiOpencodeOAuthStatus, "OpenCode OAuth status", ); +const subscribeLocalPiAuthStatusEvents = + createLocalIpcEventSubscription( + IPC.aiPiAuthStatus, + "Pi sign-in status", + ); let remoteRuntimeEventTimer: ReturnType | null = null; let remoteRuntimeEventInFlight = false; @@ -1945,6 +1954,7 @@ function hasRemoteRuntimeEventSubscribers(): boolean { remoteLaneProxyEventCallbacks.size > 0 || remoteLaneOAuthEventCallbacks.size > 0 || remoteOpenCodeOAuthStatusCallbacks.size > 0 || + remotePiAuthStatusCallbacks.size > 0 || remoteLaneDiagnosticsEventCallbacks.size > 0 || remotePtyDataEventCallbacks.size > 0 || remotePtyExitEventCallbacks.size > 0 || @@ -2310,6 +2320,19 @@ function dispatchRemoteRuntimeEventPayload( } } + if (payload.kind === "piAuthStatus" && isRecord(payload.event)) { + const event = payload.event; + if (typeof event.providerId === "string" && typeof event.state === "string") { + for (const cb of [...remotePiAuthStatusCallbacks]) { + try { + cb(event as unknown as PiAuthStatusEvent); + } catch (error) { + console.error("preload remote Pi sign-in status listener failed", error); + } + } + } + } + if (payload.type === "sync-status" && isRecord(payload.snapshot)) { for (const cb of [...remoteSyncStatusEventCallbacks]) { try { @@ -2857,6 +2880,16 @@ function subscribeRemoteOpenCodeOAuthStatusEvents( }; } +function subscribeRemotePiAuthStatusEvents( + cb: (payload: PiAuthStatusEvent) => void, +): () => void { + remotePiAuthStatusCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remotePiAuthStatusCallbacks.delete(cb); + }; +} + function subscribeRemoteLaneDiagnosticsEvents( cb: (payload: RuntimeDiagnosticsEvent) => void, ): () => void { @@ -4253,6 +4286,41 @@ contextBridge.exposeInMainWorld("ade", { removeLocal(); }; }, + piLoginProviders: async (): Promise => + callProjectRuntimeActionOr("ai", "piLoginProviders", {}, () => + ipcRenderer.invoke(IPC.aiPiLoginProviders), + ), + piLoginStart: async (args: { + providerId: string; + method?: PiLoginMethod; + }): Promise<{ ok: boolean; error?: string }> => + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr("ai", "piLoginStart", { args }, () => + ipcRenderer.invoke(IPC.aiPiLoginStart, args), + ), + ), + piLoginSubmit: async (args: { + providerId: string; + requestId: string; + value: string; + }): Promise<{ ok: boolean; error?: string }> => + callProjectRuntimeActionOr("ai", "piLoginSubmit", { args }, () => + ipcRenderer.invoke(IPC.aiPiLoginSubmit, args), + ), + piLoginCancel: async (args: { providerId: string }): Promise => + callProjectRuntimeActionOr("ai", "piLoginCancel", { args }, () => + ipcRenderer.invoke(IPC.aiPiLoginCancel, args), + ), + onPiAuthStatus: (cb: (event: PiAuthStatusEvent) => void) => { + const removeLocal = subscribeLocalPiAuthStatusEvents(cb); + const removeRemote = subscribeRemotePiAuthStatusEvents(cb); + return () => { + removeRemote(); + removeLocal(); + }; + }, cursorCloudListRepositories: async (): Promise => callProjectRuntimeActionOr("ai", "listCursorCloudRepositories", {}, () => ipcRenderer.invoke(IPC.aiCursorCloudListRepositories), diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 3c636ea90..465437c62 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3651,6 +3651,11 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { clearOpencodeProviderKey: resolvedArg({ ok: false, error: "browser" } as any), refreshModelsDev: resolved({ lastFetchedAt: null }), onOpencodeOAuthStatus: () => () => {}, + piLoginProviders: resolved([]), + piLoginStart: resolvedArg({ ok: false, error: "browser" } as any), + piLoginSubmit: resolvedArg({ ok: false, error: "browser" } as any), + piLoginCancel: resolvedArg(undefined), + onPiAuthStatus: () => () => {}, }, agentTools: { detect: resolved([]), diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx index bc1bdbd83..59ed1b40a 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx @@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ProvidersSection } from "./ProvidersSection"; -import type { AgentChatEventEnvelope, AiSettingsStatus } from "../../../shared/types"; +import type { AgentChatEventEnvelope, AiSettingsStatus, PiAuthStatusEvent } from "../../../shared/types"; vi.mock("@lobehub/icons", () => { const brand = () => { @@ -205,6 +205,42 @@ function buildStatus( } as AiSettingsStatus; } +function buildPiInstallation(): NonNullable { + return { + 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, + }; +} + function renderProvidersSection() { return render( @@ -217,10 +253,12 @@ describe("ProvidersSection", () => { const originalAde = globalThis.window.ade; let emitChatEvent: ((envelope: AgentChatEventEnvelope) => void) | null = null; let emitOAuthStatus: ((event: { providerId: string; state: string; error?: string }) => void) | null = null; + let emitPiAuthStatus: ((event: PiAuthStatusEvent) => void) | null = null; beforeEach(() => { emitChatEvent = null; emitOAuthStatus = null; + emitPiAuthStatus = null; globalThis.window.ade = { ai: { @@ -257,6 +295,16 @@ describe("ProvidersSection", () => { if (emitOAuthStatus === cb) emitOAuthStatus = null; }; }), + piLoginProviders: vi.fn().mockResolvedValue([]), + piLoginStart: vi.fn().mockResolvedValue({ ok: true }), + piLoginSubmit: vi.fn().mockResolvedValue({ ok: true }), + piLoginCancel: vi.fn().mockResolvedValue(undefined), + onPiAuthStatus: vi.fn((cb: (event: PiAuthStatusEvent) => void) => { + emitPiAuthStatus = cb; + return () => { + if (emitPiAuthStatus === cb) emitPiAuthStatus = null; + }; + }), }, projectConfig: { get: vi.fn().mockResolvedValue({ @@ -530,41 +578,7 @@ describe("ProvidersSection", () => { 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, - }, - })); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); const listApiKeysMock = window.ade.ai.listApiKeys as ReturnType; listApiKeysMock.mockReset(); listApiKeysMock.mockResolvedValue([]); @@ -574,8 +588,8 @@ describe("ProvidersSection", () => { 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.getByText(/7 models/)).toBeTruthy(); expect(screen.getByRole("button", { name: "Open settings.json" })).toBeTruthy(); await act(async () => { @@ -584,6 +598,213 @@ describe("ProvidersSection", () => { expect(window.ade.app.openPath).toHaveBeenCalledWith("/Users/example/.pi/agent/settings.json"); }); + it("signs into a Pi provider in-app: device code, prompt, then success", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + const listApiKeysMock = window.ade.ai.listApiKeys as ReturnType; + listApiKeysMock.mockReset(); + listApiKeysMock.mockResolvedValue([]); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth", "api_key"], configured: false, loginLabel: "Sign in with SuperGrok" }, + ]); + let resolveLogin: ((result: { ok: boolean; error?: string }) => void) | null = null; + (window.ade.ai.piLoginStart as ReturnType).mockImplementation( + () => new Promise<{ ok: boolean; error?: string }>((resolve) => { + resolveLogin = resolve; + }), + ); + + renderProvidersSection(); + + const signIn = await screen.findByRole("button", { name: "Sign in with SuperGrok — xAI" }); + expect(screen.getByRole("button", { name: "Use an API key — xAI" })).toBeTruthy(); + + await act(async () => { + signIn.click(); + }); + expect(window.ade.ai.piLoginStart).toHaveBeenCalledWith({ providerId: "xai", method: "oauth" }); + + await act(async () => { + emitPiAuthStatus?.({ + providerId: "xai", + state: "pending", + notice: { level: "info", message: "Enter the code", userCode: "ABCD-1234", verificationUri: "https://x.ai/device" }, + }); + }); + expect(screen.getByText("ABCD-1234")).toBeTruthy(); + + await act(async () => { + emitPiAuthStatus?.({ + providerId: "xai", + state: "prompt", + prompt: { requestId: "req-1", kind: "manual_code", title: "Sign in to xAI", message: "Paste the code from your browser" }, + }); + }); + const input = screen.getByLabelText("Paste the code from your browser") as HTMLInputElement; + fireEvent.change(input, { target: { value: "code-42" } }); + await act(async () => { + screen.getByRole("button", { name: "Continue" }).click(); + }); + expect(window.ade.ai.piLoginSubmit).toHaveBeenCalledWith({ + providerId: "xai", + requestId: "req-1", + value: "code-42", + }); + + await act(async () => { + resolveLogin?.({ ok: true }); + }); + expect(screen.getByText(/Signed in to xAI\./)).toBeTruthy(); + }); + + it("surfaces a rejected Pi prompt answer instead of waiting out the login timeout", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false }, + ]); + // The login promise never settles here: only the submit result can tell the + // user their answer was rejected. + (window.ade.ai.piLoginStart as ReturnType).mockImplementation( + () => new Promise<{ ok: boolean; error?: string }>(() => undefined), + ); + (window.ade.ai.piLoginSubmit as ReturnType).mockResolvedValue({ + ok: false, + error: "That prompt has already been answered.", + }); + + renderProvidersSection(); + + const signIn = await screen.findByRole("button", { name: "Sign in — xAI" }); + await act(async () => { + signIn.click(); + }); + await act(async () => { + emitPiAuthStatus?.({ + providerId: "xai", + state: "prompt", + prompt: { requestId: "req-1", kind: "manual_code", title: "Sign in to xAI", message: "Paste the code from your browser" }, + }); + }); + + const input = screen.getByLabelText("Paste the code from your browser") as HTMLInputElement; + fireEvent.change(input, { target: { value: "code-42" } }); + await act(async () => { + screen.getByRole("button", { name: "Continue" }).click(); + }); + + const failure = await screen.findByText(/xAI: That prompt has already been answered\./); + expect(failure.closest('[role="alert"]')).toBeTruthy(); + expect(screen.getByRole("button", { name: "Try again" })).toBeTruthy(); + }); + + it("offers a retry when a Pi sign-in fails", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false, loginLabel: "Sign in with SuperGrok" }, + ]); + const startMock = window.ade.ai.piLoginStart as ReturnType; + startMock.mockResolvedValue({ ok: false, error: "Device code expired." }); + + renderProvidersSection(); + + const signIn = await screen.findByRole("button", { name: "Sign in with SuperGrok — xAI" }); + await act(async () => { + signIn.click(); + }); + expect(screen.getByText(/xAI: Device code expired\./)).toBeTruthy(); + + await act(async () => { + screen.getByRole("button", { name: "Try again" }).click(); + }); + expect(startMock).toHaveBeenCalledTimes(2); + expect(startMock).toHaveBeenLastCalledWith({ providerId: "xai", method: "oauth" }); + }); + + it("treats a cancelled Pi sign-in as a choice, not a failure", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false }, + ]); + let resolveLogin: ((result: { ok: boolean; error?: string }) => void) | null = null; + (window.ade.ai.piLoginStart as ReturnType).mockImplementation( + () => new Promise<{ ok: boolean; error?: string }>((resolve) => { + resolveLogin = resolve; + }), + ); + + renderProvidersSection(); + + const signIn = await screen.findByRole("button", { name: "Sign in — xAI" }); + await act(async () => { + signIn.click(); + }); + await act(async () => { + screen.getByRole("button", { name: "Cancel" }).click(); + }); + await act(async () => { + resolveLogin?.({ ok: false, error: "Sign-in cancelled." }); + }); + + const cancelled = screen.getByText("Sign-in cancelled."); + expect(cancelled.closest('[role="status"]')).toBeTruthy(); + expect(cancelled.closest('[role="alert"]')).toBeNull(); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); + }); + + it("merges a configured provider and its sign-in options into one tile", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "openai-codex", name: "OpenAI Codex", authTypes: ["oauth"], configured: true }, + ]); + + renderProvidersSection(); + + expect((await screen.findAllByText("OpenAI Codex")).length).toBe(1); + expect(screen.getByRole("button", { name: "Sign in — OpenAI Codex" })).toBeTruthy(); + expect(screen.getByText(/7 models/)).toBeTruthy(); + }); + + it("keeps Pi's terminal login reachable in one click", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([]); + + renderProvidersSection(); + + expect(await screen.findByRole("button", { name: /Open Pi \/login/ })).toBeTruthy(); + }); + + it("explains the Pi card instead of stranding it when Pi's SDK is missing", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { + piInstallation: { + ...buildPiInstallation(), + sdkAvailable: false, + providers: [], + availableModelIds: [], + blocker: "Pi is installed, but ADE cannot load its package here.", + }, + })); + + renderProvidersSection(); + + expect(await screen.findAllByText("Pi is installed, but ADE cannot load its package here.")).toHaveLength(1); + expect(screen.getByRole("button", { name: /Open Pi \/login/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Refresh providers/ })).toBeNull(); + expect(window.ade.ai.piLoginProviders).not.toHaveBeenCalled(); + }); + 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 9df29b3d3..0338d6e09 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -13,6 +13,10 @@ import type { import type { AiCustomProviderConfig, OpenCodeProviderAuthMethods, + PiAuthNotice, + PiAuthPrompt, + PiLoginMethod, + PiLoginProvider, } from "../../../shared/types/config"; import { getLocalModelIdTail, @@ -535,6 +539,472 @@ function buildPiMessage( return "Pi is installed, but no configured providers or available models were detected yet."; } +type PiSignInFlow = { + providerId: string; + /** Kept so a failed flow can be retried with the button the user actually pressed. */ + method: PiLoginMethod | null; + prompt: PiAuthPrompt | null; + /** Sticky auth URL / device code the user still has to act on. */ + link: PiAuthNotice | null; + progress: string | null; +}; + +/** Cancelling is a choice, not a failure, so it gets its own state instead of an error. */ +type PiSignInOutcome = { + providerId: string; + method: PiLoginMethod | null; + state: "ok" | "cancelled" | "error"; + error?: string; +}; + +/** One provider row: what Pi already has configured, what can be signed into, or both. */ +type PiProviderRow = { + id: string; + name: string; + status: AiPiProviderStatus | null; + login: PiLoginProvider | null; +}; + +function piLoginMethodLabel(provider: PiLoginProvider, method: PiLoginMethod): string { + if (method === "api_key") return "Use an API key"; + return provider.loginLabel ?? (provider.isSubscription ? "Sign in with your subscription" : "Sign in"); +} + +/** Pi only sends options for select prompts; every other kind takes free text. */ +function piChoiceOptions(prompt: PiAuthPrompt | null | undefined): NonNullable { + return prompt?.options ?? []; +} + +/** Merges both provider sources by id so a signable, configured provider is one row, not two. */ +function buildPiProviderRows(configured: AiPiProviderStatus[], signable: PiLoginProvider[]): PiProviderRow[] { + const rows = new Map(); + for (const status of configured) { + rows.set(status.id, { id: status.id, name: status.name, status, login: null }); + } + for (const login of signable) { + const existing = rows.get(login.id); + if (existing) existing.login = login; + else rows.set(login.id, { id: login.id, name: login.name, status: null, login }); + } + return [...rows.values()]; +} + +/** Shared shell so a provider looks the same whether it is connected, signable, or both. */ +function PiProviderTile({ provider, children }: { provider: PiProviderRow; children: React.ReactNode }) { + const connected = provider.status?.configured ?? provider.login?.configured ?? false; + const noModels = connected && provider.status != null && provider.status.availableModelCount === 0; + return ( +
+
+
+ + + {provider.name} + +
+ {noModels ? ( + + No models + + ) : connected ? : null} +
+ {children} +
+ ); +} + +/** Pi's own terminal flow, offered next to the in-app one rather than behind a reveal. */ +function PiTerminalFallback({ + installation, + onRevealTerminal, +}: { + installation: AiPiInstallationStatus; + onRevealTerminal: (terminal: { terminalId: string; laneId: string }) => void; +}) { + return ( +
+ {installation.cliAvailable ? ( + + ) : ( + + Install Pi to sign in from its terminal. + + )} +
+ ); +} + +/** + * Runs Pi's own sign-in inside ADE: pick a provider, then answer whatever Pi + * asks. Pi writes the credential itself — nothing typed here is kept by ADE. + */ +function PiSignIn({ + installation, + onSignedIn, + onRevealTerminal, +}: { + installation: AiPiInstallationStatus; + onSignedIn: () => void; + onRevealTerminal: (terminal: { terminalId: string; laneId: string }) => void; +}) { + const [providers, setProviders] = useState(null); + const [providersError, setProvidersError] = useState(null); + const [loadingProviders, setLoadingProviders] = useState(false); + const [flow, setFlow] = useState(null); + const [promptValue, setPromptValue] = useState(""); + const [outcome, setOutcome] = useState(null); + const promptInputRef = useRef(null); + const firstChoiceRef = useRef(null); + const retryButtonRef = useRef(null); + /** Pi reports a cancel as a plain failure, so remember that the user asked for it. */ + const cancelledProviderRef = useRef(null); + const promptFieldId = React.useId(); + const promptLabelId = `${promptFieldId}-label`; + const { copy, copied } = useCopyToClipboard(); + + const loadProviders = useCallback(async () => { + setLoadingProviders(true); + try { + const listed = await window.ade.ai.piLoginProviders(); + setProviders(listed); + setProvidersError(null); + } catch (err) { + setProvidersError(err instanceof Error ? err.message : String(err)); + } finally { + setLoadingProviders(false); + } + }, []); + + useEffect(() => { + if (!installation.sdkAvailable) return; + void loadProviders(); + }, [installation.sdkAvailable, loadProviders]); + + useEffect(() => { + const unsubscribe = window.ade.ai.onPiAuthStatus((event) => { + setFlow((current) => { + if (!current || current.providerId !== event.providerId) return current; + if (event.state === "prompt" && event.prompt) return { ...current, prompt: event.prompt }; + if (event.state !== "pending" || !event.notice) return current; + // A URL or device code is the step the user has to act on, so it stays + // on screen; plain progress lines replace each other. + return event.notice.url || event.notice.userCode + ? { ...current, link: event.notice, progress: null } + : { ...current, progress: event.notice.message }; + }); + if (event.state === "prompt") setPromptValue(""); + }); + return unsubscribe; + }, []); + + useEffect(() => { + if (!flow?.prompt) return; + // A choice prompt unmounts whatever held focus, so hand focus to the first + // option rather than letting it fall back to the document. + if (piChoiceOptions(flow.prompt).length) firstChoiceRef.current?.focus(); + else promptInputRef.current?.focus(); + }, [flow?.prompt]); + + useEffect(() => { + // Settling unmounts the flow card, which is where focus was; without this a + // keyboard user lands back on the document body instead of the one control + // that can recover the failed attempt. + if (outcome?.state === "error") retryButtonRef.current?.focus(); + }, [outcome]); + + const start = async (providerId: string, method?: PiLoginMethod) => { + setOutcome(null); + setPromptValue(""); + cancelledProviderRef.current = null; + setFlow({ providerId, method: method ?? null, prompt: null, link: null, progress: null }); + try { + const result = await window.ade.ai.piLoginStart({ providerId, ...(method ? { method } : {}) }); + const cancelled = !result.ok && cancelledProviderRef.current === providerId; + setOutcome({ + providerId, + method: method ?? null, + state: result.ok ? "ok" : cancelled ? "cancelled" : "error", + ...(result.ok || cancelled || !result.error ? {} : { error: result.error }), + }); + if (result.ok) { + onSignedIn(); + void loadProviders(); + } + } catch (err) { + setOutcome({ + providerId, + method: method ?? null, + state: "error", + error: err instanceof Error ? err.message : String(err), + }); + } finally { + // A second sign-in may already own these, so only this attempt's own + // state is torn down here. + if (cancelledProviderRef.current === providerId) cancelledProviderRef.current = null; + setFlow((current) => (current?.providerId === providerId ? null : current)); + } + }; + + const answer = async (value: string) => { + const prompt = flow?.prompt; + if (!flow || !prompt) return; + setFlow((current) => (current ? { ...current, prompt: null } : current)); + setPromptValue(""); + const fail = (error: string) => + setOutcome({ providerId: flow.providerId, method: flow.method, state: "error", error }); + try { + // A rejected answer comes back as ok:false rather than a throw, and the + // prompt is already gone — without this the user waits out Pi's login + // timeout with nothing on screen. + const result = await window.ade.ai.piLoginSubmit({ + providerId: flow.providerId, + requestId: prompt.requestId, + value, + }); + if (!result.ok) fail(result.error ?? "Pi did not accept that answer."); + } catch (err) { + fail(err instanceof Error ? err.message : String(err)); + } + }; + + const cancel = () => { + if (!flow) return; + cancelledProviderRef.current = flow.providerId; + void window.ade.ai.piLoginCancel({ providerId: flow.providerId }).catch(() => undefined); + }; + + const signableProviders = providers ?? []; + const providerName = (providerId: string) => + signableProviders.find((provider) => provider.id === providerId)?.name + ?? installation.providers.find((provider) => provider.id === providerId)?.name + ?? providerId; + const providerRows = buildPiProviderRows( + installation.providers.filter((provider) => provider.configured), + signableProviders, + ); + const prompt = flow?.prompt ?? null; + const choiceOptions = piChoiceOptions(prompt); + const link = flow?.link ?? null; + const userCode = link?.userCode ?? null; + const verifyUrl = link?.url ?? link?.verificationUri ?? null; + + if (!installation.sdkAvailable) { + return ( + // The card's own message already states the blocker, so this branch only + // has to offer the way out. +
+
Sign in
+ +
+ ); + } + + return ( +
+
+
Providers
+ +
+ + {providersError ? ( +
+ Could not list Pi providers: {providersError} +
+ ) : null} + + {flow ? ( +
+
+ Signing in to {providerName(flow.providerId)} +
+
+ {flow.progress ?? "Waiting for Pi…"} +
+ + {userCode ? ( +
+ Code + + {userCode} + + +
+ ) : null} + + {verifyUrl ? ( +
+ + {verifyUrl} + + +
+ ) : null} + + {prompt ? ( +
+ {/* A label may only point at a form control, so the choice + branch names its button group instead of borrowing htmlFor. */} + + {choiceOptions.length ? ( +
+ {choiceOptions.map((option, index) => ( + + ))} +
+ ) : ( +
{ + event.preventDefault(); + void answer(promptValue); + }} + > + setPromptValue(event.target.value)} + style={{ flex: "1 1 220px", minWidth: 0, background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, padding: "6px 8px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} + /> + +
+ )} +
+ ) : null} + +
+ +
+
+ ) : null} + + {/* The live region stays mounted across settles: a status/alert node + inserted at the same instant its text appears is announced + unreliably. Empty, it leaves the flow so it adds no column gap. The + role alone carries urgency — an explicit aria-live would demote an + alert back to polite. */} +
+ {outcome ? ( + + {outcome.state === "ok" + ? `Signed in to ${providerName(outcome.providerId)}.` + : outcome.state === "cancelled" + ? "Sign-in cancelled." + : `${providerName(outcome.providerId)}: ${outcome.error ?? "Sign-in did not finish."}`} + + ) : null} + {outcome?.state === "error" ? ( + + ) : null} +
+ + {/* Starting a second sign-in cancels the first, so the list steps aside + while one is running. */} + {!flow && providerRows.length ? ( +
+ {providerRows.map((row) => { + const { status, login } = row; + const modelCount = status ? status.availableModelCount || status.modelCount : 0; + return ( + + {status ? ( + <> +
+ {modelCount} model{modelCount === 1 ? "" : "s"} + {status.availableModelCount > 0 && status.modelCount > status.availableModelCount ? ` · ${status.modelCount} known` : ""} +
+
+ {piProviderAuthSummary(status)}{status.authLabel ? ` · ${status.authLabel}` : ""} +
+ + ) : null} + {login ? ( +
+ {login.authTypes.map((method) => ( + + ))} +
+ ) : null} +
+ ); + })} +
+ ) : null} + + {!flow && providers !== null && !signableProviders.length && !providersError ? ( +
+ {providerRows.length + ? "These read their keys from the environment — there is nothing to sign in to here." + : "No providers are set up in Pi yet."} +
+ ) : null} + + +
+ ); +} + function piProviderAuthSummary(provider: AiPiProviderStatus): string { if (provider.authType === "oauth") return provider.subscription ? "OAuth subscription" : "OAuth"; if (provider.authType === "api-key") return "API key"; @@ -1361,7 +1831,6 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh : isInitialCheckInFlight ? "Checking Pi installation and provider inventory." : buildPiMessage(piConnection, piInstallation); - const configuredProviders = piInstallation?.providers.filter((provider) => provider.configured) ?? []; return (
@@ -1397,55 +1866,18 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh ) : 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}` : ""} -
-
- ))} -
-
+ {piInstallation ? ( + void refreshStatus({ force: true })} + onRevealTerminal={(terminal) => revealTerminalSessionInWork(navigate, terminal)} + /> ) : null} {piInstallation ? (
- revealTerminalSessionInWork(navigate, terminal)} - /> - {!piInstallation.cliAvailable ? ( - <> - - Install the Pi CLI to use Pi’s native /login flow. - - - + {!piInstallation.sdkAvailable ? ( + ) : null} {piInstallation.settingsFileDetected ? (