From 2efd16796ff0246dea6dc4d6d47f8ae088d92da6 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 12 Nov 2025 10:07:30 +0000 Subject: [PATCH] Permissions --- src/App.tsx | 72 ++++++++++++++++++++++ src/lib/sse-manager.ts | 14 ++++- src/stores/instances.ts | 130 +++++++++++++++++++++++++++++++++++++++- src/stores/sessions.ts | 24 +++++++- src/types/session.ts | 12 +--- 5 files changed, 235 insertions(+), 17 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1b47897e..b2da8ca9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Toaster } from "solid-toast" import type { Session } from "./types/session" import type { Attachment } from "./types/attachment" import type { SDKPart, ClientPart } from "./types/message" +import type { Permission } from "@opencode-ai/sdk" import FolderSelectionView from "./components/folder-selection-view" import InstanceWelcomeView from "./components/instance-welcome-view" import CommandPalette from "./components/command-palette" @@ -36,6 +37,8 @@ import { stopInstance, getActiveInstance, addLog, + getActivePermission, + sendPermissionResponse, } from "./stores/instances" import { getSessions, @@ -173,6 +176,41 @@ const SessionView: Component<{ } + const activePermission = createMemo(() => getActivePermission(props.instanceId)) + + async function handlePermissionResponse(response: "once" | "always" | "reject") { + const permission = activePermission() + if (!permission) return + + try { + await sendPermissionResponse(props.instanceId, props.sessionId, permission.id, response) + } catch (error) { + console.error("Failed to send permission response:", error) + } + } + + // Handle permission keyboard shortcuts + createEffect(() => { + const permission = activePermission() + if (!permission) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault() + handlePermissionResponse("once") + } else if (event.key === "a" || event.key === "A") { + event.preventDefault() + handlePermissionResponse("always") + } else if (event.key === "d" || event.key === "D") { + event.preventDefault() + handlePermissionResponse("reject") + } + } + + document.addEventListener("keydown", handleKeyDown) + onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) + }) + return ( + + + {(permission) => ( +
+
+
+
+ + + +
+
+
+
+ Permission Required + {permission().type} +
+
+ {permission().title} +
+
+ Enter + Accept once + a + Accept always + d + Deny +
+
+
+
+ )} +
+ } // Fallback for unknown event types @@ -133,6 +137,12 @@ class SSEManager { case "session.idle": this.onSessionIdle?.(instanceId, event as EventSessionIdle) break + case "permission.updated": + this.onPermissionUpdated?.(instanceId, event as EventPermissionUpdated) + break + case "permission.replied": + this.onPermissionReplied?.(instanceId, event as EventPermissionReplied) + break default: console.warn("[SSE] Unknown event type:", event.type) } @@ -176,6 +186,8 @@ class SSEManager { onSessionError?: (instanceId: string, event: EventSessionError) => void onTuiToast?: (instanceId: string, event: TuiToastEvent) => void onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void + onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void + onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null { return connectionStatus().get(instanceId) ?? null diff --git a/src/stores/instances.ts b/src/stores/instances.ts index 0e838c75..76e652e7 100644 --- a/src/stores/instances.ts +++ b/src/stores/instances.ts @@ -1,5 +1,6 @@ import { createSignal } from "solid-js" import type { Instance, LogEntry } from "../types/instance" +import type { Permission } from "@opencode-ai/sdk" import { sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" import { @@ -16,6 +17,10 @@ const [activeInstanceId, setActiveInstanceId] = createSignal(null const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) +// Permission queue management per instance +const [permissionQueues, setPermissionQueues] = createSignal>(new Map()) +const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) + const MAX_LOG_ENTRIES = 1000 function ensureLogContainer(id: string) { @@ -115,10 +120,11 @@ function removeInstance(id: string) { if (activeInstanceId() === id) { if (index > 0) { - nextActiveId = keys[index - 1] + const prevKey = keys[index - 1] + nextActiveId = prevKey ?? null } else { const remainingKeys = Array.from(next.keys()) - nextActiveId = remainingKeys.length > 0 ? remainingKeys[0] : null + nextActiveId = remainingKeys.length > 0 ? (remainingKeys[0] ?? null) : null } } @@ -146,7 +152,7 @@ async function createInstance(folder: string, binaryPath?: string): Promise { + const next = new Map(prev) + const queue = next.get(instanceId) ?? [] + + // Check if permission already exists + if (queue.some(p => p.id === permission.id)) { + return next // Don't add duplicate + } + + // Add to queue and sort by creation time to maintain order + const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created) + next.set(instanceId, updatedQueue) + return next + }) + + // Set as active if no active permission + setActivePermissionId((prev) => { + const next = new Map(prev) + if (!next.get(instanceId)) { + next.set(instanceId, permission.id) + } + return next + }) +} + +function getActivePermission(instanceId: string): Permission | null { + const activeId = activePermissionId().get(instanceId) + if (!activeId) return null + + const queue = getPermissionQueue(instanceId) + return queue.find(p => p.id === activeId) ?? null +} + +function removePermissionFromQueue(instanceId: string, permissionId: string): void { + let updatedQueue: Permission[] = [] + + setPermissionQueues((prev) => { + const next = new Map(prev) + const queue = next.get(instanceId) ?? [] + updatedQueue = queue.filter(p => p.id !== permissionId) + if (updatedQueue.length > 0) { + next.set(instanceId, updatedQueue) + } else { + next.delete(instanceId) + } + return next + }) + + setActivePermissionId((prev) => { + const next = new Map(prev) + const activeId = next.get(instanceId) + if (activeId === permissionId) { + // Set the next permission in queue as active, or null if queue is empty + const nextPermission = updatedQueue.length > 0 ? updatedQueue[0] : null + next.set(instanceId, nextPermission?.id ?? null) + } + return next + }) +} + +function clearPermissionQueue(instanceId: string): void { + setPermissionQueues((prev) => { + const next = new Map(prev) + next.delete(instanceId) + return next + }) + setActivePermissionId((prev) => { + const next = new Map(prev) + next.delete(instanceId) + return next + }) +} + +async function sendPermissionResponse( + instanceId: string, + sessionId: string, + permissionId: string, + response: "once" | "always" | "reject" +): Promise { + const instance = instances().get(instanceId) + if (!instance?.client) { + throw new Error("Instance not ready") + } + + try { + await instance.client.postSessionIdPermissionsPermissionId({ + path: { id: sessionId, permissionID: permissionId }, + body: { response } + }) + + // Remove from queue after successful response + removePermissionFromQueue(instanceId, permissionId) + } catch (error) { + console.error("Failed to send permission response:", error) + throw error + } +} + export { instances, activeInstanceId, @@ -258,4 +372,14 @@ export { getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming, + // Permission management + permissionQueues, + activePermissionId, + getPermissionQueue, + getPermissionQueueLength, + addPermissionToQueue, + getActivePermission, + removePermissionFromQueue, + clearPermissionQueue, + sendPermissionResponse, } diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index 3c306f3a..dbf9701a 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -2,7 +2,7 @@ import { createSignal } from "solid-js" import type { Session, Agent, Provider } from "../types/session" import type { Message, MessageDisplayParts, MessagePartRemovedEvent, MessagePartUpdatedEvent, MessageRemovedEvent, MessageUpdateEvent } from "../types/message" import { partHasRenderableText } from "../types/message" -import { instances } from "./instances" +import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances" import { sseManager } from "../lib/sse-manager" import { decodeHtmlEntities } from "../lib/markdown" @@ -12,7 +12,9 @@ import type { EventSessionUpdated, EventSessionCompacted, EventSessionError, - EventSessionIdle + EventSessionIdle, + EventPermissionUpdated, + EventPermissionReplied } from "@opencode-ai/sdk" interface TuiToastEvent { @@ -2123,6 +2125,22 @@ function handleTuiToast(_instanceId: string, event: TuiToastEvent): void { }) } +function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdated): void { + const permission = event.properties + if (!permission) return + + console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`) + addPermissionToQueue(instanceId, permission) +} + +function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void { + const { permissionID } = event.properties + if (!permissionID) return + + console.log(`[SSE] Permission replied: ${permissionID}`) + removePermissionFromQueue(instanceId, permissionID) +} + sseManager.onMessageUpdate = handleMessageUpdate sseManager.onMessagePartUpdated = handleMessageUpdate sseManager.onMessageRemoved = handleMessageRemoved @@ -2132,6 +2150,8 @@ sseManager.onSessionCompacted = handleSessionCompacted sseManager.onSessionError = handleSessionError sseManager.onSessionIdle = handleSessionIdle sseManager.onTuiToast = handleTuiToast +sseManager.onPermissionUpdated = handlePermissionUpdated +sseManager.onPermissionReplied = handlePermissionReplied export { sessions, diff --git a/src/types/session.ts b/src/types/session.ts index cf34af62..2ac990ef 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -46,17 +46,7 @@ export function createClientSession( } } -// Type guard to check if object is SDK Session -export function isSdkSession(obj: unknown): obj is import("@opencode-ai/sdk").Session { - return ( - typeof obj === "object" && - obj !== null && - "id" in obj && - "title" in obj && - "version" in obj && - "time" in obj - ) -} +// No type guard needed - we control the API and know the exact types we receive // Our client-specific Agent interface (simplified version of SDK Agent) export interface Agent {