feat: CodePilot - desktop GUI for Claude Code
Full-featured desktop application built with Next.js + Electron: - Streaming chat with Claude Code via Agent SDK - Session management with SQLite persistence - Project file tree and file preview - MCP server and custom skills management - Permission controls with Code/Plan/Ask modes - Dark/light theme, token tracking - macOS DMG packaging with electron-builder Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6776cc6297
commit
ff1fb38735
149 files changed
+31117
-122
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { resolvePendingPermission } from '@/lib/permission-registry';
|
||||
import type { PermissionResponseRequest } from '@/types';
|
||||
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: PermissionResponseRequest = await request.json();
|
||||
const { permissionRequestId, decision } = body;
|
||||
|
||||
if (!permissionRequestId || !decision) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'permissionRequestId and decision are required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
let result: PermissionResult;
|
||||
if (decision.behavior === 'allow') {
|
||||
result = {
|
||||
behavior: 'allow',
|
||||
updatedPermissions: decision.updatedPermissions as unknown as PermissionUpdate[],
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
behavior: 'deny',
|
||||
message: decision.message || 'User denied permission',
|
||||
};
|
||||
}
|
||||
|
||||
const found = resolvePendingPermission(permissionRequestId, result);
|
||||
|
||||
if (!found) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Permission request not found or already resolved' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return new Response(
|
||||
JSON.stringify({ error: message }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { streamClaude } from '@/lib/claude-client';
|
||||
import { addMessage, getSession, updateSessionTitle, updateSdkSessionId, getSetting } from '@/lib/db';
|
||||
import type { SendMessageRequest, SSEEvent, TokenUsage } from '@/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: SendMessageRequest = await request.json();
|
||||
const { session_id, content, model, mode } = body;
|
||||
|
||||
if (!session_id || !content) {
|
||||
return new Response(JSON.stringify({ error: 'session_id and content are required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const session = getSession(session_id);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Save user message
|
||||
addMessage(session_id, 'user', content);
|
||||
|
||||
// Auto-generate title from first message if still default
|
||||
if (session.title === 'New Chat') {
|
||||
const title = content.slice(0, 50) + (content.length > 50 ? '...' : '');
|
||||
updateSessionTitle(session_id, title);
|
||||
}
|
||||
|
||||
// Determine model: request override > session model > default setting
|
||||
const effectiveModel = model || session.model || getSetting('default_model') || undefined;
|
||||
|
||||
// Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools)
|
||||
const effectiveMode = mode || session.mode || 'code';
|
||||
let permissionMode: string;
|
||||
let systemPromptOverride: string | undefined;
|
||||
switch (effectiveMode) {
|
||||
case 'plan':
|
||||
permissionMode = 'plan';
|
||||
break;
|
||||
case 'ask':
|
||||
permissionMode = 'default';
|
||||
systemPromptOverride = (session.system_prompt || '') +
|
||||
'\n\nYou are in Ask mode. Answer questions and provide information only. Do not use any tools, do not read or write files, do not execute commands. Only respond with text.';
|
||||
break;
|
||||
default: // 'code'
|
||||
permissionMode = 'acceptEdits';
|
||||
break;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Handle client disconnect
|
||||
request.signal.addEventListener('abort', () => {
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
// Stream Claude response, using SDK session ID for resume if available
|
||||
const stream = streamClaude({
|
||||
prompt: content,
|
||||
sessionId: session_id,
|
||||
sdkSessionId: session.sdk_session_id || undefined,
|
||||
model: effectiveModel,
|
||||
systemPrompt: systemPromptOverride || session.system_prompt || undefined,
|
||||
workingDirectory: session.working_directory || undefined,
|
||||
abortController,
|
||||
permissionMode,
|
||||
});
|
||||
|
||||
// Tee the stream: one for client, one for collecting the response
|
||||
const [streamForClient, streamForCollect] = stream.tee();
|
||||
|
||||
// Save assistant message in background
|
||||
collectStreamResponse(streamForCollect, session_id);
|
||||
|
||||
return new Response(streamForClient, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function collectStreamResponse(stream: ReadableStream<string>, sessionId: string) {
|
||||
const reader = stream.getReader();
|
||||
let fullText = '';
|
||||
let tokenUsage: TokenUsage | null = null;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const lines = value.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const event: SSEEvent = JSON.parse(line.slice(6));
|
||||
if (event.type === 'permission_request' || event.type === 'tool_output') {
|
||||
// Skip permission_request and tool_output events - not saved as message content
|
||||
} else if (event.type === 'text') {
|
||||
fullText += event.data;
|
||||
} else if (event.type === 'status') {
|
||||
// Capture SDK session_id from init event and persist it
|
||||
try {
|
||||
const statusData = JSON.parse(event.data);
|
||||
if (statusData.session_id) {
|
||||
updateSdkSessionId(sessionId, statusData.session_id);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed status data
|
||||
}
|
||||
} else if (event.type === 'result') {
|
||||
try {
|
||||
const resultData = JSON.parse(event.data);
|
||||
if (resultData.usage) {
|
||||
tokenUsage = resultData.usage;
|
||||
}
|
||||
// Also capture session_id from result if we missed it from init
|
||||
if (resultData.session_id) {
|
||||
updateSdkSessionId(sessionId, resultData.session_id);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed result data
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fullText.trim()) {
|
||||
addMessage(
|
||||
sessionId,
|
||||
'assistant',
|
||||
fullText.trim(),
|
||||
tokenUsage ? JSON.stringify(tokenUsage) : null,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Stream reading error - best effort save
|
||||
if (fullText.trim()) {
|
||||
addMessage(sessionId, 'assistant', fullText.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getMessages, getSession } from '@/lib/db';
|
||||
import type { MessagesResponse } from '@/types';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return Response.json({ error: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const messages = getMessages(id);
|
||||
const response: MessagesResponse = { messages };
|
||||
return Response.json(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch messages';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { deleteSession, getSession, updateSessionWorkingDirectory, updateSessionTitle, updateSessionMode } from '@/lib/db';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return Response.json({ error: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
return Response.json({ session });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get session';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return Response.json({ error: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
if (body.working_directory) {
|
||||
updateSessionWorkingDirectory(id, body.working_directory);
|
||||
}
|
||||
if (body.title) {
|
||||
updateSessionTitle(id, body.title);
|
||||
}
|
||||
if (body.mode) {
|
||||
updateSessionMode(id, body.mode);
|
||||
}
|
||||
|
||||
const updated = getSession(id);
|
||||
return Response.json({ session: updated });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update session';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return Response.json({ error: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
deleteSession(id);
|
||||
return Response.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete session';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getAllSessions, createSession } from '@/lib/db';
|
||||
import type { CreateSessionRequest, SessionsResponse, SessionResponse } from '@/types';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sessions = getAllSessions();
|
||||
const response: SessionsResponse = { sessions };
|
||||
return Response.json(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch sessions';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: CreateSessionRequest = await request.json();
|
||||
const session = createSession(
|
||||
body.title,
|
||||
body.model,
|
||||
body.system_prompt,
|
||||
body.working_directory,
|
||||
body.mode,
|
||||
);
|
||||
const response: SessionResponse = { session };
|
||||
return Response.json(response, { status: 201 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create session';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user