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,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
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in new issue
Block a user