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:
7418andClaude Opus 4.6 committed 2026-02-06 21:04:05 +08:00
1 parent 6776cc6297
commit ff1fb38735
149 files changed
+31117 -122

No files matched your search

+343
View File
@@ -0,0 +1,343 @@
import { query } from '@anthropic-ai/claude-agent-sdk';
import type {
SDKMessage,
SDKAssistantMessage,
SDKUserMessage,
SDKResultMessage,
SDKPartialAssistantMessage,
SDKSystemMessage,
SDKToolProgressMessage,
Options,
McpStdioServerConfig,
NotificationHookInput,
PostToolUseHookInput,
} from '@anthropic-ai/claude-agent-sdk';
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent } from '@/types';
import { registerPendingPermission } from './permission-registry';
/**
* Convert our MCPServerConfig to the SDK's McpStdioServerConfig format
*/
function toSdkMcpConfig(
servers: Record<string, MCPServerConfig>
): Record<string, McpStdioServerConfig> {
const result: Record<string, McpStdioServerConfig> = {};
for (const [name, config] of Object.entries(servers)) {
result[name] = {
command: config.command,
args: config.args,
env: config.env,
};
}
return result;
}
/**
* Format an SSE line from an event object
*/
function formatSSE(event: SSEEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
/**
* Extract text content from an SDK assistant message
*/
function extractTextFromMessage(msg: SDKAssistantMessage): string {
const parts: string[] = [];
for (const block of msg.message.content) {
if (block.type === 'text') {
parts.push(block.text);
}
}
return parts.join('');
}
/**
* Extract token usage from an SDK result message
*/
function extractTokenUsage(msg: SDKResultMessage): TokenUsage | null {
if (!msg.usage) return null;
return {
input_tokens: msg.usage.input_tokens,
output_tokens: msg.usage.output_tokens,
cache_read_input_tokens: msg.usage.cache_read_input_tokens ?? 0,
cache_creation_input_tokens: msg.usage.cache_creation_input_tokens ?? 0,
cost_usd: 'total_cost_usd' in msg ? msg.total_cost_usd : undefined,
};
}
/**
* Stream Claude responses using the Agent SDK.
* Returns a ReadableStream of SSE-formatted strings.
*/
export function streamClaude(options: ClaudeStreamOptions): ReadableStream<string> {
const {
prompt,
sdkSessionId,
model,
systemPrompt,
workingDirectory,
mcpServers,
abortController,
permissionMode,
} = options;
return new ReadableStream<string>({
async start(controller) {
try {
const queryOptions: Options = {
cwd: workingDirectory || process.cwd(),
abortController,
includePartialMessages: true,
permissionMode: (permissionMode as Options['permissionMode']) || 'acceptEdits',
};
if (model) {
queryOptions.model = model;
}
if (systemPrompt) {
queryOptions.systemPrompt = systemPrompt;
}
if (mcpServers && Object.keys(mcpServers).length > 0) {
queryOptions.mcpServers = toSdkMcpConfig(mcpServers);
}
// Resume session if we have an SDK session ID from a previous conversation turn
if (sdkSessionId) {
queryOptions.resume = sdkSessionId;
}
// Permission handler: sends SSE event and waits for user response
queryOptions.canUseTool = async (toolName, input, opts) => {
const permissionRequestId = `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const permEvent: PermissionRequestEvent = {
permissionRequestId,
toolName,
toolInput: input,
suggestions: opts.suggestions as PermissionRequestEvent['suggestions'],
decisionReason: opts.decisionReason,
blockedPath: opts.blockedPath,
toolUseId: opts.toolUseID,
description: undefined,
};
// Send permission_request SSE event to the client
controller.enqueue(formatSSE({
type: 'permission_request',
data: JSON.stringify(permEvent),
}));
// Wait for user response (resolved by POST /api/chat/permission)
// Store original input so registry can inject updatedInput on allow
return registerPendingPermission(permissionRequestId, input, opts.signal);
};
// Hooks: capture notifications and tool completion events
queryOptions.hooks = {
Notification: [{
hooks: [async (input) => {
const notif = input as NotificationHookInput;
controller.enqueue(formatSSE({
type: 'status',
data: JSON.stringify({
notification: true,
title: notif.title,
message: notif.message,
}),
}));
return {};
}],
}],
PostToolUse: [{
hooks: [async (input) => {
const toolEvent = input as PostToolUseHookInput;
controller.enqueue(formatSSE({
type: 'tool_result',
data: JSON.stringify({
tool_use_id: toolEvent.tool_use_id,
content: typeof toolEvent.tool_response === 'string'
? toolEvent.tool_response
: JSON.stringify(toolEvent.tool_response),
is_error: false,
}),
}));
return {};
}],
}],
};
// Capture real-time stderr output from Claude Code process
queryOptions.stderr = (data: string) => {
// Diagnostic: log raw stderr data length to server console
console.log(`[stderr] received ${data.length} bytes, first 200 chars:`, data.slice(0, 200).replace(/[\x00-\x1F\x7F]/g, '?'));
// Strip ANSI escape codes, OSC sequences, and control characters
// but preserve tabs (\x09) and carriage returns (\x0D)
const cleaned = data
.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences (colors, cursor)
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '') // OSC sequences
.replace(/\x1B\([A-Z]/g, '') // Character set selection
.replace(/\x1B[=>]/g, '') // Keypad mode
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') // Control chars (keep \t \n \r)
.replace(/\r\n/g, '\n') // Normalize CRLF
.replace(/\r/g, '\n') // Convert remaining CR to LF
.replace(/\n{3,}/g, '\n\n') // Collapse multiple blank lines
.trim();
if (cleaned) {
controller.enqueue(formatSSE({
type: 'tool_output',
data: cleaned,
}));
}
};
const conversation = query({
prompt,
options: queryOptions,
});
let lastAssistantText = '';
let tokenUsage: TokenUsage | null = null;
for await (const message of conversation) {
if (abortController?.signal.aborted) {
break;
}
switch (message.type) {
case 'assistant': {
const assistantMsg = message as SDKAssistantMessage;
const text = extractTextFromMessage(assistantMsg);
if (text && text !== lastAssistantText) {
// Send the new delta
const delta = text.slice(lastAssistantText.length);
if (delta) {
controller.enqueue(formatSSE({ type: 'text', data: delta }));
}
lastAssistantText = text;
}
// Check for tool use blocks
for (const block of assistantMsg.message.content) {
if (block.type === 'tool_use') {
controller.enqueue(formatSSE({
type: 'tool_use',
data: JSON.stringify({
id: block.id,
name: block.name,
input: block.input,
}),
}));
}
}
break;
}
case 'user': {
// Tool execution results come back as user messages with tool_result blocks
const userMsg = message as SDKUserMessage;
const content = userMsg.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result') {
const resultContent = typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
? block.content
.filter((c: { type: string }) => c.type === 'text')
.map((c: { text: string }) => c.text)
.join('\n')
: String(block.content ?? '');
controller.enqueue(formatSSE({
type: 'tool_result',
data: JSON.stringify({
tool_use_id: block.tool_use_id,
content: resultContent,
is_error: block.is_error || false,
}),
}));
}
}
}
break;
}
case 'stream_event': {
const streamEvent = message as SDKPartialAssistantMessage;
const evt = streamEvent.event;
if (evt.type === 'content_block_delta' && 'delta' in evt) {
const delta = evt.delta;
if ('text' in delta && delta.text) {
controller.enqueue(formatSSE({ type: 'text', data: delta.text }));
}
}
break;
}
case 'system': {
const sysMsg = message as SDKSystemMessage;
if ('subtype' in sysMsg) {
if (sysMsg.subtype === 'init') {
controller.enqueue(formatSSE({
type: 'status',
data: JSON.stringify({
session_id: sysMsg.session_id,
model: sysMsg.model,
tools: sysMsg.tools,
}),
}));
}
}
break;
}
case 'tool_progress': {
const progressMsg = message as SDKToolProgressMessage;
controller.enqueue(formatSSE({
type: 'tool_output',
data: JSON.stringify({
_progress: true,
tool_use_id: progressMsg.tool_use_id,
tool_name: progressMsg.tool_name,
elapsed_time_seconds: progressMsg.elapsed_time_seconds,
}),
}));
break;
}
case 'result': {
const resultMsg = message as SDKResultMessage;
tokenUsage = extractTokenUsage(resultMsg);
controller.enqueue(formatSSE({
type: 'result',
data: JSON.stringify({
subtype: resultMsg.subtype,
is_error: resultMsg.is_error,
num_turns: resultMsg.num_turns,
duration_ms: resultMsg.duration_ms,
usage: tokenUsage,
session_id: resultMsg.session_id,
}),
}));
break;
}
}
}
controller.enqueue(formatSSE({ type: 'done', data: '' }));
controller.close();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
controller.enqueue(formatSSE({ type: 'error', data: errorMessage }));
controller.enqueue(formatSSE({ type: 'done', data: '' }));
controller.close();
}
},
cancel() {
abortController?.abort();
},
});
}
+310
View File
@@ -0,0 +1,310 @@
import Database from 'better-sqlite3';
import path from 'path';
import crypto from 'crypto';
import type { ChatSession, Message, SettingsMap, TaskItem, TaskStatus } from '@/types';
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(process.cwd(), 'data');
const DB_PATH = path.join(dataDir, 'codepilot.db');
let db: Database.Database | null = null;
export function getDb(): Database.Database {
if (!db) {
const fs = require('fs');
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
initDb(db);
}
return db;
}
function initDb(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS chat_sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT 'New Chat',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
model TEXT NOT NULL DEFAULT '',
system_prompt TEXT NOT NULL DEFAULT '',
working_directory TEXT NOT NULL DEFAULT '',
sdk_session_id TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
token_usage TEXT,
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'failed')),
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON chat_sessions(updated_at);
CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id);
`);
// Run migrations for existing databases
migrateDb(db);
}
function migrateDb(db: Database.Database): void {
const columns = db.prepare("PRAGMA table_info(chat_sessions)").all() as { name: string }[];
const colNames = columns.map(c => c.name);
if (!colNames.includes('model')) {
db.exec("ALTER TABLE chat_sessions ADD COLUMN model TEXT NOT NULL DEFAULT ''");
}
if (!colNames.includes('system_prompt')) {
db.exec("ALTER TABLE chat_sessions ADD COLUMN system_prompt TEXT NOT NULL DEFAULT ''");
}
if (!colNames.includes('sdk_session_id')) {
db.exec("ALTER TABLE chat_sessions ADD COLUMN sdk_session_id TEXT NOT NULL DEFAULT ''");
}
if (!colNames.includes('project_name')) {
db.exec("ALTER TABLE chat_sessions ADD COLUMN project_name TEXT NOT NULL DEFAULT ''");
// Backfill project_name from working_directory for existing rows
db.exec(`
UPDATE chat_sessions
SET project_name = CASE
WHEN working_directory != '' THEN REPLACE(REPLACE(working_directory, RTRIM(working_directory, REPLACE(working_directory, '/', '')), ''), '/', '')
ELSE ''
END
WHERE project_name = ''
`);
}
if (!colNames.includes('status')) {
db.exec("ALTER TABLE chat_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
}
if (!colNames.includes('mode')) {
db.exec("ALTER TABLE chat_sessions ADD COLUMN mode TEXT NOT NULL DEFAULT 'code'");
}
const msgColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
const msgColNames = msgColumns.map(c => c.name);
if (!msgColNames.includes('token_usage')) {
db.exec("ALTER TABLE messages ADD COLUMN token_usage TEXT");
}
// Ensure tasks table exists for databases created before this migration
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'failed')),
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id);
`);
}
// ==========================================
// Session Operations
// ==========================================
export function getAllSessions(): ChatSession[] {
const db = getDb();
return db.prepare('SELECT * FROM chat_sessions ORDER BY updated_at DESC').all() as ChatSession[];
}
export function getSession(id: string): ChatSession | undefined {
const db = getDb();
return db.prepare('SELECT * FROM chat_sessions WHERE id = ?').get(id) as ChatSession | undefined;
}
export function createSession(
title?: string,
model?: string,
systemPrompt?: string,
workingDirectory?: string,
mode?: string,
): ChatSession {
const db = getDb();
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
const wd = workingDirectory || process.cwd();
const projectName = path.basename(wd);
db.prepare(
'INSERT INTO chat_sessions (id, title, created_at, updated_at, model, system_prompt, working_directory, sdk_session_id, project_name, status, mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, title || 'New Chat', now, now, model || '', systemPrompt || '', wd, '', projectName, 'active', mode || 'code');
return getSession(id)!;
}
export function deleteSession(id: string): boolean {
const db = getDb();
const result = db.prepare('DELETE FROM chat_sessions WHERE id = ?').run(id);
return result.changes > 0;
}
export function updateSessionTimestamp(id: string): void {
const db = getDb();
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
db.prepare('UPDATE chat_sessions SET updated_at = ? WHERE id = ?').run(now, id);
}
export function updateSessionTitle(id: string, title: string): void {
const db = getDb();
db.prepare('UPDATE chat_sessions SET title = ? WHERE id = ?').run(title, id);
}
export function updateSdkSessionId(id: string, sdkSessionId: string): void {
const db = getDb();
db.prepare('UPDATE chat_sessions SET sdk_session_id = ? WHERE id = ?').run(sdkSessionId, id);
}
export function updateSessionWorkingDirectory(id: string, workingDirectory: string): void {
const db = getDb();
const projectName = path.basename(workingDirectory);
db.prepare('UPDATE chat_sessions SET working_directory = ?, project_name = ? WHERE id = ?').run(workingDirectory, projectName, id);
}
export function updateSessionMode(id: string, mode: string): void {
const db = getDb();
db.prepare('UPDATE chat_sessions SET mode = ? WHERE id = ?').run(mode, id);
}
// ==========================================
// Message Operations
// ==========================================
export function getMessages(sessionId: string): Message[] {
const db = getDb();
return db.prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC').all(sessionId) as Message[];
}
export function addMessage(
sessionId: string,
role: 'user' | 'assistant',
content: string,
tokenUsage?: string | null,
): Message {
const db = getDb();
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
db.prepare(
'INSERT INTO messages (id, session_id, role, content, created_at, token_usage) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, sessionId, role, content, now, tokenUsage || null);
updateSessionTimestamp(sessionId);
return db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as Message;
}
// ==========================================
// Settings Operations
// ==========================================
export function getSetting(key: string): string | undefined {
const db = getDb();
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value;
}
export function setSetting(key: string, value: string): void {
const db = getDb();
db.prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, value);
}
export function getAllSettings(): SettingsMap {
const db = getDb();
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
const settings: SettingsMap = {};
for (const row of rows) {
settings[row.key] = row.value;
}
return settings;
}
// ==========================================
// Session Status Operations
// ==========================================
export function updateSessionStatus(id: string, status: 'active' | 'archived'): void {
const db = getDb();
db.prepare('UPDATE chat_sessions SET status = ? WHERE id = ?').run(status, id);
}
// ==========================================
// Task Operations
// ==========================================
export function getTasksBySession(sessionId: string): TaskItem[] {
const db = getDb();
return db.prepare('SELECT * FROM tasks WHERE session_id = ? ORDER BY created_at ASC').all(sessionId) as TaskItem[];
}
export function getTask(id: string): TaskItem | undefined {
const db = getDb();
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskItem | undefined;
}
export function createTask(sessionId: string, title: string, description?: string): TaskItem {
const db = getDb();
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
db.prepare(
'INSERT INTO tasks (id, session_id, title, status, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(id, sessionId, title, 'pending', description || null, now, now);
return getTask(id)!;
}
export function updateTask(id: string, updates: { title?: string; status?: TaskStatus; description?: string }): TaskItem | undefined {
const db = getDb();
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
const existing = getTask(id);
if (!existing) return undefined;
const title = updates.title ?? existing.title;
const status = updates.status ?? existing.status;
const description = updates.description !== undefined ? updates.description : existing.description;
db.prepare(
'UPDATE tasks SET title = ?, status = ?, description = ?, updated_at = ? WHERE id = ?'
).run(title, status, description, now, id);
return getTask(id);
}
export function deleteTask(id: string): boolean {
const db = getDb();
const result = db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
return result.changes > 0;
}
+173
View File
@@ -0,0 +1,173 @@
import fs from 'fs';
import path from 'path';
import type { FileTreeNode, FilePreview } from '@/types';
const IGNORED_DIRS = new Set([
'node_modules',
'.git',
'dist',
'.next',
'__pycache__',
'.cache',
'.turbo',
'coverage',
'.output',
'build',
]);
const LANGUAGE_MAP: Record<string, string> = {
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
py: 'python',
rb: 'ruby',
rs: 'rust',
go: 'go',
java: 'java',
kt: 'kotlin',
swift: 'swift',
c: 'c',
cpp: 'cpp',
h: 'c',
hpp: 'cpp',
cs: 'csharp',
css: 'css',
scss: 'scss',
less: 'less',
html: 'html',
xml: 'xml',
json: 'json',
yaml: 'yaml',
yml: 'yaml',
toml: 'toml',
md: 'markdown',
mdx: 'markdown',
sql: 'sql',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
fish: 'fish',
ps1: 'powershell',
dockerfile: 'dockerfile',
graphql: 'graphql',
gql: 'graphql',
vue: 'vue',
svelte: 'svelte',
prisma: 'prisma',
env: 'dotenv',
lua: 'lua',
r: 'r',
php: 'php',
dart: 'dart',
zig: 'zig',
};
export function getFileLanguage(ext: string): string {
const normalized = ext.replace(/^\./, '').toLowerCase();
return LANGUAGE_MAP[normalized] || 'plaintext';
}
export function isPathSafe(basePath: string, targetPath: string): boolean {
const resolvedBase = path.resolve(basePath);
const resolvedTarget = path.resolve(targetPath);
return resolvedTarget.startsWith(resolvedBase + path.sep) || resolvedTarget === resolvedBase;
}
export function scanDirectory(dir: string, depth: number = 3): FileTreeNode[] {
const resolvedDir = path.resolve(dir);
if (!fs.existsSync(resolvedDir)) {
return [];
}
return scanDirectoryRecursive(resolvedDir, depth);
}
function scanDirectoryRecursive(dir: string, depth: number): FileTreeNode[] {
if (depth <= 0) return [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
const nodes: FileTreeNode[] = [];
// Sort: directories first, then files, both alphabetically
const sorted = entries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (const entry of sorted) {
// Skip hidden files/dirs (except common config files)
if (entry.name.startsWith('.') && !entry.name.startsWith('.env')) {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name)) continue;
const children = scanDirectoryRecursive(fullPath, depth - 1);
nodes.push({
name: entry.name,
path: fullPath,
type: 'directory',
children,
});
} else if (entry.isFile()) {
const ext = path.extname(entry.name).replace(/^\./, '');
let size: number | undefined;
try {
const stat = fs.statSync(fullPath);
size = stat.size;
} catch {
// Skip files we can't stat
}
nodes.push({
name: entry.name,
path: fullPath,
type: 'file',
size,
extension: ext || undefined,
});
}
}
return nodes;
}
export function readFilePreview(filePath: string, maxLines: number = 200): FilePreview {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`File not found: ${filePath}`);
}
const stat = fs.statSync(resolvedPath);
if (!stat.isFile()) {
throw new Error(`Not a file: ${filePath}`);
}
// Read the file content, limiting to maxLines
const content = fs.readFileSync(resolvedPath, 'utf-8');
const lines = content.split('\n');
const truncated = lines.slice(0, maxLines).join('\n');
const ext = path.extname(resolvedPath).replace(/^\./, '');
const language = getFileLanguage(ext);
return {
path: resolvedPath,
content: truncated,
language,
line_count: lines.length,
};
}
+93
View File
@@ -0,0 +1,93 @@
import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk';
interface PendingPermission {
resolve: (result: PermissionResult) => void;
createdAt: number;
abortSignal?: AbortSignal;
toolInput: Record<string, unknown>; // Original tool input for updatedInput in allow response
}
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
// Use globalThis to ensure the Map is shared across all module instances.
// In Next.js dev mode (Turbopack), different API routes may load separate
// module instances, so a module-level variable would NOT be shared.
const globalKey = '__pendingPermissions__' as const;
function getMap(): Map<string, PendingPermission> {
if (!(globalThis as Record<string, unknown>)[globalKey]) {
(globalThis as Record<string, unknown>)[globalKey] = new Map<string, PendingPermission>();
}
return (globalThis as Record<string, unknown>)[globalKey] as Map<string, PendingPermission>;
}
/**
* Lazily clean up expired entries (older than TIMEOUT_MS).
*/
function cleanupExpired() {
const map = getMap();
const now = Date.now();
for (const [id, entry] of map) {
if (now - entry.createdAt > TIMEOUT_MS) {
entry.resolve({ behavior: 'deny', message: 'Permission request timed out' });
map.delete(id);
}
}
}
/**
* Register a pending permission request.
* Returns a Promise that resolves when the user responds.
*/
export function registerPendingPermission(
id: string,
toolInput: Record<string, unknown>,
abortSignal?: AbortSignal,
): Promise<PermissionResult> {
// Lazily clean up expired entries on each registration
cleanupExpired();
const map = getMap();
return new Promise<PermissionResult>((resolve) => {
map.set(id, {
resolve,
createdAt: Date.now(),
abortSignal,
toolInput,
});
// Auto-deny if the abort signal fires (client disconnect / stop button)
if (abortSignal) {
const onAbort = () => {
if (map.has(id)) {
resolve({ behavior: 'deny', message: 'Request aborted' });
map.delete(id);
}
};
abortSignal.addEventListener('abort', onAbort, { once: true });
}
});
}
/**
* Resolve a pending permission request with the user's decision.
* Returns true if the permission was found and resolved, false otherwise.
*/
export function resolvePendingPermission(
id: string,
result: PermissionResult,
): boolean {
const map = getMap();
const entry = map.get(id);
if (!entry) return false;
// SDK requires updatedInput when allowing — inject the original tool input
if (result.behavior === 'allow' && !result.updatedInput) {
result = { ...result, updatedInput: entry.toolInput };
}
entry.resolve(result);
map.delete(id);
return true;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}