Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ jobs:
path: ./artifacts

- name: Upload release assets
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
files: |
artifacts/*/*
Expand All @@ -132,7 +132,7 @@ jobs:
prerelease: false

- name: Publish release
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
generate_release_notes: true
draft: false
Expand Down
4 changes: 3 additions & 1 deletion backend/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { handleProjectsRequest } from "./handlers/projects.ts";
import { handleHistoriesRequest } from "./handlers/histories.ts";
import { handleConversationRequest } from "./handlers/conversations.ts";
import { handleChatRequest } from "./handlers/chat.ts";
import { handleChatRequest, handlePermissionResponse } from "./handlers/chat.ts";
import { handleAbortRequest } from "./handlers/abort.ts";
import { logger } from "./utils/logger.ts";
import { readBinaryFile } from "./utils/fs.ts";
Expand Down Expand Up @@ -72,6 +72,8 @@ export function createApp(

app.post("/api/chat", (c) => handleChatRequest(c, requestAbortControllers));

app.post("/api/permission", (c) => handlePermissionResponse(c));

// Static file serving with SPA fallback
// Serve static assets (CSS, JS, images, etc.)
const serveStatic = runtime.createStaticFileMiddleware({
Expand Down
196 changes: 143 additions & 53 deletions backend/handlers/chat.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import { Context } from "hono";
import { query, type PermissionMode } from "@anthropic-ai/claude-code";
import type { ChatRequest, StreamResponse } from "../../shared/types.ts";
import type {
ChatRequest,
StreamResponse,
PermissionResponse,
} from "../../shared/types.ts";
import { logger } from "../utils/logger.ts";
import {
createPermissionRequest,
resolvePermissionRequest,
generateRequestId,
} from "./permission.ts";

const TOOLS_REQUIRING_INTERACTION = new Set(["AskUserQuestion"]);

interface PermissionQueueItem {
type: "permission_request";
requestId: string;
toolName: string;
toolInput: Record<string, unknown>;
}

/**
* Executes a Claude command and yields streaming responses
* @param message - User message or command
* @param requestId - Unique request identifier for abort functionality
* @param requestAbortControllers - Shared map of abort controllers
* @param cliPath - Path to actual CLI script (detected by validateClaudeCli)
* @param sessionId - Optional session ID for conversation continuity
* @param allowedTools - Optional array of allowed tool names
* @param workingDirectory - Optional working directory for Claude execution
* @param permissionMode - Optional permission mode for Claude execution
* @returns AsyncGenerator yielding StreamResponse objects
*/
async function* executeClaudeCommand(
message: string,
requestId: string,
Expand All @@ -24,71 +30,136 @@ async function* executeClaudeCommand(
allowedTools?: string[],
workingDirectory?: string,
permissionMode?: PermissionMode,
permissionQueue?: PermissionQueueItem[],
): AsyncGenerator<StreamResponse> {
let abortController: AbortController;
const abortController = new AbortController();
requestAbortControllers.set(requestId, abortController);
const permissionQueueLocal = permissionQueue || [];

try {
// Process commands that start with '/'
let processedMessage = message;
if (message.startsWith("/")) {
// Remove the '/' and send just the command
processedMessage = message.substring(1);
let processedMessage = message;
if (message.startsWith("/")) {
processedMessage = message.substring(1);
}

const canUseTool = async (
toolName: string,
input: Record<string, unknown>,
options: { signal: AbortSignal; suggestions?: unknown },
): Promise<{ behavior: "allow" | "deny"; updatedInput: Record<string, unknown>; message?: string }> => {
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);

if (permissionMode === "bypassPermissions" && !requiresInteraction) {
return { behavior: "allow", updatedInput: input };
}

if (allowedTools) {
const isAllowed = allowedTools.some(
(tool) => tool === toolName || tool.startsWith(`${toolName}(`),
);
if (isAllowed && !requiresInteraction) {
return { behavior: "allow", updatedInput: input };
}
}

const permRequestId = generateRequestId();

permissionQueueLocal.push({
type: "permission_request",
requestId: permRequestId,
toolName,
toolInput: input,
});

try {
const response = await createPermissionRequest(
permRequestId,
toolName,
input,
);

if (response.allow) {
if (response.rememberEntry && allowedTools) {
allowedTools.push(response.rememberEntry);
}
return {
behavior: "allow",
updatedInput: response.updatedInput || input,
};
}

return {
behavior: "deny",
updatedInput: input,
message: response.message || "User denied tool use",
};
} catch (error) {
return {
behavior: "deny",
updatedInput: input,
message: error instanceof Error ? error.message : "Permission request failed",
};
}
};

// Create and store AbortController for this request
abortController = new AbortController();
requestAbortControllers.set(requestId, abortController);
try {
const queryOptions: Parameters<typeof query>[0]["options"] = {
abortController,
executable: "node" as const,
executableArgs: [],
pathToClaudeCodeExecutable: cliPath,
...(sessionId ? { resume: sessionId } : {}),
...(allowedTools ? { allowedTools } : {}),
...(workingDirectory ? { cwd: workingDirectory } : {}),
...(permissionMode ? { permissionMode } : {}),
canUseTool,
};

for await (const sdkMessage of query({
prompt: processedMessage,
options: {
abortController,
executable: "node" as const,
executableArgs: [],
pathToClaudeCodeExecutable: cliPath,
...(sessionId ? { resume: sessionId } : {}),
...(allowedTools ? { allowedTools } : {}),
...(workingDirectory ? { cwd: workingDirectory } : {}),
...(permissionMode ? { permissionMode } : {}),
},
options: queryOptions,
})) {
// Debug logging of raw SDK messages with detailed content
logger.chat.debug("Claude SDK Message: {sdkMessage}", { sdkMessage });

while (permissionQueueLocal.length > 0) {
const permRequest = permissionQueueLocal.shift()!;
yield {
type: "permission_request",
permissionRequestId: permRequest.requestId,
toolName: permRequest.toolName,
toolInput: permRequest.toolInput,
};
}

yield {
type: "claude_json",
data: sdkMessage,
};
}

yield { type: "done" };
} catch (error) {
// Check if error is due to abort
// TODO: Re-enable when AbortError is properly exported from Claude SDK
// if (error instanceof AbortError) {
// yield { type: "aborted" };
// } else {
{
logger.chat.error("Claude Code execution failed: {error}", { error });
while (permissionQueueLocal.length > 0) {
const permRequest = permissionQueueLocal.shift()!;
yield {
type: "error",
error: error instanceof Error ? error.message : String(error),
type: "permission_request",
permissionRequestId: permRequest.requestId,
toolName: permRequest.toolName,
toolInput: permRequest.toolInput,
};
}

yield { type: "done" };
} catch (error) {
logger.chat.error("Claude Code execution failed: {error}", { error });
yield {
type: "error",
error: error instanceof Error ? error.message : String(error),
};
} finally {
// Clean up AbortController from map
if (requestAbortControllers.has(requestId)) {
requestAbortControllers.delete(requestId);
}
}
}

/**
* Handles POST /api/chat requests with streaming responses
* @param c - Hono context object with config variables
* @param requestAbortControllers - Shared map of abort controllers
* @returns Response with streaming NDJSON
*/
export async function handleChatRequest(
c: Context,
requestAbortControllers: Map<string, AbortController>,
Expand All @@ -101,18 +172,21 @@ export async function handleChatRequest(
chatRequest as unknown as Record<string, unknown>,
);

const permissionQueue: PermissionQueueItem[] = [];

const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of executeClaudeCommand(
chatRequest.message,
chatRequest.requestId,
requestAbortControllers,
cliPath, // Use detected CLI path from validateClaudeCli
cliPath,
chatRequest.sessionId,
chatRequest.allowedTools,
chatRequest.workingDirectory,
chatRequest.permissionMode,
permissionQueue,
)) {
const data = JSON.stringify(chunk) + "\n";
controller.enqueue(new TextEncoder().encode(data));
Expand All @@ -139,3 +213,19 @@ export async function handleChatRequest(
},
});
}

export async function handlePermissionResponse(c: Context) {
const response: PermissionResponse = await c.req.json();

logger.permission.debug("Received permission response: {response}", {
response,
});

const success = resolvePermissionRequest(response);

if (!success) {
return c.json({ error: "No pending permission request found" }, 404);
}

return c.json({ success: true });
}
Loading
Loading