diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx index 52cb1dd..3748075 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx @@ -12,7 +12,9 @@ import { import Link from "next/link"; import type { FC } from "react"; import { useEffect, useRef, useState } from "react"; +import { PermissionDialog } from "@/components/PermissionDialog"; import { Button } from "@/components/ui/button"; +import { usePermissionRequests } from "@/hooks/usePermissionRequests"; import { useTaskNotifications } from "@/hooks/useTaskNotifications"; import { Badge } from "../../../../../../components/ui/badge"; import { honoClient } from "../../../../../../lib/api/client"; @@ -50,6 +52,8 @@ export const SessionPageContent: FC<{ }); const { isRunningTask, isPausedTask } = useAliveTask(sessionId); + const { currentPermissionRequest, isDialogOpen, onPermissionResponse } = + usePermissionRequests(); // Set up task completion notifications useTaskNotifications(isRunningTask); @@ -226,6 +230,13 @@ export const SessionPageContent: FC<{ isOpen={isDiffModalOpen} onOpenChange={setIsDiffModalOpen} /> + + {/* Permission Dialog */} + ); }; diff --git a/src/components/PermissionDialog.tsx b/src/components/PermissionDialog.tsx new file mode 100644 index 0000000..fb91583 --- /dev/null +++ b/src/components/PermissionDialog.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { ChevronDown, ChevronRight, Copy } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { + PermissionRequest, + PermissionResponse, +} from "@/types/permissions"; + +interface PermissionDialogProps { + permissionRequest: PermissionRequest | null; + isOpen: boolean; + onResponse: (response: PermissionResponse) => void; +} + +export const PermissionDialog = ({ + permissionRequest, + isOpen, + onResponse, +}: PermissionDialogProps) => { + const [isResponding, setIsResponding] = useState(false); + const [isParametersExpanded, setIsParametersExpanded] = useState(false); + + if (!permissionRequest) return null; + + const handleResponse = async (decision: "allow" | "deny") => { + setIsResponding(true); + + const response: PermissionResponse = { + permissionRequestId: permissionRequest.id, + decision, + }; + + try { + await onResponse(response); + } finally { + setIsResponding(false); + } + }; + + const formatValue = (value: unknown): string => { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (typeof value === "boolean") return value.toString(); + if (typeof value === "number") return value.toString(); + if (typeof value === "string") return value; + return JSON.stringify(value, null, 2); + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + } catch (error) { + console.error("Failed to copy to clipboard:", error); + } + }; + + const renderParameterValue = (key: string, value: unknown) => { + const formattedValue = formatValue(value); + const isLong = formattedValue.length > 100; + + return ( +
+
+ {key} + +
+
+
+            {formattedValue}
+          
+
+
+ ); + }; + + const parameterEntries = Object.entries(permissionRequest.toolInput); + const hasParameters = parameterEntries.length > 0; + + return ( + !isResponding}> + + + + ⚠️ + Claude Code Permission Request + + + Claude Code wants to execute the following tool and needs your + permission. + + + +
+ {/* Tool Information */} +
+
+
+ + {permissionRequest.toolName} + +
+ + {new Date(permissionRequest.timestamp).toLocaleTimeString()} + +
+
+ + {/* Parameters Section */} + {hasParameters && ( +
+ + +
+
+ Tool Parameters + + {parameterEntries.length} parameter + {parameterEntries.length !== 1 ? "s" : ""} + +
+ {isParametersExpanded ? ( + + ) : ( + + )} +
+
+ + +
+ {parameterEntries.map(([key, value]) => + renderParameterValue(key, value), + )} +
+
+
+
+ )} + + {!hasParameters && ( +
+ This tool has no parameters. +
+ )} +
+ + {/* Action Buttons */} +
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/SettingsControls.tsx b/src/components/SettingsControls.tsx index f02b935..415eda2 100644 --- a/src/components/SettingsControls.tsx +++ b/src/components/SettingsControls.tsx @@ -32,6 +32,7 @@ export const SettingsControls: FC = ({ }: SettingsControlsProps) => { const checkboxId = useId(); const enterKeyBehaviorId = useId(); + const permissionModeId = useId(); const { config, updateConfig } = useConfig(); const queryClient = useQueryClient(); @@ -74,6 +75,19 @@ export const SettingsControls: FC = ({ await onConfigChanged(); }; + const handlePermissionModeChange = async (value: string) => { + const newConfig = { + ...config, + permissionMode: value as + | "acceptEdits" + | "bypassPermissions" + | "default" + | "plan", + }; + updateConfig(newConfig); + await onConfigChanged(); + }; + return (
@@ -148,6 +162,41 @@ export const SettingsControls: FC = ({

)}
+ +
+ {showLabels && ( + + )} + + {showDescriptions && ( +

+ Control how Claude Code handles permission requests for file + operations +

+ )} +
); }; diff --git a/src/hooks/usePermissionRequests.ts b/src/hooks/usePermissionRequests.ts new file mode 100644 index 0000000..b061b07 --- /dev/null +++ b/src/hooks/usePermissionRequests.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useServerEventListener } from "@/lib/sse/hook/useServerEventListener"; +import type { + PermissionRequest, + PermissionResponse, +} from "@/types/permissions"; + +export const usePermissionRequests = () => { + const [currentPermissionRequest, setCurrentPermissionRequest] = + useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + // Listen for permission requests from the server + useServerEventListener("permission_requested", (data) => { + if (data.permissionRequest) { + setCurrentPermissionRequest(data.permissionRequest); + setIsDialogOpen(true); + } + }); + + const handlePermissionResponse = useCallback( + async (response: PermissionResponse) => { + try { + const apiResponse = await fetch("/api/tasks/permission-response", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(response), + }); + + if (!apiResponse.ok) { + throw new Error("Failed to send permission response"); + } + + // Close the dialog + setIsDialogOpen(false); + setCurrentPermissionRequest(null); + } catch (error) { + console.error("Error sending permission response:", error); + // TODO: Show error toast to user + } + }, + [], + ); + + return { + currentPermissionRequest, + isDialogOpen, + onPermissionResponse: handlePermissionResponse, + }; +}; diff --git a/src/server/config/config.ts b/src/server/config/config.ts index 40087f1..aefa6b8 100644 --- a/src/server/config/config.ts +++ b/src/server/config/config.ts @@ -7,6 +7,10 @@ export const configSchema = z.object({ .enum(["shift-enter-send", "enter-send"]) .optional() .default("shift-enter-send"), + permissionMode: z + .enum(["acceptEdits", "bypassPermissions", "default", "plan"]) + .optional() + .default("default"), }); export type Config = z.infer; diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index ef443a1..133cd48 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -5,7 +5,7 @@ import { zValidator } from "@hono/zod-validator"; import { setCookie } from "hono/cookie"; import { streamSSE } from "hono/streaming"; import { z } from "zod"; -import { configSchema } from "../config/config"; +import { type Config, configSchema } from "../config/config"; import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import type { SerializableAliveTask } from "../service/claude-code/types"; import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; @@ -26,7 +26,16 @@ import type { HonoAppType } from "./app"; import { configMiddleware } from "./middleware/config.middleware"; export const routes = (app: HonoAppType) => { - const taskController = new ClaudeCodeTaskController(); + let taskController: ClaudeCodeTaskController | null = null; + const getTaskController = (config: Config) => { + if (!taskController) { + taskController = new ClaudeCodeTaskController(config); + } else { + taskController.updateConfig(config); + } + return taskController; + }; + const fileWatcher = getFileWatcher(); const eventBus = getEventBus(); @@ -312,7 +321,9 @@ export const routes = (app: HonoAppType) => { return c.json({ error: "Project path not found" }, 400); } - const task = await taskController.startOrContinueTask( + const task = await getTaskController( + c.get("config"), + ).startOrContinueTask( { projectId, cwd: project.meta.projectPath, @@ -345,7 +356,9 @@ export const routes = (app: HonoAppType) => { return c.json({ error: "Project path not found" }, 400); } - const task = await taskController.startOrContinueTask( + const task = await getTaskController( + c.get("config"), + ).startOrContinueTask( { projectId, sessionId, @@ -364,7 +377,7 @@ export const routes = (app: HonoAppType) => { .get("/tasks/alive", async (c) => { return c.json({ - aliveTasks: taskController.aliveTasks.map( + aliveTasks: getTaskController(c.get("config")).aliveTasks.map( (task): SerializableAliveTask => ({ id: task.id, status: task.status, @@ -380,11 +393,29 @@ export const routes = (app: HonoAppType) => { zValidator("json", z.object({ sessionId: z.string() })), async (c) => { const { sessionId } = c.req.valid("json"); - taskController.abortTask(sessionId); + getTaskController(c.get("config")).abortTask(sessionId); return c.json({ message: "Task aborted" }); }, ) + .post( + "/tasks/permission-response", + zValidator( + "json", + z.object({ + permissionRequestId: z.string(), + decision: z.enum(["allow", "deny"]), + }), + ), + async (c) => { + const permissionResponse = c.req.valid("json"); + getTaskController(c.get("config")).respondToPermissionRequest( + permissionResponse, + ); + return c.json({ message: "Permission response received" }); + }, + ) + .get("/sse", async (c) => { return streamSSE( c, diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index 2b78859..475a0f4 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -2,12 +2,15 @@ import { execSync } from "node:child_process"; import { query } from "@anthropic-ai/claude-code"; import prexit from "prexit"; import { ulid } from "ulid"; +import type { Config } from "../../config/config"; import { getEventBus, type IEventBus } from "../events/EventBus"; import { createMessageGenerator } from "./createMessageGenerator"; import type { AliveClaudeCodeTask, ClaudeCodeTask, PendingClaudeCodeTask, + PermissionRequest, + PermissionResponse, RunningClaudeCodeTask, } from "./types"; @@ -15,12 +18,16 @@ export class ClaudeCodeTaskController { private pathToClaudeCodeExecutable: string; private tasks: ClaudeCodeTask[] = []; private eventBus: IEventBus; + private config: Config; + private pendingPermissionRequests: Map = new Map(); + private permissionResponses: Map = new Map(); - constructor() { + constructor(config: Config) { this.pathToClaudeCodeExecutable = execSync("which claude", {}) .toString() .trim(); this.eventBus = getEventBus(); + this.config = config; prexit(() => { this.aliveTasks.forEach((task) => { @@ -29,6 +36,124 @@ export class ClaudeCodeTaskController { }); } + public updateConfig(config: Config) { + this.config = config; + } + + public respondToPermissionRequest(response: PermissionResponse) { + this.permissionResponses.set(response.permissionRequestId, response); + this.pendingPermissionRequests.delete(response.permissionRequestId); + } + + private createCanUseToolCallback(taskId: string, sessionId?: string) { + return async ( + toolName: string, + toolInput: Record, + _options: { signal: AbortSignal }, + ) => { + // If not in default mode, use the configured permission mode behavior + if (this.config.permissionMode !== "default") { + // Convert Claude Code permission modes to canUseTool behaviors + if ( + this.config.permissionMode === "bypassPermissions" || + this.config.permissionMode === "acceptEdits" + ) { + return { + behavior: "allow" as const, + updatedInput: toolInput, + }; + } else { + // plan mode should deny actual tool execution + return { + behavior: "deny" as const, + message: "Tool execution is disabled in plan mode", + }; + } + } + + // Create permission request + const permissionRequest: PermissionRequest = { + id: ulid(), + taskId, + sessionId, + toolName, + toolInput, + timestamp: Date.now(), + }; + + // Store the request + this.pendingPermissionRequests.set( + permissionRequest.id, + permissionRequest, + ); + + // Emit event to notify UI + this.eventBus.emit("permissionRequested", { + permissionRequest, + }); + + // Wait for user response with timeout + const response = await this.waitForPermissionResponse( + permissionRequest.id, + 60000, + ); // 60 second timeout + + if (response) { + if (response.decision === "allow") { + return { + behavior: "allow" as const, + updatedInput: toolInput, + }; + } else { + return { + behavior: "deny" as const, + message: "Permission denied by user", + }; + } + } else { + // Timeout - default to deny for security + this.pendingPermissionRequests.delete(permissionRequest.id); + return { + behavior: "deny" as const, + message: "Permission request timed out", + }; + } + }; + } + + private async waitForPermissionResponse( + permissionRequestId: string, + timeoutMs: number, + ): Promise { + return new Promise((resolve) => { + const checkResponse = () => { + const response = this.permissionResponses.get(permissionRequestId); + if (response) { + this.permissionResponses.delete(permissionRequestId); + resolve(response); + return; + } + + // Check if request was cancelled/deleted + if (!this.pendingPermissionRequests.has(permissionRequestId)) { + resolve(null); + return; + } + + // Continue polling + setTimeout(checkResponse, 100); + }; + + // Set timeout + setTimeout(() => { + resolve(null); + }, timeoutMs); + + // Start polling + checkResponse(); + }); + } + public get aliveTasks() { return this.tasks.filter( (task) => task.status === "running" || task.status === "paused", @@ -114,7 +239,11 @@ export class ClaudeCodeTaskController { resume: task.baseSessionId, cwd: task.cwd, pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable, - permissionMode: "bypassPermissions", + permissionMode: this.config.permissionMode, + canUseTool: this.createCanUseToolCallback( + task.id, + task.baseSessionId, + ), abortController: abortController, }, })) { diff --git a/src/server/service/claude-code/types.ts b/src/server/service/claude-code/types.ts index b43535d..f2d2176 100644 --- a/src/server/service/claude-code/types.ts +++ b/src/server/service/claude-code/types.ts @@ -58,3 +58,17 @@ export type SerializableAliveTask = Pick< AliveClaudeCodeTask, "id" | "status" | "sessionId" | "userMessageId" >; + +export type PermissionRequest = { + id: string; + taskId: string; + sessionId?: string; + toolName: string; + toolInput: Record; + timestamp: number; +}; + +export type PermissionResponse = { + permissionRequestId: string; + decision: "allow" | "deny"; +}; diff --git a/src/server/service/events/InternalEventDeclaration.ts b/src/server/service/events/InternalEventDeclaration.ts index 4f12e31..299d8db 100644 --- a/src/server/service/events/InternalEventDeclaration.ts +++ b/src/server/service/events/InternalEventDeclaration.ts @@ -1,4 +1,7 @@ -import type { AliveClaudeCodeTask } from "../claude-code/types"; +import type { + AliveClaudeCodeTask, + PermissionRequest, +} from "../claude-code/types"; export type InternalEventDeclaration = { // biome-ignore lint/complexity/noBannedTypes: correct type @@ -16,4 +19,8 @@ export type InternalEventDeclaration = { taskChanged: { aliveTasks: AliveClaudeCodeTask[]; }; + + permissionRequested: { + permissionRequest: PermissionRequest; + }; }; diff --git a/src/server/service/events/adaptInternalEventToSSE.ts b/src/server/service/events/adaptInternalEventToSSE.ts index ba15e74..e4bf7c8 100644 --- a/src/server/service/events/adaptInternalEventToSSE.ts +++ b/src/server/service/events/adaptInternalEventToSSE.ts @@ -30,6 +30,7 @@ export const adaptInternalEventToSSE = ( abortController.abort(); eventBus.off("heartbeat", heartbeat); + eventBus.off("permissionRequested", permissionRequested); cleanUp?.(); }; @@ -45,7 +46,16 @@ export const adaptInternalEventToSSE = ( }); }; + const permissionRequested = ( + event: InternalEventDeclaration["permissionRequested"], + ) => { + stream.writeSSE("permission_requested", { + permissionRequest: event.permissionRequest, + }); + }; + eventBus.on("heartbeat", heartbeat); + eventBus.on("permissionRequested", permissionRequested); stream.writeSSE("connect", { timestamp: new Date().toISOString(), diff --git a/src/types/permissions.ts b/src/types/permissions.ts new file mode 100644 index 0000000..32f35fc --- /dev/null +++ b/src/types/permissions.ts @@ -0,0 +1,13 @@ +export type PermissionRequest = { + id: string; + taskId: string; + sessionId?: string; + toolName: string; + toolInput: Record; + timestamp: number; +}; + +export type PermissionResponse = { + permissionRequestId: string; + decision: "allow" | "deny"; +}; diff --git a/src/types/sse.ts b/src/types/sse.ts index 08aae42..c19ba08 100644 --- a/src/types/sse.ts +++ b/src/types/sse.ts @@ -1,4 +1,7 @@ -import type { AliveClaudeCodeTask } from "../server/service/claude-code/types"; +import type { + AliveClaudeCodeTask, + PermissionRequest, +} from "../server/service/claude-code/types"; export type SSEEventDeclaration = { // biome-ignore lint/complexity/noBannedTypes: correct type @@ -19,6 +22,10 @@ export type SSEEventDeclaration = { taskChanged: { aliveTasks: AliveClaudeCodeTask[]; }; + + permission_requested: { + permissionRequest: PermissionRequest; + }; }; export type SSEEventMap = { diff --git a/tsconfig.json b/tsconfig.json index ed4ef0a..eac6513 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,12 @@ "exactOptionalPropertyTypes": false, "allowJs": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "next-env.d.ts", + "./.next/types/**/*.ts" + ], "exclude": ["node_modules"] }