diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index f346f35ac4..6fdb3de493 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -31,10 +31,7 @@ import { type KeybindingOverrides, type PlanExecutionFinishStatus, } from "@pi-desktop/shared"; -import { - genericModelConfig, - summarizeSessionTitle, -} from "@pi-desktop/agent-runtime"; +import { summarizeSessionTitle } from "@pi-desktop/agent-runtime"; import { AgentExtensionBridge } from "./agent-extensions"; import { registerAgentExtensionIpc } from "./agent-extensions-ipc"; import { isTemplateName, scaffold } from "@pi-desktop/plugin-devkit"; @@ -56,11 +53,8 @@ import { installMainProcessErrorHandlers } from "./main-process-errors"; import { isDbSchemaTooNewError, } from "./host-boot-diagnostics"; -import { - ModelsDevCatalog, - modelConfigFromModelsDev, -} from "./models-dev-catalog"; -import { VendorOAuth } from "./oauth"; +import { ModelsDevCatalog } from "./models-dev-catalog"; +import { createVendorAccounts } from "./runtime/vendor-accounts"; import { AppUpdaterController } from "./updater"; import { catalogs, resolveLocale } from "@pi-desktop/i18n"; import { @@ -120,6 +114,7 @@ import { import { registerApplicationActivation } from "./bootstrap/app-activation"; import type { RuntimeState } from "./runtime/context"; import { createHostRuntime } from "./runtime/host"; +import { createPermissionReviewResolver } from "./services/permission-review"; import { createSidecarRuntime } from "./runtime/sidecar"; import { createEventPersistence } from "./runtime/event-persistence"; import { createPlanRuntime, type PlanRuntimeState } from "./runtime/plans"; @@ -607,27 +602,8 @@ const modelsDevCatalog = new ModelsDevCatalog({ : join(app.getAppPath(), "resources", "models.dev", "api.json"), }); -const vendorOAuth = new VendorOAuth({ - call: (method: string, params?: unknown): Promise => { - if (!host) throw new Error("host unavailable"); - return host.call(method, params); - }, - emit: (event) => sendToRenderer(IPC.event.providersOauth, event), - openExternal: async (url) => { - await safeOpenExternal(url); - }, - log: (level, message, data) => logger.app("provider", level, message, { data }), - modelConfigFor: async ({ vendorKey, option }) => { - await modelsDevCatalog.ensureLoaded(); - const model = modelsDevCatalog.findModel({ - vendorKey, - baseUrl: option.baseUrl, - modelId: option.modelId, - }); - return model - ? modelConfigFromModelsDev(model, option.baseUrl) - : genericModelConfig(option.modelId, option.baseUrl); - }, +const vendorOAuth = createVendorAccounts({ + getHost: () => host, sendToRenderer, safeOpenExternal, logger, modelsDevCatalog, }); let sessionLaunchRuntime: ReturnType | null = null; @@ -1061,6 +1037,7 @@ const { isTurnDispatchable, isSessionBusy, isStaleTerminalEvent, + isStaleRuntimeActivity, } = sessionCoordination; async function withGitBranch( @@ -1171,18 +1148,21 @@ const eventPersistence = createEventPersistence({ }); const { persistAgentEvent } = eventPersistence; +let cancelReviewForEvent: (envelope: AgentEventEnvelope) => void = () => {}; const sidecarRuntime = createSidecarRuntime({ runtimeState, steeringReplies, logger, sendToRenderer, persistAgentEvent, + onAgentEvent: (envelope) => cancelReviewForEvent(envelope), activeTurns, approvedExecutionIdsBySession, claimedExecutionSessions, inflightCheckpointer, finishTurn, isStaleTerminalEvent, + isStaleRuntimeActivity, finishApprovedExecution, superviseRestart, isQuitting: () => quitting, @@ -1203,7 +1183,8 @@ const sidecarRuntime = createSidecarRuntime({ emitAgentEvent = sidecarRuntime.emitAgentEvent; const { wireSidecar, startSidecar } = sidecarRuntime; -const { wireHost, startHost } = createHostRuntime({ +const { wireHost, startHost, cancelReviewForEvent: cancelHostReviewForEvent, + cancelReviewForSession, takeOverSessionReviews } = createHostRuntime({ runtimeState, dataDir, logger, @@ -1226,7 +1207,17 @@ const { wireHost, startHost } = createHostRuntime({ importLegacyScheduled, superviseRestart, isQuitting: () => quitting, + settleExternalApproval: (requestId, decision) => { + agentHostBridge?.settleApproval(requestId, { decision }); + }, + reviewPermission: createPermissionReviewResolver({ + getHost: () => runtimeState.host, + modelsDevCatalog, + vendorOAuth, + report: (code) => logger.app("permission", "warn", "review model unavailable", { data: { code } }), + }), }); +cancelReviewForEvent = cancelHostReviewForEvent; runtimeLifecycle = createRuntimeLifecycle({ runtimeState, @@ -1248,6 +1239,8 @@ const { bootHostStatus, runtimeArch, bootBackends } = runtimeLifecycle; function registerIpc() { return registerIpcHandlers({ + cancelReviewForSession, + takeOverSessionReviews, traySessions: applicationLifecycle!.traySessions, ipcMain, getMainWindow: () => mainWindow, diff --git a/apps/desktop/electron/main/ipc/agent-ipc.ts b/apps/desktop/electron/main/ipc/agent-ipc.ts index 2da6385ddd..d0c4cfee1c 100644 --- a/apps/desktop/electron/main/ipc/agent-ipc.ts +++ b/apps/desktop/electron/main/ipc/agent-ipc.ts @@ -1,4 +1,4 @@ -import { IPC, ErrorCodes, compactionRecordId, isGlobalPermissionMode, isRpcTimeoutError, type AgentEventEnvelope, type AgentPromptRequest, type AgentSteerRequest, type UiMessage, type AgentQueuePushRequest, type AgentStopRequest, type AskToolResolution, type GlobalPermissionMode, type MessageUsage, type PlanExecutionFinishStatus, type PlanResolutionResult, type PlanResolveRequest, type PromptEnhancementRequest, type SessionSummarizeTitleRequest, canonicalThinkingLevel, type ThinkingLevel } from "@pi-desktop/shared"; +import { IPC, ErrorCodes, compactionRecordId, isGlobalPermissionMode, isRpcTimeoutError, type AgentEventEnvelope, type AgentPromptRequest, type AgentSteerRequest, type UiMessage, type AgentQueuePushRequest, type AgentStopRequest, type AskToolResolution, type GlobalPermissionMode, type MessageUsage, type PlanExecutionFinishStatus, type PlanResolutionResult, type PlanResolveRequest, type PromptEnhancementRequest, type SessionSummarizeTitleRequest, type ToolPermissionResolution, canonicalThinkingLevel, type ThinkingLevel } from "@pi-desktop/shared"; import type { FinishTurn } from "../runtime/plans"; import { expandSlashInvocation, enhancePromptDraft, summarizeSessionTitle, visionFromModelConfig, type ComposerTemplate, type RuntimeProviderConfig } from "@pi-desktop/agent-runtime"; import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth"; @@ -13,6 +13,7 @@ import type { PersistenceOutbox } from "../persistence-outbox"; import type { ComposerCommandService } from "./composer-ipc"; import type { IpcRegistrar } from "./types"; import { withPromptEnhancementTimeout } from "../prompt-enhancement-timeout"; +import { isRemoteSessionId, parseRemoteApprovalRequestId } from "../remote/backend-router"; export type AgentIpcDependencies = { registrar: IpcRegistrar; @@ -23,6 +24,7 @@ export type AgentIpcDependencies = { vendorOAuth: VendorOAuth; agentExtensions: AgentExtensionBridge; cancelSessionTools: (sessionId: string, reason?: string) => void; + takeOverSessionReviews: (sessionId: string) => Promise; persistenceOutbox: PersistenceOutbox; dataDir: string; activeTurns: Map; @@ -56,6 +58,14 @@ function rejectNativeAgentOperation(sessionId: string): void { } } +function rejectRemotePermissionOperation(sessionId: string): void { + if (isRemoteSessionId(sessionId)) { + throw Object.assign(new Error("Permission review and grants are unavailable on remote sessions"), { + errorCode: ErrorCodes.CAPABILITY_UNAVAILABLE, + }); + } +} + /** Register prompt, agent lifecycle, queue, approval and plan channels. */ export function registerAgentIpc({ registrar, @@ -66,6 +76,7 @@ export function registerAgentIpc({ vendorOAuth, agentExtensions, cancelSessionTools, + takeOverSessionReviews, persistenceOutbox, dataDir, activeTurns, @@ -712,6 +723,7 @@ export function registerAgentIpc({ handle(IPC.invoke.agentStop, async (req: AgentStopRequest) => { if (!sidecar) throw new Error("sidecar unavailable"); + await takeOverSessionReviews(req.sessionId); logger.app("session", "info", "prompt graceful stop requested", { sessionId: req.sessionId, }); @@ -760,10 +772,7 @@ export function registerAgentIpc({ }, ); - handle(IPC.invoke.toolResolvePermission, async (resolution: { - requestId: string; - decision: string; - }) => { + handle(IPC.invoke.toolResolvePermission, async (resolution: ToolPermissionResolution) => { if (!host) throw new Error("host unavailable"); logger.app("permission", "info", "permission resolved", { data: { requestId: resolution.requestId, decision: resolution.decision }, @@ -779,6 +788,42 @@ export function registerAgentIpc({ return resolved; }); + handle(IPC.invoke.permissionTakeoverReview, async ({ requestId }: { requestId: string }) => { + if (!requestId?.trim()) throw new Error("requestId required"); + const remoteRequest = parseRemoteApprovalRequestId(requestId); + if (remoteRequest) rejectRemotePermissionOperation(remoteRequest.remoteSessionId); + if (!host) throw new Error("host unavailable"); + return host.call("permissions.takeoverReview", { requestId }); + }); + + handle(IPC.invoke.permissionListSessionGrants, async ({ sessionId }: { sessionId: string }) => { + if (!sessionId?.trim()) throw new Error("sessionId required"); + rejectRemotePermissionOperation(sessionId); + if (!host) throw new Error("host unavailable"); + return host.call("permissions.listSessionGrants", { sessionId }); + }); + + handle(IPC.invoke.permissionRevokeSessionGrant, async ({ sessionId, grantId }: { sessionId: string; grantId: string }) => { + if (!sessionId?.trim() || !grantId?.trim()) throw new Error("sessionId and grantId required"); + rejectRemotePermissionOperation(sessionId); + if (!host) throw new Error("host unavailable"); + return host.call("permissions.revokeSessionGrant", { sessionId, grantId }); + }); + + handle(IPC.invoke.permissionClearSessionGrants, async ({ sessionId }: { sessionId: string }) => { + if (!sessionId?.trim()) throw new Error("sessionId required"); + rejectRemotePermissionOperation(sessionId); + if (!host) throw new Error("host unavailable"); + return host.call("permissions.clearSessionGrants", { sessionId }); + }); + + handle(IPC.invoke.permissionListReviewHistory, async ({ sessionId }: { sessionId: string }) => { + if (!sessionId?.trim()) throw new Error("sessionId required"); + rejectRemotePermissionOperation(sessionId); + if (!host) throw new Error("host unavailable"); + return host.call("permissions.listReviewHistory", { sessionId }); + }); + handle(IPC.invoke.askToolResolve, async (resolution: AskToolResolution) => { if (!sidecar) throw new Error("sidecar unavailable"); const sessionId = String(resolution?.sessionId ?? "").trim(); diff --git a/apps/desktop/electron/main/ipc/register.ts b/apps/desktop/electron/main/ipc/register.ts index b48a03528c..3dfada246e 100644 --- a/apps/desktop/electron/main/ipc/register.ts +++ b/apps/desktop/electron/main/ipc/register.ts @@ -35,6 +35,8 @@ import type { IpcRegistrar } from "./types"; import type { createTraySessions } from "../tray-sessions"; export type RegisterIpcDependencies = { + cancelReviewForSession?: (sessionId: string) => void; + takeOverSessionReviews?: (sessionId: string) => Promise; isQuitting: () => boolean; ipcMain: IpcMain; getMainWindow: () => BrowserWindow | null; @@ -76,6 +78,8 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { getHost, getSidecar, getAgentHostBridge, + cancelReviewForSession, + takeOverSessionReviews, getBackendRouter, getNotificationViewingSessionId, setNotificationViewingSessionId, @@ -355,10 +359,14 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { }); registerAgentIpc({ registrar, + takeOverSessionReviews: (sessionId: string) => takeOverSessionReviews?.(sessionId) ?? Promise.resolve(), getHost, getSidecar, getAgentHostBridge, - cancelSessionTools: (sessionId: string, reason?: string) => plugins.cancelSessionTools(sessionId, reason), + cancelSessionTools: (sessionId: string, reason?: string) => { + cancelReviewForSession?.(sessionId); + plugins.cancelSessionTools(sessionId, reason); + }, logger, vendorOAuth, agentExtensions, diff --git a/apps/desktop/electron/main/ipc/session-ipc.ts b/apps/desktop/electron/main/ipc/session-ipc.ts index c63162b7c1..9af45f97b2 100644 --- a/apps/desktop/electron/main/ipc/session-ipc.ts +++ b/apps/desktop/electron/main/ipc/session-ipc.ts @@ -534,6 +534,7 @@ export function registerSessionIpc({ modelId?: string; thinkingLevel?: SessionThinkingLevel; permissionMode?: "inherit" | "ask" | "accept-edits" | "auto"; + approvalReviewer?: "inherit" | "user" | "auto_review"; }, ) => { rejectNativeMutation(id, "configuration"); diff --git a/apps/desktop/electron/main/runtime/host.ts b/apps/desktop/electron/main/runtime/host.ts index 95bdcfcc14..7792070c2e 100644 --- a/apps/desktop/electron/main/runtime/host.ts +++ b/apps/desktop/electron/main/runtime/host.ts @@ -1,6 +1,7 @@ import { ErrorCodes, IPC, type AgentEventEnvelope, type PlanExecutionFinishStatus, type Risk } from "@pi-desktop/shared"; import { assertLinuxGlibcSupported } from "../linux-glibc"; import { HostProcess } from "../host-process"; +import { PermissionReviewCoordinator, type ReviewAction, type PermissionReviewResult } from "@pi-desktop/host-runtime"; import type { Logger } from "../logger"; import type { PersistenceOutbox } from "../persistence-outbox"; import type { PluginRuntime } from "../plugin-runtime"; @@ -41,6 +42,8 @@ export type HostRuntimeDependencies = { importLegacyScheduled: () => Promise; superviseRestart: (kind: "host" | "sidecar") => Promise; isQuitting: () => boolean; + reviewPermission: (action: ReviewAction, signal: AbortSignal, sessionId: string) => Promise; + settleExternalApproval: (requestId: string, decision: "allow-once" | "deny") => void; }; export function createHostRuntime({ @@ -66,12 +69,61 @@ export function createHostRuntime({ importLegacyScheduled, superviseRestart, isQuitting, + reviewPermission, + settleExternalApproval, }: HostRuntimeDependencies): { wireHost: (host: HostProcess) => void; startHost: () => Promise; + cancelReviewForEvent: (event: AgentEventEnvelope) => void; + cancelReviewForSession: (sessionId: string) => void; + takeOverSessionReviews: (sessionId: string) => Promise; } { + let activeReviewCoordinator: PermissionReviewCoordinator | undefined; + const reviewRequests = new Map(); const wireHost = (h: HostProcess) => { - + reviewRequests.clear(); + const reviewCoordinator = new PermissionReviewCoordinator({ + claim: async (requestId) => { + const claimed = await h.call<{ token: string; fingerprint: string; action: ReviewAction }>( + "permissions.claimReview", { requestId }, + ); + const previous = reviewRequests.get(requestId); + if (previous) { + const next = { ...previous, reviewState: "reviewing" as const }; + reviewRequests.set(requestId, next); + emitAgentEvent({ sessionId: previous.sessionId, ts: Date.now(), event: { + type: "tool_permission_request", request: { ...next, requestId }, + } }); + } + return claimed; + }, + settle: async (requestId, token, fingerprint, result) => { + const settled = await h.call<{ decision: "allow_once" | "deny" | "needs_user" }>( + "permissions.resolveReview", { requestId, token, fingerprint, result }, + ); + // Host owns the final decision. It may downgrade a model approval to a + // manual request if the turn or evidence became stale while reviewing. + if (settled.decision === "needs_user") return; + reviewRequests.delete(requestId); + settleExternalApproval(requestId, settled.decision === "allow_once" ? "allow-once" : "deny"); + }, + fallback: (requestId, token, fingerprint, result) => + h.call("permissions.resolveReview", { requestId, token, fingerprint, result }).then(() => undefined), + }, reviewPermission, Date.now, (code) => { + logger.app("permission", "warn", "review could not complete", { data: { code } }); + }); + activeReviewCoordinator = reviewCoordinator; h.onNotification((method, params) => { // Notifications from a previous host generation must never reach the // current plugin/renderer bridge after a restart. @@ -83,6 +135,20 @@ export function createHostRuntime({ sendToRenderer(IPC.event.pluginInstallProgress, params); return; } + if (method === "permissions.reviewUpdated") { + const update = params as { requestId: string; reviewState: "user" | "awaiting_review" | "reviewing"; reason?: string }; + const previous = reviewRequests.get(update.requestId); + if (previous) { + if (update.reviewState === "user") reviewCoordinator.cancel(update.requestId); + const next = { ...previous, reviewState: update.reviewState, reason: update.reason ?? previous.reason }; + reviewRequests.set(update.requestId, next); + emitAgentEvent({ sessionId: previous.sessionId, ts: Date.now(), event: { + type: "tool_permission_request", request: { ...next, requestId: update.requestId }, + } }); + if (update.reviewState === "user") reviewRequests.delete(update.requestId); + } + return; + } if (method === "permissions.request") { const permission = params as { requestId: string; @@ -92,7 +158,19 @@ export function createHostRuntime({ argsPreview: string; risk: Risk; reason: string; + reviewState?: "user" | "awaiting_review" | "reviewing"; + scopeLabel?: string; + createdAt?: string; + expiresAt?: string; }; + reviewRequests.set(permission.requestId, permission); + if (permission.reviewState === "awaiting_review") { + const hostCreatedAt = permission.createdAt ? Date.parse(permission.createdAt) : NaN; + reviewCoordinator.enqueue({ + requestId: permission.requestId, sessionId: permission.sessionId, toolCallId: permission.toolCallId, + requestedAt: Number.isFinite(hostCreatedAt) ? hostCreatedAt : Date.now(), + }); + } // A delegate's call is already in `activeToolCalls` by the time the host // asks: the sidecar forwards `tool_start` before it executes the tool. // Without this the dialog would attribute a delegate's write to the main @@ -129,6 +207,10 @@ export function createHostRuntime({ argsPreview: permission.argsPreview, risk: permission.risk, reason: permission.reason, + ...(permission.reviewState ? { reviewState: permission.reviewState } : {}), + ...(permission.scopeLabel ? { scopeLabel: permission.scopeLabel } : {}), + ...(permission.createdAt ? { createdAt: permission.createdAt } : {}), + ...(permission.expiresAt ? { expiresAt: permission.expiresAt } : {}), ...(asking?.agentName ? { agentName: asking.agentName } : {}), ...(asking?.parentToolCallId ? { parentToolCallId: asking.parentToolCallId } @@ -141,6 +223,7 @@ export function createHostRuntime({ void (async () => { const q = params as { executionId: string; + permitToken?: string; sessionId?: string; /** * Runtime turn identity of the tool call, forwarded unchanged from the @@ -157,9 +240,24 @@ export function createHostRuntime({ ? (sessionProjects.get(q.sessionId) ?? null) : null; const tool = plugins.getTools().find((t) => t.fullName === q.toolName); + const consumePermit = async () => { + if (!q.permitToken || !q.sessionId || !q.turnId || !q.toolCallId || + !isTurnDispatchable(q.sessionId, q.turnId)) { + throw new Error("plugin execution permit or active turn missing"); + } + await h.call("permissions.consumeExecutionPermit", { + executionId: q.executionId, permitToken: q.permitToken, + sessionId: q.sessionId, turnId: q.turnId, + toolCallId: q.toolCallId, toolName: q.toolName, args: q.args, + }); + if (!isTurnDispatchable(q.sessionId, q.turnId)) { + throw new Error("plugin turn ended before dispatch"); + } + }; let payload: Record; if (q.toolName.startsWith("mcp_")) { try { + await consumePermit(); const result = await userMcp.callTool(q.toolName, q.args, projectPath); payload = { executionId: q.executionId, ok: true, content: result ?? null }; } catch (e) { @@ -248,6 +346,7 @@ export function createHostRuntime({ }, }; } else { + await consumePermit(); const result = await tool.execute(q.args, { sessionId: q.sessionId, turnId: q.turnId, @@ -312,6 +411,7 @@ export function createHostRuntime({ } }); h.onExit(({ code, signal, intentional }) => { + reviewCoordinator.dispose(); if (runtimeState.host !== h) return; logger.flushChild("host"); runtimeState.host = null; @@ -360,6 +460,9 @@ export function createHostRuntime({ runtimeState.host = h; try { await h.handshake(); + // A headless/older host defaults to manual approval until an actual + // reviewer executor registers on this host generation. + await h.call("permissions.setReviewCapability", { available: true }); logger.app("runtime", "info", "host-core handshake ok", { data: { generation: h.generation }, }); @@ -384,5 +487,57 @@ export function createHostRuntime({ throw error; } }; - return { wireHost, startHost }; + return { + wireHost, startHost, + cancelReviewForEvent: (envelope) => { + if (envelope.event.type === "tool_end") { + activeReviewCoordinator?.cancelForTool(envelope.sessionId, envelope.event.toolCallId); + for (const [id, request] of reviewRequests) { + if (request.sessionId === envelope.sessionId && request.toolCallId === envelope.event.toolCallId) { + reviewRequests.delete(id); + } + } + } else if (envelope.event.type === "turn_end" || envelope.event.type === "agent_end") { + activeReviewCoordinator?.cancelForSession(envelope.sessionId); + for (const [id, request] of reviewRequests) { + if (request.sessionId === envelope.sessionId) reviewRequests.delete(id); + } + } + }, + cancelReviewForSession: (sessionId) => { + activeReviewCoordinator?.cancelForSession(sessionId); + for (const [id, request] of reviewRequests) { + if (request.sessionId === sessionId) reviewRequests.delete(id); + } + }, + takeOverSessionReviews: async (sessionId) => { + activeReviewCoordinator?.cancelForSession(sessionId); + const host = runtimeState.host; + if (!host) return; + // A graceful stop cannot finish while this tool awaits approval. Move + // only active auto reviews to the manual state, then deny that pending + // tool via Host before the sidecar receives agent.stop. + const requests = [...reviewRequests].filter(([, item]) => item.sessionId === sessionId && + (item.reviewState === "awaiting_review" || item.reviewState === "reviewing")); + await Promise.all(requests.map(async ([id]) => { + try { + await host.call("permissions.takeoverReview", { requestId: id }); + await host.call("permissions.resolve", { requestId: id, decision: "deny" }); + reviewRequests.delete(id); + settleExternalApproval(id, "deny"); + } catch (error) { + const code = (error as { errorCode?: string; data?: { errorCode?: string } })?.data?.errorCode ?? + (error as { errorCode?: string })?.errorCode; + if (code === "NOT_FOUND" || code === "PERMISSION_TIMEOUT") { + reviewRequests.delete(id); + return; + } + logger.app("permission", "warn", "review stop denial failed", { + sessionId, data: { requestId: id, error: String(error) }, + }); + throw error; + } + })); + }, + }; } diff --git a/apps/desktop/electron/main/runtime/session-coordination.ts b/apps/desktop/electron/main/runtime/session-coordination.ts index babef544d9..2275880bbf 100644 --- a/apps/desktop/electron/main/runtime/session-coordination.ts +++ b/apps/desktop/electron/main/runtime/session-coordination.ts @@ -233,6 +233,23 @@ export function createSessionCoordination({ return !isActiveTurn(envelope.sessionId, envelope.turnId); } + /** Only a dispatchable durable turn may re-activate a session in Agent Host + * or the renderer. A canceled tool can emit a late turn_start without + * agent_end; its historical message/tool events still pass separately. + * Manual compaction is admitted by compaction_start (not a turn_start), even + * when the runtime still stamps its status with a previous turn identity. + */ + function isStaleRuntimeActivity(envelope: AgentEventEnvelope): boolean { + const type = envelope.event.type; + if (type === "agent_start" || type === "turn_start") { + return !isTurnDispatchable(envelope.sessionId, envelope.turnId); + } + if (type === "status" && (envelope.turnId || activeTurns.has(envelope.sessionId))) { + return !isTurnDispatchable(envelope.sessionId, envelope.turnId); + } + return false; + } + function shouldCreateTaskNotification(sessionId: string): boolean { const window = getMainWindow(); const liveWindow = window !== null && !window.isDestroyed(); @@ -266,5 +283,6 @@ export function createSessionCoordination({ isTurnDispatchable, isSessionBusy, isStaleTerminalEvent, + isStaleRuntimeActivity, }; } diff --git a/apps/desktop/electron/main/runtime/sidecar.ts b/apps/desktop/electron/main/runtime/sidecar.ts index d978656fd8..d3359950a7 100644 --- a/apps/desktop/electron/main/runtime/sidecar.ts +++ b/apps/desktop/electron/main/runtime/sidecar.ts @@ -30,6 +30,7 @@ export type SidecarRuntimeDependencies = { logger: Logger; sendToRenderer: (channel: string, payload: unknown) => void; persistAgentEvent: (envelope: AgentEventEnvelope) => UiMessage | undefined; + onAgentEvent?: (envelope: AgentEventEnvelope) => void; activeTurns: Map; approvedExecutionIdsBySession: Map; claimedExecutionSessions: Map; @@ -43,6 +44,7 @@ export type SidecarRuntimeDependencies = { * in Agent Host or the renderer. */ isStaleTerminalEvent: (envelope: AgentEventEnvelope) => boolean; + isStaleRuntimeActivity: (envelope: AgentEventEnvelope) => boolean; isQuitting: () => boolean; dataDir: string; agentExtensions: AgentExtensionBridge; @@ -65,12 +67,14 @@ export function createSidecarRuntime({ logger, sendToRenderer, persistAgentEvent, + onAgentEvent, activeTurns, approvedExecutionIdsBySession, claimedExecutionSessions, inflightCheckpointer, finishTurn, isStaleTerminalEvent, + isStaleRuntimeActivity, finishApprovedExecution, superviseRestart, isQuitting, @@ -96,7 +100,8 @@ export function createSidecarRuntime({ // A terminal event for a turn that no longer owns its session must not clear // the current turn's state in Agent Host or the renderer. Persistence is a // separate call, so dropping it here still archives it as history. - if (isStaleTerminalEvent(envelope)) return; + if (isStaleTerminalEvent(envelope) || isStaleRuntimeActivity(envelope)) return; + onAgentEvent?.(envelope); runtimeState.agentHostBridge?.ingest(envelope); sendToRenderer(IPC.event.agentMessage, envelope); }; diff --git a/apps/desktop/electron/main/runtime/vendor-accounts.ts b/apps/desktop/electron/main/runtime/vendor-accounts.ts new file mode 100644 index 0000000000..aa2e1319b5 --- /dev/null +++ b/apps/desktop/electron/main/runtime/vendor-accounts.ts @@ -0,0 +1,35 @@ +import { IPC } from "@pi-desktop/shared"; +import { genericModelConfig } from "@pi-desktop/agent-runtime"; +import type { HostProcess } from "../host-process"; +import type { Logger } from "../logger"; +import { modelConfigFromModelsDev, type ModelsDevCatalog } from "../models-dev-catalog"; +import { VendorOAuth } from "../oauth"; + +/** Keep OAuth credentials and catalog-backed model configuration in main. */ +export function createVendorAccounts(options: { + getHost: () => HostProcess | null; + sendToRenderer: (channel: string, payload: unknown) => void; + safeOpenExternal: (url: string) => Promise; + logger: Logger; + modelsDevCatalog: ModelsDevCatalog; +}): VendorOAuth { + return new VendorOAuth({ + call: (method: string, params?: unknown): Promise => { + const host = options.getHost(); + if (!host) throw new Error("host unavailable"); + return host.call(method, params); + }, + emit: (event) => options.sendToRenderer(IPC.event.providersOauth, event), + openExternal: async (url) => { await options.safeOpenExternal(url); }, + log: (level, message, data) => options.logger.app("provider", level, message, { data }), + modelConfigFor: async ({ vendorKey, option }) => { + await options.modelsDevCatalog.ensureLoaded(); + const model = options.modelsDevCatalog.findModel({ + vendorKey, baseUrl: option.baseUrl, modelId: option.modelId, + }); + return model + ? modelConfigFromModelsDev(model, option.baseUrl) + : genericModelConfig(option.modelId, option.baseUrl); + }, + }); +} diff --git a/apps/desktop/electron/main/services/permission-review.ts b/apps/desktop/electron/main/services/permission-review.ts new file mode 100644 index 0000000000..34888e1d80 --- /dev/null +++ b/apps/desktop/electron/main/services/permission-review.ts @@ -0,0 +1,112 @@ +import { + OAUTH_AUTH_KIND, + THINKING_LEVELS, + canonicalThinkingLevel, + modelIdsMatch, + resolveBindingContextWindow, + type ThinkingLevel, +} from "@pi-desktop/shared"; +import { + capabilitiesFromModelConfig, + clampThinkingLevel, + genericModelConfig, + modelConfigWithBinding, + optionalProviderHeaders, + reviewPermissionAction, + type RuntimeProviderConfig, +} from "@pi-desktop/agent-runtime"; +import type { PermissionReviewResult, ReviewAction } from "@pi-desktop/host-runtime"; +import type { HostProcess } from "../host-process"; +import { modelConfigFromModelsDev, type ModelsDevCatalog } from "../models-dev-catalog"; +import type { RuntimeProvider } from "../runtime/provider-catalog"; +import type { VendorOAuth } from "../oauth"; + +type ReviewSettings = { + defaultProviderId?: string; + defaultModelId?: string; + autoReview?: { providerId?: string; modelId?: string; thinkingLevel?: string }; +}; + +type ReviewSession = { providerId?: string; modelId?: string }; + +function validThinkingLevel(value: unknown): ThinkingLevel { + return typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value) + ? value as ThinkingLevel : "off"; +} + +/** Resolve an independent reviewer using the exact configured binding. */ +export function createPermissionReviewResolver(dependencies: { + getHost: () => HostProcess | null; + modelsDevCatalog: ModelsDevCatalog; + vendorOAuth: VendorOAuth; + report: (code: "model_unavailable" | "context_unavailable") => void; +}) { + return async (action: ReviewAction, signal: AbortSignal, sessionId: string): Promise => { + const host = dependencies.getHost(); + if (!host || signal.aborted) return reviewPermissionAction(undefined, action, "off", { signal }); + try { + const settings = await host.call("settings.get"); + const current = await host.call<{ session?: ReviewSession }>("session.get", { id: sessionId, messageLimit: 1 }); + if (signal.aborted || !current.session || host !== dependencies.getHost()) throw new Error("Session unavailable"); + if (Boolean(settings.autoReview?.providerId) !== Boolean(settings.autoReview?.modelId)) { + throw new Error("Incomplete reviewer model binding"); + } + const pinned = Boolean(settings.autoReview?.providerId); + const thinkingLevel = validThinkingLevel(settings.autoReview?.thinkingLevel); + const rows = await host.call<{ providers: RuntimeProvider[] }>("providers.list", { includeDisabled: false }); + const providerId = pinned + ? settings.autoReview!.providerId + : current.session.providerId ?? settings.defaultProviderId; + const row = rows.providers.find((provider) => provider.id === providerId && provider.enabled !== false); + if (!row || row.extensionAgentKey) throw new Error("Reviewer provider unavailable"); + const modelId = pinned + ? settings.autoReview!.modelId + : current.session.modelId ?? settings.defaultModelId ?? row.models?.[0]?.id; + if (!modelId || (pinned && !row.models?.some((binding) => modelIdsMatch(binding.id, modelId)))) { + throw new Error("Reviewer model unavailable"); + } + const vendorBinding = row.authKind === OAUTH_AUTH_KIND + ? await dependencies.vendorOAuth.bindingFor(row.id, modelId) : undefined; + if (row.authKind === OAUTH_AUTH_KIND && !vendorBinding) throw new Error("Account model unavailable"); + const secret = row.authKind === OAUTH_AUTH_KIND || row.authKind === "none" + ? undefined : (await host.call<{ value?: string }>("providers.getSecret", { id: row.id })).value; + if (!secret && row.authKind !== OAUTH_AUTH_KIND && row.authKind !== "none") { + throw new Error("Review provider credential unavailable"); + } + if (signal.aborted || host !== dependencies.getHost()) throw new Error("Review context changed"); + const baseUrl = vendorBinding?.baseUrl ?? row.baseUrl; + const catalog = dependencies.modelsDevCatalog.findModel({ + vendorKey: row.vendorKey, baseUrl: row.baseUrl, modelId, + }); + const baseConfig = vendorBinding?.modelConfig ?? + (catalog ? modelConfigFromModelsDev(catalog, baseUrl) : genericModelConfig(modelId, baseUrl ?? "")); + const limits = resolveBindingContextWindow(baseConfig, + row.models?.find((binding) => modelIdsMatch(binding.id, modelId))); + const modelConfig = modelConfigWithBinding(limits.catalogConfig, limits.binding); + const capabilities = capabilitiesFromModelConfig(modelConfig); + const provider: RuntimeProviderConfig = { + id: row.id, + name: row.name, + ...(row.vendorKey ? { vendorKey: row.vendorKey } : {}), + ...(baseUrl ? { baseUrl } : {}), + modelId, + apiKey: secret ?? "", + ...(row.authKind ? { authKind: row.authKind } : {}), + ...(vendorBinding?.apiStyle ?? row.apiStyle + ? { apiStyle: vendorBinding?.apiStyle ?? row.apiStyle } : {}), + ...optionalProviderHeaders(row.headers), + supportsReasoning: capabilities.supportsReasoning, + supportedThinkingLevels: [...capabilities.supportedThinkingLevels], + modelConfig, + ...(row.authKind === OAUTH_AUTH_KIND + ? { resolveAuth: () => dependencies.vendorOAuth.resolveAuth(row.id) } : {}), + }; + const result = await reviewPermissionAction(provider, action, + canonicalThinkingLevel(clampThinkingLevel(capabilities, thinkingLevel)), { signal }); + return { ...result, reviewerProviderId: row.id, reviewerModelId: modelId }; + } catch { + dependencies.report("model_unavailable"); + return reviewPermissionAction(undefined, action, "off", { signal }); + } + }; +} diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index d964d6be43..7a7d13b7de 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -6,10 +6,7 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; -import type { - Mode, - PermissionMode, -} from "@pi-desktop/shared"; +import type { Mode } from "@pi-desktop/shared"; import { initialThinkingLevelForBinding, imageGenerationBindings, @@ -38,7 +35,7 @@ import { COMPOSER_MIN_HEIGHT_PX, PLACEHOLDER_KEYS, cssPixels, - isPermissionMode, + composerPermissionState, isThinkingLevel, thinkingLevelForProvider, thinkingProviderForModel, @@ -309,23 +306,9 @@ export function Composer({ isRunning && planningState === "planning" && (mode === "plan" || mode === "goal"); - // Permission mode (D115/D132): inherited sessions still resolve through the - // global setting, but the composer presents only the effective mode. - const globalPermissionMode: PermissionMode = - settings?.defaultPermissionMode ?? "ask"; - const sessionPermissionMode: PermissionMode = activeSession - ? isPermissionMode(activeSession.permissionMode) - ? activeSession.permissionMode - : "inherit" - : isPermissionMode(draftConfiguration?.permissionMode) - ? draftConfiguration.permissionMode - : "inherit"; - const effectivePermissionMode: Exclude = - sessionPermissionMode === "inherit" - ? (globalPermissionMode as Exclude) - : sessionPermissionMode; - const composerPermissionMode: Exclude = - mode === "goal" ? "auto" : effectivePermissionMode; + const { permissionMode: composerPermissionMode, sessionReviewer, effectiveReviewer } = + composerPermissionState({ mode, session: activeSession, draft: draftConfiguration, + globalPermissionMode: settings?.defaultPermissionMode, globalReviewer: settings?.approvalReviewer }); const provider = providers.find( (candidate) => candidate.id === @@ -588,6 +571,10 @@ export function Composer({ modelId={modelId} thinkingLevel={thinkingLevel} composerPermissionMode={composerPermissionMode} + effectiveReviewer={effectiveReviewer} + sessionReviewer={sessionReviewer} + hasActiveSession={Boolean(activeSession && !nativeSession && !activeSessionId?.startsWith("remote:"))} + activeSessionId={activeSessionId ?? undefined} permissionOpen={permissionOpen} setPermissionOpen={setPermissionOpen} controlsBlocked={controlsBlocked} diff --git a/apps/desktop/src/components/PermissionCard.tsx b/apps/desktop/src/components/PermissionCard.tsx index 87596bd068..43f9df7902 100644 --- a/apps/desktop/src/components/PermissionCard.tsx +++ b/apps/desktop/src/components/PermissionCard.tsx @@ -8,6 +8,7 @@ import { useAppStore } from "../stores/app-store"; import { buildToolPresentation } from "../lib/tool-presentation"; import { ToolDetailBlocks } from "./ToolDetails"; import { Button } from "./ui"; +import { api } from "../lib/api"; export function PermissionCard({ permission, @@ -24,9 +25,11 @@ export function PermissionCard({ state.sessions.find((session) => session.id === permission.sessionId)?.projectPath, ); const [secondsLeft, setSecondsLeft] = useState(() => - permissionSecondsLeft(permission.receivedAt), + permissionSecondsLeft(permission.receivedAt, Date.now(), permission.expiresAt), ); const [resolving, setResolving] = useState(false); + const reviewing = permission.reviewState === "reviewing" || permission.reviewState === "awaiting_review"; + const [takingOver, setTakingOver] = useState(false); const timeoutHandled = useRef(false); const restoreComposerFocus = () => { @@ -54,17 +57,29 @@ export function PermissionCard({ useEffect(() => { timeoutHandled.current = false; - const update = () => setSecondsLeft(permissionSecondsLeft(permission.receivedAt)); + const update = () => setSecondsLeft(permissionSecondsLeft(permission.receivedAt, Date.now(), permission.expiresAt)); update(); const timer = window.setInterval(update, 1000); return () => window.clearInterval(timer); - }, [permission.receivedAt, permission.requestId]); + }, [permission.receivedAt, permission.expiresAt, permission.requestId]); useEffect(() => { - if (secondsLeft > 0 || timeoutHandled.current || resolving) return; + if (secondsLeft > 0 || timeoutHandled.current || resolving || reviewing) return; timeoutHandled.current = true; void resolve("deny"); - }, [resolving, secondsLeft]); + }, [resolving, reviewing, secondsLeft]); + + const takeOver = async () => { + if (takingOver) return; + setTakingOver(true); + try { + await api.takeoverPermissionReview(permission.requestId); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), { variant: "error" }); + } finally { + setTakingOver(false); + } + }; // Same structured presentation as the transcript tool rows: a command reads // as shell, file content as code, everything else as labeled fields. @@ -86,7 +101,7 @@ export function PermissionCard({ >
- {t("permission.title")} + {reviewing ? t("permission.reviewing") : t("permission.title")} {queued > 0 ? ( @@ -112,6 +127,11 @@ export function PermissionCard({ {permission.reason ? (
{permission.reason}
) : null} + {permission.scopeLabel ? ( +
+ {t("permission.scope", { scope: permission.scopeLabel })} +
+ ) : null} {argBlocks.length > 0 ? (
@@ -128,6 +148,12 @@ export function PermissionCard({
+ {reviewing ? ( + + ) : ( + <> - + : null} + + )}
); diff --git a/apps/desktop/src/components/settings/SettingsMenuSelect.tsx b/apps/desktop/src/components/settings/SettingsMenuSelect.tsx index 6da9270b96..e705f49676 100644 --- a/apps/desktop/src/components/settings/SettingsMenuSelect.tsx +++ b/apps/desktop/src/components/settings/SettingsMenuSelect.tsx @@ -10,9 +10,8 @@ * list is short still use this control so one Settings window does not mix two * popup implementations. * - * Unlike the Appearance pickers this list is not searchable — the longest - * catalog here is the host command-shell list — so the menu opens on the - * current option and keyboard users move with arrows alone. + * Short lists open on the current option. Model catalogs can opt into search + * while retaining the same anchored menu and keyboard behavior. */ import { useRef, @@ -21,7 +20,7 @@ import { type ReactNode, } from "react"; import { cx } from "../ui"; -import { IconCheck, IconChevronDown } from "../icons"; +import { IconCheck, IconChevronDown, IconSearch } from "../icons"; import { AnchoredMenu } from "./AnchoredMenu"; export type MenuSelectOption = { @@ -42,6 +41,8 @@ export function SettingsMenuSelect({ className, triggerClassName, leading, + searchPlaceholder, + emptyLabel, }: { value: string; options: MenuSelectOption[]; @@ -58,15 +59,25 @@ export function SettingsMenuSelect({ triggerClassName?: string; /** Optional icon or marker shown before the selected value. */ leading?: ReactNode; + /** Enables filtering for longer option lists such as model catalogs. */ + searchPlaceholder?: string; + emptyLabel?: string; }) { const [open, setOpen] = useState(false); const [activeId, setActiveId] = useState(value); + const [query, setQuery] = useState(""); const optionRefs = useRef(new Map()); const current = options.find((option) => option.id === value); - const selectable = options.filter((option) => !option.disabled); + const visibleOptions = searchPlaceholder && query.trim() + ? options.filter((option) => option.label.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())) + : options; + const selectable = visibleOptions.filter((option) => !option.disabled); - const close = () => setOpen(false); + const close = () => { + setOpen(false); + setQuery(""); + }; const choose = (option: MenuSelectOption) => { close(); @@ -102,9 +113,10 @@ export function SettingsMenuSelect({ ( )} > + {searchPlaceholder ? ( +
+ + setQuery(event.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + /> +
+ ) : null}
+ {visibleOptions.length === 0 && emptyLabel ? ( +
{emptyLabel}
+ ) : null}
    - {options.map((option) => { + {visibleOptions.map((option) => { const isCurrent = option.id === value; return (
  • @@ -148,6 +175,7 @@ export function SettingsMenuSelect({ tabIndex={-1} aria-selected={isCurrent} disabled={option.disabled} + title={option.label} className={cx( "settings-menu-select-option", isCurrent && "is-current", diff --git a/apps/desktop/src/features/chat/composer/ComposerPermissionPicker.tsx b/apps/desktop/src/features/chat/composer/ComposerPermissionPicker.tsx index 6ee6fa0361..0b4b81bdb5 100644 --- a/apps/desktop/src/features/chat/composer/ComposerPermissionPicker.tsx +++ b/apps/desktop/src/features/chat/composer/ComposerPermissionPicker.tsx @@ -5,20 +5,34 @@ import { AnchoredMenu } from "../../../components/settings/AnchoredMenu"; import { TooltipButton } from "../../../components/ui"; import { IconCheck, IconChevronDown } from "../../../components/icons"; import { PERMISSION_MODE_I18N_KEYS } from "../../../lib/permission-mode-labels"; +import type { ApprovalReviewer, SessionApprovalReviewer } from "@pi-desktop/shared"; +import { SessionPermissionGrants } from "./SessionPermissionGrants"; +import { SessionPermissionReviewHistory } from "./SessionPermissionReviewHistory"; /** Controlled permission UI shared by conversations and task drafts. */ export function ComposerPermissionPicker({t, mode, composerPermissionMode, - permissionOpen, setPermissionOpen, controlsBlocked, onCloseOtherMenus, onSelect, + effectiveReviewer, sessionReviewer, hasActiveSession, + sessionId, + permissionOpen, setPermissionOpen, controlsBlocked, onCloseOtherMenus, onSelect, onSelectReviewer, }: { t: TFunction; mode: Mode; composerPermissionMode: GlobalPermissionMode; + effectiveReviewer: ApprovalReviewer; + sessionReviewer: SessionApprovalReviewer; + hasActiveSession: boolean; + sessionId?: string; permissionOpen: boolean; setPermissionOpen: Dispatch>; controlsBlocked: boolean; onCloseOtherMenus: () => void; onSelect: (mode: GlobalPermissionMode) => void | Promise; + onSelectReviewer: (reviewer: SessionApprovalReviewer) => void | Promise; }) { + const reviewerLabel = composerPermissionMode === "auto" || mode === "goal" + ? t("chat.permissionNoReview") + : effectiveReviewer === "auto_review" ? t("settings.reviewByModel") : t("settings.reviewByUser"); + const pickerLabel = `${t("chat.permissionMode")} · ${reviewerLabel}`; return ( {t(PERMISSION_MODE_I18N_KEYS[composerPermissionMode])} + · {reviewerLabel} @@ -82,6 +97,33 @@ export function ComposerPermissionPicker({t, mode, composerPermissionMode, {composerPermissionMode === candidate ? : null} ))} + {hasActiveSession && mode !== "goal" && composerPermissionMode !== "auto" ? ( + <> + {t("settings.approvalReviewer")} + {(["inherit", "user", "auto_review"] as const).map((candidate) => ( + + ))} + + ) : null} + {sessionId && hasActiveSession ? : null} + {sessionId && hasActiveSession ? : null} ); } diff --git a/apps/desktop/src/features/chat/composer/ComposerToolbar.tsx b/apps/desktop/src/features/chat/composer/ComposerToolbar.tsx index 2f27e84e66..07ba9839bd 100644 --- a/apps/desktop/src/features/chat/composer/ComposerToolbar.tsx +++ b/apps/desktop/src/features/chat/composer/ComposerToolbar.tsx @@ -4,6 +4,8 @@ import { keybindingDisplayParts, type Mode, type PermissionMode, + type ApprovalReviewer, + type SessionApprovalReviewer, type ShortcutPlatform, type SessionThinkingLevel, } from "@pi-desktop/shared"; @@ -37,6 +39,10 @@ export type ComposerToolbarProps = { modelId?: string; thinkingLevel: SessionThinkingLevel; composerPermissionMode: Exclude; + effectiveReviewer: ApprovalReviewer; + sessionReviewer: SessionApprovalReviewer; + hasActiveSession: boolean; + activeSessionId?: string; permissionOpen: boolean; setPermissionOpen: Dispatch>; controlsBlocked: boolean; @@ -72,6 +78,10 @@ export function ComposerToolbar({ modelId, thinkingLevel, composerPermissionMode, + effectiveReviewer, + sessionReviewer, + hasActiveSession, + activeSessionId, permissionOpen, setPermissionOpen, controlsBlocked, @@ -152,6 +162,10 @@ export function ComposerToolbar({ modelMenu.setOpen(false)} onSelect={async (candidate) => { @@ -168,6 +182,17 @@ export function ComposerToolbar({ variant: "error", }); } + }} + onSelectReviewer={async (candidate) => { + try { + await configureActiveSession({ + mode, providerId, modelId, thinkingLevel, + permissionMode: composerPermissionMode, + approvalReviewer: candidate, + }); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), { variant: "error" }); + } }} />
diff --git a/apps/desktop/src/features/chat/composer/SessionPermissionGrants.tsx b/apps/desktop/src/features/chat/composer/SessionPermissionGrants.tsx new file mode 100644 index 0000000000..6fb27a51e2 --- /dev/null +++ b/apps/desktop/src/features/chat/composer/SessionPermissionGrants.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { SessionPermissionGrant } from "@pi-desktop/shared"; +import { api } from "../../../lib/api"; + +/** Host-owned grants are read afresh whenever the permission menu opens. */ +export function SessionPermissionGrants({ sessionId, open }: { sessionId: string; open: boolean }) { + const { t } = useTranslation(); + const [grants, setGrants] = useState([]); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const generation = useRef(undefined); + const identity = useRef({ sessionId, open }); + identity.current = { sessionId, open }; + + useEffect(() => { + if (!open) return; + const current = Symbol(sessionId); + generation.current = current; + const stillCurrent = () => generation.current === current && identity.current.open && + identity.current.sessionId === sessionId; + let active = true; + setGrants([]); + setError(false); + setBusy(false); + setLoading(true); + void api.listSessionPermissionGrants(sessionId).then(({ grants: next }) => { + if (active && stillCurrent()) setGrants(next); + }).catch(() => { + if (active && stillCurrent()) setError(true); + }).finally(() => { + if (active && stillCurrent()) setLoading(false); + }); + return () => { + active = false; + if (generation.current === current) generation.current = undefined; + }; + }, [open, sessionId]); + + const revoke = async (grantId?: string) => { + if (busy) return; + const current = generation.current; + if (!current || !identity.current.open || identity.current.sessionId !== sessionId) return; + const stillCurrent = () => generation.current === current && identity.current.open && + identity.current.sessionId === sessionId; + setBusy(true); + setError(false); + try { + if (grantId) await api.revokeSessionPermissionGrant(sessionId, grantId); + else await api.clearSessionPermissionGrants(sessionId); + if (!stillCurrent()) return; + const result = await api.listSessionPermissionGrants(sessionId); + if (stillCurrent()) setGrants(result.grants); + } catch { + if (stillCurrent()) setError(true); + } finally { + if (stillCurrent()) setBusy(false); + } + }; + + return ( +
+ {t("permission.grantsTitle")} + {loading ? {t("permission.grantsLoading")} : null} + {error ? {t("permission.grantsError")} : null} + {!loading && !error && grants.length === 0 + ? {t("permission.grantsEmpty")} : null} + {grants.map((grant) => ( + + ))} + {grants.length > 0 ? ( + + ) : null} +
+ ); +} diff --git a/apps/desktop/src/features/chat/composer/SessionPermissionReviewHistory.tsx b/apps/desktop/src/features/chat/composer/SessionPermissionReviewHistory.tsx new file mode 100644 index 0000000000..7bebe1b72c --- /dev/null +++ b/apps/desktop/src/features/chat/composer/SessionPermissionReviewHistory.tsx @@ -0,0 +1,50 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { PermissionReviewHistoryEntry } from "@pi-desktop/shared"; +import { api } from "../../../lib/api"; + +/** Query Host audit when reopened, so reviews remain visible after a renderer reload. */ +export function SessionPermissionReviewHistory({ sessionId, open }: { sessionId: string; open: boolean }) { + const { t } = useTranslation(); + const [entries, setEntries] = useState([]); + const [status, setStatus] = useState<"loading" | "error" | "ready">("loading"); + const request = useRef(0); + + useEffect(() => { + if (!open) return; + const current = ++request.current; + setEntries([]); + setStatus("loading"); + void api.listSessionPermissionReviews(sessionId).then((result) => { + if (request.current !== current) return; + setEntries([...new Map(result.entries.map((entry) => [entry.requestId, entry])).values()].slice(0, 5)); + setStatus("ready"); + }).catch(() => { + if (request.current === current) setStatus("error"); + }); + return () => { if (request.current === current) request.current++; }; + }, [sessionId, open]); + + return ( +
+ {t("permission.reviewHistoryTitle")} + {status === "loading" ? {t("permission.reviewHistoryLoading")} : null} + {status === "error" ? {t("permission.reviewHistoryError")} : null} + {status === "ready" && entries.length === 0 ? {t("permission.reviewHistoryEmpty")} : null} + {status === "ready" && entries.map((entry) => ( +
+
+ {entry.toolName ?? t("permission.reviewUnknownTool")} · {t(`permission.reviewDecision.${entry.decision}`)} + {entry.reason} + {entry.reviewerModelId ? {t("permission.reviewModel", { model: entry.reviewerModelId })} : null} + + {entry.usage + ? t("permission.reviewTokens", { count: entry.usage.totalTokens }) + : t("permission.reviewUsageUnknown")} + +
+
+ ))} +
+ ); +} diff --git a/apps/desktop/src/features/chat/composer/model.ts b/apps/desktop/src/features/chat/composer/model.ts index a6cc262575..bf1eb294a7 100644 --- a/apps/desktop/src/features/chat/composer/model.ts +++ b/apps/desktop/src/features/chat/composer/model.ts @@ -2,6 +2,8 @@ import type { ModelInfo, Mode, PermissionMode, + ApprovalReviewer, + SessionApprovalReviewer, ProviderPublic, SessionThinkingLevel, ThinkingLevel, @@ -88,6 +90,23 @@ export function isPermissionMode(value: unknown): value is PermissionMode { ); } +/** One effective permission display state for a live session or draft. */ +export function composerPermissionState(input: { + mode: Mode; + session?: { permissionMode?: PermissionMode; approvalReviewer?: SessionApprovalReviewer }; + draft?: { permissionMode?: PermissionMode } | null; + globalPermissionMode?: Exclude; + globalReviewer?: ApprovalReviewer; +}) { + const sessionMode = input.session?.permissionMode ?? input.draft?.permissionMode; + const selectedMode = isPermissionMode(sessionMode) ? sessionMode : "inherit"; + const permissionMode = input.mode === "goal" ? "auto" + : selectedMode === "inherit" ? (input.globalPermissionMode ?? "ask") : selectedMode; + const sessionReviewer = input.session?.approvalReviewer ?? "inherit"; + const effectiveReviewer = sessionReviewer === "inherit" ? (input.globalReviewer ?? "user") : sessionReviewer; + return { permissionMode, sessionReviewer, effectiveReviewer }; +} + /** * Preserve the current level when changing providers, but never carry a * reasoning level into a provider that cannot accept it. diff --git a/apps/desktop/src/features/scheduled/ScheduledExecutionSettings.tsx b/apps/desktop/src/features/scheduled/ScheduledExecutionSettings.tsx index 842c022804..7619564ee4 100644 --- a/apps/desktop/src/features/scheduled/ScheduledExecutionSettings.tsx +++ b/apps/desktop/src/features/scheduled/ScheduledExecutionSettings.tsx @@ -83,9 +83,12 @@ export function ScheduledExecutionSettings({ /> {}} - onSelect={onPermissionChange} /> + onSelect={onPermissionChange} onSelectReviewer={() => {}} />
diff --git a/apps/desktop/src/features/settings/PermissionReviewRows.tsx b/apps/desktop/src/features/settings/PermissionReviewRows.tsx new file mode 100644 index 0000000000..3014d7ed7c --- /dev/null +++ b/apps/desktop/src/features/settings/PermissionReviewRows.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + imageGenerationBindings, + MAX_PERMISSION_REVIEW_POLICY_CHARS, + type AppSettings, + type ProviderPublic, +} from "@pi-desktop/shared"; +import { useTranslation } from "react-i18next"; +import { SettingsMenuSelect } from "../../components/settings/SettingsMenuSelect"; +import { defaultModelOptions } from "../../components/settings/default-model"; +import { useAppStore } from "../../stores/app-store"; +import { SettingsRow } from "./primitives"; +import { syncPermissionPolicyDraft } from "./permission-policy-draft"; +import { reviewThinkingLevels, selectedReviewThinkingLevel } from "./permission-review-model"; + +export function PermissionReviewRows({ settings, providers, saveSettings }: { + settings: AppSettings; + providers: ProviderPublic[]; + saveSettings: (patch: Partial) => Promise; +}) { + const { t } = useTranslation(); + const providerModels = useAppStore((state) => state.providerModels); + const binding = settings.autoReview; + const savedPolicy = binding?.policyPrompt ?? ""; + const [policyDraft, setPolicyDraft] = useState(savedPolicy); + const policyDraftRef = useRef(savedPolicy); + const previousSavedPolicy = useRef(savedPolicy); + const saveSettingsRef = useRef(saveSettings); + saveSettingsRef.current = saveSettings; + const mountedRef = useRef(true); + const writeQueue = useRef(Promise.resolve()); + const [savingPolicy, setSavingPolicy] = useState(false); + const [savingBinding, setSavingBinding] = useState(false); + const [policySaveFailed, setPolicySaveFailed] = useState(false); + const [bindingSaveFailed, setBindingSaveFailed] = useState(false); + const busy = savingPolicy || savingBinding; + const policyLength = [...policyDraft].length; + const policyInvalid = policyLength > MAX_PERMISSION_REVIEW_POLICY_CHARS; + + const enqueueWrite = useCallback((write: () => Promise) => { + writeQueue.current = writeQueue.current.then(write).catch(() => { + if (mountedRef.current) setBindingSaveFailed(true); + }); + }, []); + + const queuePolicySave = useCallback((text: string) => { + if ([...text].length > MAX_PERMISSION_REVIEW_POLICY_CHARS) return; + const policyPrompt = text.trim() ? text : undefined; + enqueueWrite(async () => { + if (policyDraftRef.current !== text) return; + const currentBinding = useAppStore.getState().settings?.autoReview; + if ((currentBinding?.policyPrompt ?? "") === (policyPrompt ?? "")) { + if (!policyPrompt && mountedRef.current) { + policyDraftRef.current = ""; + setPolicyDraft(""); + } + return; + } + if (mountedRef.current) setSavingPolicy(true); + try { + await saveSettingsRef.current({ autoReview: { ...currentBinding, policyPrompt } }); + if (mountedRef.current) { + setPolicySaveFailed(false); + if (!policyPrompt && policyDraftRef.current === text) { + policyDraftRef.current = ""; + setPolicyDraft(""); + } + } + } catch { + if (mountedRef.current && policyDraftRef.current === text) setPolicySaveFailed(true); + } finally { + if (mountedRef.current) setSavingPolicy(false); + } + }); + }, [enqueueWrite]); + + // Provider refreshes and remote settings updates must not erase unsaved edits. + useEffect(() => { + const previous = previousSavedPolicy.current; + setPolicyDraft((current) => { + const next = syncPermissionPolicyDraft(current, previous, savedPolicy); + policyDraftRef.current = next; + return next; + }); + previousSavedPolicy.current = savedPolicy; + }, [savedPolicy]); + + useEffect(() => { + if (policyDraft === savedPolicy || policyInvalid) return; + const timer = window.setTimeout(() => queuePolicySave(policyDraft), 650); + return () => window.clearTimeout(timer); + }, [policyDraft, savedPolicy, policyInvalid, queuePolicySave]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (policyDraftRef.current !== previousSavedPolicy.current) { + queuePolicySave(policyDraftRef.current); + } + }; + }, [queuePolicySave]); + + const persistReviewSetting = (patch: Partial) => { + setSavingBinding(true); + setBindingSaveFailed(false); + enqueueWrite(async () => { + try { + const currentBinding = useAppStore.getState().settings?.autoReview; + await saveSettingsRef.current(patch.autoReview + ? { ...patch, autoReview: { ...currentBinding, ...patch.autoReview } } + : patch); + } catch { + if (mountedRef.current) setBindingSaveFailed(true); + } finally { + if (mountedRef.current) setSavingBinding(false); + } + }); + }; + const availableProviders = providers.filter((provider) => provider.enabled && ( + provider.hasSecret || provider.hasOauth || provider.authKind === "none" + )); + const images = imageGenerationBindings(settings.imageGenerationModels, settings.imageGeneration); + const models = defaultModelOptions(availableProviders, images).map(({ provider, modelId }) => ({ + id: JSON.stringify([provider.id, modelId]), + label: `${provider.name} / ${modelId}`, + provider, + modelId, + })); + const selected = binding?.providerId && binding.modelId + ? JSON.stringify([binding.providerId, binding.modelId]) : "follow"; + const selectedModel = models.find((model) => model.id === selected); + const thinkingLevels = selectedModel + ? reviewThinkingLevels(selectedModel.provider, selectedModel.modelId, + providerModels[selectedModel.provider.id]) + : (["off"] as const); + const selectedThinking = selectedReviewThinkingLevel(binding?.thinkingLevel, thinkingLevels); + + return ( + <> + + void persistReviewSetting({ approvalReviewer: value === "auto_review" ? "auto_review" : "user" })} + /> + + <> + + model.id === selected) + ? [{ id: selected, label: t("settings.reviewModelUnavailable"), disabled: true }] + : []), + ]} + onChange={(value) => { + const model = models.find((candidate) => candidate.id === value); + if (value !== "follow" && !model) return; + const levels = model + ? reviewThinkingLevels(model.provider, model.modelId, providerModels[model.provider.id]) + : (["off"] as const); + void persistReviewSetting({ autoReview: { + providerId: model?.provider.id, + modelId: model?.modelId, + thinkingLevel: selectedReviewThinkingLevel(binding?.thinkingLevel, levels), + } }); + }} + /> + + + ({ id: level, label: level }))} + onChange={(value) => { + if (!thinkingLevels.some((level) => level === value)) return; + void persistReviewSetting({ autoReview: { + thinkingLevel: thinkingLevels.find((level) => level === value), + } }); + }} + /> + +

{t("settings.reviewCostCaution")}

+ {bindingSaveFailed ?

{t("settings.reviewSettingsSaveFailed")}

: null} +
+ +