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}
+
+
+
+
+ );
+ };
+
+ const parameterEntries = Object.entries(permissionRequest.toolInput);
+ const hasParameters = parameterEntries.length > 0;
+
+ return (
+
+ );
+};
\ 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"]
}