diff --git a/docs/providers.md b/docs/providers.md index d491d082b..f9e0d600f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -24,6 +24,8 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`. +Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them. + Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. --- diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 7fcc9a2f6..1ff035e71 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -54,6 +54,8 @@ import { runDesktopStartup } from "./desktop-startup.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; const PASEO_DEBUG = process.env.PASEO_DEBUG === "1"; +const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1"; +const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo"; function isAllowedBrowserWebviewUrl(value: string | undefined): boolean { if (!value) { @@ -108,7 +110,7 @@ const FORWARDED_PASEO_SHORTCUT_KEYS = new Set([ ]); const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE"; const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop"; -app.setName("Paseo"); +app.setName(APP_NAME); function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null { const prefix = "persist:paseo-browser-"; @@ -374,7 +376,7 @@ async function createMainWindow(): Promise { const iconPath = getWindowIconPath(); const systemTheme = resolveSystemWindowTheme(); - const title = devWorktreeName ? `Paseo (${devWorktreeName})` : "Paseo"; + const title = devWorktreeName ? `${APP_NAME} (${devWorktreeName})` : APP_NAME; const mainWindow = new BrowserWindow({ title, width: 1200, @@ -522,6 +524,11 @@ function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void { // --------------------------------------------------------------------------- function setupSingleInstanceLock(): boolean { + if (DISABLE_SINGLE_INSTANCE_LOCK) { + log.info("[single-instance] disabled by PASEO_DISABLE_SINGLE_INSTANCE_LOCK"); + return true; + } + const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.quit(); diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 0984a7095..581b39f7f 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -102,6 +102,20 @@ class SessionEvents { ); } + nextPermissionRequest(): Promise> { + return this.nextEvent( + (event): event is Extract => + event.type === "permission_requested", + ); + } + + nextPermissionResolution(): Promise> { + return this.nextEvent( + (event): event is Extract => + event.type === "permission_resolved", + ); + } + private nextEvent( predicate: (event: AgentStreamEvent) => event is T, ): Promise { @@ -119,6 +133,125 @@ class SessionEvents { } describe("PiRpcAgentSession", () => { + test("bridges Pi RPC select extension UI requests through question permissions", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + await session.startTurn("ask"); + fakeSession.emit({ + type: "extension_ui_request", + id: "ui-1", + method: "select", + title: "Pick one", + options: ["A", "B"], + }); + + const permission = await events.nextPermissionRequest(); + expect(permission.request).toMatchObject({ + id: "ui-1", + provider: "pi", + kind: "question", + title: "Pick one", + input: { + questions: [ + { + question: "Pick one", + header: "Response", + options: [{ label: "A" }, { label: "B" }], + multiSelect: false, + }, + ], + }, + metadata: { extensionUiMethod: "select" }, + }); + expect(session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission("ui-1", { + behavior: "allow", + updatedInput: { answers: { Response: "B" } }, + }); + + expect(fakeSession.extensionUiResponses).toEqual([{ id: "ui-1", response: { value: "B" } }]); + expect(session.getPendingPermissions()).toEqual([]); + await expect(events.nextPermissionResolution()).resolves.toMatchObject({ + requestId: "ui-1", + resolution: { behavior: "allow" }, + }); + }); + + test("bridges Pi RPC input and confirm extension UI responses", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + fakeSession.emit({ + type: "extension_ui_request", + id: "input-1", + method: "input", + title: "Your name", + placeholder: "name", + }); + await events.nextPermissionRequest(); + await session.respondToPermission("input-1", { + behavior: "allow", + updatedInput: { answers: { Response: "Ada" } }, + }); + + fakeSession.emit({ + type: "extension_ui_request", + id: "confirm-1", + method: "confirm", + title: "Proceed?", + }); + await events.nextPermissionRequest(); + await session.respondToPermission("confirm-1", { + behavior: "allow", + updatedInput: { answers: { Response: "No" } }, + }); + + expect(fakeSession.extensionUiResponses).toEqual([ + { id: "input-1", response: { value: "Ada" } }, + { id: "confirm-1", response: { confirmed: false } }, + ]); + }); + + test("cancels Pi RPC extension UI dialogs when question permission is denied", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + fakeSession.emit({ + type: "extension_ui_request", + id: "ui-cancel", + method: "select", + title: "Pick one", + options: ["A", "B"], + }); + await events.nextPermissionRequest(); + + await session.respondToPermission("ui-cancel", { + behavior: "deny", + message: "Dismissed by user", + }); + + expect(fakeSession.extensionUiResponses).toEqual([ + { id: "ui-cancel", response: { cancelled: true } }, + ]); + }); + + test("ignores Pi RPC fire-and-forget extension UI requests", async () => { + const { pi } = await createSession(); + const fakeSession = pi.latestSession(); + + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-1", + method: "notify", + message: "hello", + }); + + expect(fakeSession.extensionUiResponses).toEqual([]); + expect(fakeSession.canceledExtensionUiRequests).toEqual([]); + }); + test("streams assistant text, reasoning, and tool calls from Pi events", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 1f5dc012b..182d0e34d 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -403,6 +403,108 @@ function latestPiErrorMessage(messages: PiAgentMessage[]): string | null { : null; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function mapExtensionUiRequestToPermission( + event: Extract, +): AgentPermissionRequest | null { + switch (event.method) { + case "select": + return buildExtensionUiQuestionPermission(event, { + question: optionalString(event.title) ?? "Select an option", + options: Array.isArray(event.options) + ? event.options.filter((option): option is string => typeof option === "string") + : [], + multiSelect: false, + }); + case "input": + return buildExtensionUiQuestionPermission(event, { + question: optionalString(event.title) ?? "Enter a value", + options: [], + multiSelect: false, + }); + case "editor": + return buildExtensionUiQuestionPermission(event, { + question: optionalString(event.title) ?? "Edit text", + options: [], + multiSelect: false, + }); + case "confirm": + return buildExtensionUiQuestionPermission(event, { + question: [optionalString(event.title), optionalString(event.message)] + .filter(Boolean) + .join("\n\n"), + options: ["Yes", "No"], + multiSelect: false, + }); + default: + return null; + } +} + +function buildExtensionUiQuestionPermission( + event: Extract, + input: { question: string; options: string[]; multiSelect: boolean }, +): AgentPermissionRequest { + const header = "Response"; + return { + id: event.id, + provider: PI_PROVIDER, + name: `Pi ${event.method}`, + kind: "question", + title: input.question, + input: { + questions: [ + { + question: input.question, + header, + options: input.options.map((label) => ({ label })), + multiSelect: input.multiSelect, + }, + ], + }, + metadata: { + extensionUiMethod: event.method, + answerHeader: header, + }, + }; +} + +function firstPermissionAnswer(input: AgentMetadata | undefined): string | null { + const answers = isRecord(input?.answers) ? input.answers : null; + if (!answers) { + return null; + } + const first = Object.values(answers).find((value) => typeof value === "string"); + return typeof first === "string" ? first : null; +} + +function buildExtensionUiResponse( + request: AgentPermissionRequest, + response: AgentPermissionResponse, +): { value?: string; confirmed?: boolean; cancelled?: boolean } { + if (response.behavior === "deny") { + return { cancelled: true }; + } + + const method = optionalString(request.metadata?.extensionUiMethod); + const answer = firstPermissionAnswer(response.updatedInput); + if (answer === null) { + return { cancelled: true }; + } + + if (method === "confirm") { + return { confirmed: /^yes$/i.test(answer.trim()) }; + } + return { value: answer }; +} + function mapPiModel(model: PiModel): AgentModelDefinition { return { provider: PI_PROVIDER, @@ -428,6 +530,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly activeToolCalls = new Map(); + private readonly pendingExtensionUiRequests = new Map(); private activeTurnId: string | null = null; private lastKnownThinkingOptionId: string | null; private state: PiSessionState; @@ -540,12 +643,27 @@ export class PiRpcAgentSession implements AgentSession { } getPendingPermissions(): AgentPermissionRequest[] { - return []; + return [...this.pendingExtensionUiRequests.values()]; } async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { - void requestId; - void response; + const request = this.pendingExtensionUiRequests.get(requestId); + if (!request) { + throw new Error(`No pending permission request with id '${requestId}'`); + } + this.pendingExtensionUiRequests.delete(requestId); + + this.runtimeSession.respondToExtensionUiRequest( + requestId, + buildExtensionUiResponse(request, response), + ); + this.emit({ + type: "permission_resolved", + provider: PI_PROVIDER, + requestId, + resolution: response, + turnId: this.currentTurnIdForEvent(), + }); } describePersistence(): AgentPersistenceHandle | null { @@ -624,9 +742,26 @@ export class PiRpcAgentSession implements AgentSession { return this.activeTurnId ?? undefined; } + private handleExtensionUiRequest( + event: Extract, + ): void { + const request = mapExtensionUiRequestToPermission(event); + if (!request) { + return; + } + + this.pendingExtensionUiRequests.set(request.id, request); + this.emit({ + type: "permission_requested", + provider: PI_PROVIDER, + request, + turnId: this.currentTurnIdForEvent(), + }); + } + private handleRuntimeEvent(event: PiRuntimeEvent): void { if (event.type === "extension_ui_request") { - this.runtimeSession.cancelExtensionUiRequest(event.id); + this.handleExtensionUiRequest(event); return; } if (event.type === "process_exit") { diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 9be0c59f6..84f8cbaf9 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -166,8 +166,15 @@ class PiCliRuntimeSession implements PiRuntimeSession { return data.commands ?? []; } + respondToExtensionUiRequest( + id: string, + response: { value?: string; confirmed?: boolean; cancelled?: boolean }, + ): void { + this.writeJsonLine({ type: "extension_ui_response", id, ...response }); + } + cancelExtensionUiRequest(id: string): void { - this.writeJsonLine({ type: "extension_ui_response", id, cancelled: true }); + this.respondToExtensionUiRequest(id, { cancelled: true }); } async close(): Promise { diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index 8925ed1a0..f119bad6a 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -43,6 +43,10 @@ export interface PiRuntimeSession { setThinkingLevel(level: string): Promise; getSessionStats(): Promise; getCommands(): Promise; + respondToExtensionUiRequest( + id: string, + response: { value?: string; confirmed?: boolean; cancelled?: boolean }, + ): void; cancelExtensionUiRequest(id: string): void; close(): Promise; } diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 9542bc602..7cee9d669 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -55,6 +55,10 @@ export class FakePiSession implements PiRuntimeSession { readonly setThinkingLevelRequests: string[] = []; abortRequested = false; readonly canceledExtensionUiRequests: string[] = []; + readonly extensionUiResponses: Array<{ + id: string; + response: { value?: string; confirmed?: boolean; cancelled?: boolean }; + }> = []; setModelResult: PiModel | null = null; models: PiModel[] = []; messages: PiAgentMessage[] = []; @@ -130,8 +134,16 @@ export class FakePiSession implements PiRuntimeSession { return this.commands; } + respondToExtensionUiRequest( + id: string, + response: { value?: string; confirmed?: boolean; cancelled?: boolean }, + ): void { + this.extensionUiResponses.push({ id, response }); + } + cancelExtensionUiRequest(id: string): void { this.canceledExtensionUiRequests.push(id); + this.respondToExtensionUiRequest(id, { cancelled: true }); } async close(): Promise {}