feat: UI許可システムの実装

- permissionModeを設定可能に変更(bypassPermissions, default, acceptEdits, plan)
- defaultモード時のUI許可ダイアログを実装
  - canUseTool callbackによるプログラマティックな許可処理
  - SSEを使用したリアルタイム通信
  - 許可/拒否の選択UI
- PermissionDialog機能
  - 大きなダイアログサイズ(max-w-4xl, max-h-[80vh])
  - パラメータの折りたたみ表示
  - 各パラメータのコピーボタン
  - 長いテキストのスクロール対応
  - レスポンシブデザイン対応
This commit is contained in:
dobachi
2025-09-24 01:37:53 +09:00
parent a35cba7a21
commit b7e9947efb
13 changed files with 541 additions and 11 deletions

View File

@@ -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<typeof configSchema>;

View File

@@ -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,

View File

@@ -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<string, PermissionRequest> = new Map();
private permissionResponses: Map<string, PermissionResponse> = 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<string, unknown>,
_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<PermissionResponse | null> {
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,
},
})) {

View File

@@ -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<string, unknown>;
timestamp: number;
};
export type PermissionResponse = {
permissionRequestId: string;
decision: "allow" | "deny";
};

View File

@@ -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;
};
};

View File

@@ -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(),