diff --git a/README.md b/README.md index cfa08c524..dc334f30f 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,46 @@ By [21st.dev](https://21st.dev) team Enhancements added in this fork on top of upstream: +### Workflow & UI + +- **Split View with Drag-to-Split** - Drag a sub-chat from the sidebar to create or extend a split-view layout; per-pane close button in the title bar +- **Cmd+Shift+T: New Sub-Chat in Split** - Dedicated shortcut (and tooltip) that opens a new sub-chat directly into split view +- **Sortable Sidebar** - Reorder chats in the sidebar via drag-and-drop (@dnd-kit), with a grab-cursor + grip-handle hint on hover +- **Draggable Tab Bar** - Native HTML5 drag-and-drop on tabs with an insertion marker; split pairs stay locked +- **Queue Reorder** - Drag to reorder queued messages before they're sent +- **Text-Selection Copy Popover** - Copy button appears when you highlight text inside a chat message +- **Optimistic Sub-Chat Creation** - New sub-chats appear instantly and roll back on RPC failure - **Per-Mode Thinking Effort** - Set Claude's thinking budget independently for Plan and Agent modes +- **Per-Mode Default Models** - Configure a default model per mode with automatic switching on mode change - **Usage Statistics** - Built-in page showing Claude + Codex token and cost tracking -- **Per-Mode Default Models** - Configure default model per mode with automatic switching on mode change -- **Latest Claude Models** - Opus 4.7 and updated model list including latest Claude releases - **Wider Chat Column** - Expanded chat area (max-w-4xl) for better readability -- **Windows Git Path Fix** - POSIX-normalized git paths so the sidebar tree view works on Windows - **Enter / Shift+Enter Swap** - Enter submits, Shift+Enter inserts a newline (matches common conventions) +### Git, PRs & Worktrees + +- **PR Widget with Comments** - Inline PR status, comments, and details alongside the chat +- **Branch Switcher Popover** - Switch branches from a popover in the changes panel; PR chip refreshes immediately on switch (no more 30-second polling wait) +- **PR Auto-Refresh on Commit/Push** - PR status updates automatically when you commit or push from the app +- **Two-Column Commit Diff** - Side-by-side diff view for commit contents +- **Pull & Push Recovery Dialog** - When `git push` fails because the remote is ahead, a one-click dialog auto-stashes, rebases, and re-pushes instead of surfacing a raw "non-fast-forward" error +- **Worktree Deletion Safety** - Worktrees are only removed when you explicitly opt in via the archive flow with the "Delete worktree" checkbox; project delete and app startup no longer auto-remove worktrees + +### Models + +- **Latest Claude Models** - Opus 4.7 and updated model list including the latest Claude releases +- **Sonnet 4.6 1M Context** - Full 1M-token context for Sonnet (`sonnet[1m]`) alongside the existing Opus 1M, with an amber "1M · higher cost" badge in the selector +- **One-Click 1M Recovery** - On rate-limit or context errors against a 1M model, the toast action becomes "Switch to \" — one click moves the sub-chat back to the 200K variant +- **GPT-5.4 & GPT-5.4 Mini** - Latest Codex models registered as the default; gpt-5.3-codex remains available + +### Stability & Polish + +- **Rich Tool Rendering** - Proper icons and labels for `Skill`, `ScheduleWakeup`, `EnterPlanMode`, `Cron*`, `Monitor`, `PushNotification`, `TaskOutput`/`TaskStop`, `EnterWorktree`/`ExitWorktree`, `RemoteTrigger`, and `ToolSearch` (previously rendered as plain text) +- **Stream Wedge Timeout** - 90-second first-chunk timeout aborts and surfaces a `STREAM_WEDGE` error instead of hanging the UI indefinitely +- **Crash Auto-Recovery** - App-root error boundary + one-shot auto-reload (10s debounce) for IPC race crashes, so you get a visible error state instead of a black screen +- **Session Abort on Delete** - In-flight Claude sessions are aborted before their workspace is removed on project/chat/sub-chat delete +- **Lazy Archive Popover** - Archive queries no longer fire until the popover opens, reducing startup network chatter +- **Windows Git Path Fix** - POSIX-normalized git paths so the sidebar tree view works on Windows + --- ## Highlights diff --git a/drizzle/0009_wise_rumiko_fujikawa.sql b/drizzle/0009_wise_rumiko_fujikawa.sql new file mode 100644 index 000000000..aa86b6b84 --- /dev/null +++ b/drizzle/0009_wise_rumiko_fujikawa.sql @@ -0,0 +1,3 @@ +CREATE INDEX `chats_project_id_idx` ON `chats` (`project_id`);--> statement-breakpoint +CREATE INDEX `sub_chats_chat_id_idx` ON `sub_chats` (`chat_id`);--> statement-breakpoint +CREATE INDEX `sub_chats_stream_id_idx` ON `sub_chats` (`stream_id`); \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 000000000..2c341534c --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,480 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "117fd460-8aeb-4646-8ff8-010720741f5f", + "prevId": "703af73c-d5ad-4d1e-9405-252a3086df88", + "tables": { + "anthropic_accounts": { + "name": "anthropic_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "desktop_user_id": { + "name": "desktop_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "anthropic_settings": { + "name": "anthropic_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'singleton'" + }, + "active_account_id": { + "name": "active_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "chats_worktree_path_idx": { + "name": "chats_worktree_path_idx", + "columns": [ + "worktree_path" + ], + "isUnique": false + }, + "chats_project_id_idx": { + "name": "chats_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chats_project_id_projects_id_fk": { + "name": "chats_project_id_projects_id_fk", + "tableFrom": "chats", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claude_code_credentials": { + "name": "claude_code_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_provider": { + "name": "git_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_owner": { + "name": "git_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_repo": { + "name": "git_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_path": { + "name": "icon_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_path_unique": { + "name": "projects_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sub_chats": { + "name": "sub_chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "file_stats_additions": { + "name": "file_stats_additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_stats_deletions": { + "name": "file_stats_deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_stats_file_count": { + "name": "file_stats_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sub_chats_chat_id_idx": { + "name": "sub_chats_chat_id_idx", + "columns": [ + "chat_id" + ], + "isUnique": false + }, + "sub_chats_stream_id_idx": { + "name": "sub_chats_stream_id_idx", + "columns": [ + "stream_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sub_chats_chat_id_chats_id_fk": { + "name": "sub_chats_chat_id_chats_id_fk", + "tableFrom": "sub_chats", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 57808ce6d..550ea615e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1776516765268, "tag": "0008_shiny_hydra", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1776530863612, + "tag": "0009_wise_rumiko_fujikawa", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/main/auth-manager.ts b/src/main/auth-manager.ts index e31b7bc1b..2d5c6fe71 100644 --- a/src/main/auth-manager.ts +++ b/src/main/auth-manager.ts @@ -1,6 +1,10 @@ import { AuthStore, AuthData, AuthUser } from "./auth-store" import { app, BrowserWindow } from "electron" import { AUTH_SERVER_PORT } from "./constants" +import { randomBytes, timingSafeEqual } from "node:crypto" + +// How long a `state` nonce is accepted after startAuthFlow() is called. +const AUTH_STATE_TTL_MS = 10 * 60 * 1000 // Get API URL - in packaged app always use production, in dev allow override function getApiBaseUrl(): string { @@ -15,6 +19,9 @@ export class AuthManager { private refreshTimer?: NodeJS.Timeout private isDev: boolean private onTokenRefresh?: (authData: AuthData) => void + // Tracks the in-flight OAuth `state` nonce from the most recent + // startAuthFlow() call. Used to reject unsolicited deep-link codes. + private pendingAuthState: { state: string; issuedAt: number } | null = null constructor(isDev: boolean = false) { this.store = new AuthStore(app.getPath("userData")) @@ -26,6 +33,41 @@ export class AuthManager { } } + /** + * Verify that an incoming OAuth callback was solicited by this app. + * + * - If the callback carries a `state` parameter, it must match the stored + * nonce (constant-time compare). + * - If no `state` is present (backend doesn't echo it), fall back to + * requiring that startAuthFlow() was called within the TTL window. + * + * Either path consumes the nonce — codes can't be replayed. + */ + verifyAndConsumeAuthState(incomingState: string | null): boolean { + const pending = this.pendingAuthState + this.pendingAuthState = null + + if (!pending) return false + if (Date.now() - pending.issuedAt > AUTH_STATE_TTL_MS) return false + + if (incomingState) { + const expected = Buffer.from(pending.state, "utf8") + const actual = Buffer.from(incomingState, "utf8") + if (expected.length !== actual.length) return false + try { + return timingSafeEqual(expected, actual) + } catch { + return false + } + } + + // No state echoed by server — accept only because a recent flow is + // in-flight. This is weaker than state matching but still prevents the + // passive-CSRF drive-by (attacker can't land a code on a user who didn't + // just click Sign In). + return true + } + /** * Set callback to be called when token is refreshed * This allows the main process to update cookies when tokens change @@ -209,7 +251,13 @@ export class AuthManager { startAuthFlow(mainWindow: BrowserWindow | null): void { const { shell } = require("electron") - let authUrl = `${this.getApiUrl()}/auth/desktop?auto=true` + // Generate a fresh anti-CSRF nonce. If the backend echoes `state` back in + // the callback, we strict-match it. If not, the presence of a recent + // in-flight flow alone gates code acceptance (see verifyAndConsumeAuthState). + const state = randomBytes(32).toString("hex") + this.pendingAuthState = { state, issuedAt: Date.now() } + + let authUrl = `${this.getApiUrl()}/auth/desktop?auto=true&state=${state}` // In dev mode, use localhost callback (we run HTTP server on AUTH_SERVER_PORT) // Also pass the protocol so web knows which deep link to use as fallback diff --git a/src/main/index.ts b/src/main/index.ts index dda864953..83ad366f6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -192,10 +192,19 @@ function handleDeepLink(url: string): void { try { const parsed = new URL(url) - // Handle auth callback: twentyfirst-agents://auth?code=xxx + // Handle auth callback: twentyfirst-agents://auth?code=xxx&state=yyy if (parsed.pathname === "/auth" || parsed.host === "auth") { const code = parsed.searchParams.get("code") + const state = parsed.searchParams.get("state") if (code) { + const verified = + authManager.verifyAndConsumeAuthState(state) + if (!verified) { + console.warn( + "[DeepLink] Rejected /auth code: no in-flight auth flow or state mismatch", + ) + return + } handleAuthCode(code) return } @@ -1024,7 +1033,27 @@ if (gotTheLock) { app.on("before-quit", async () => { console.log("[App] Shutting down...") cancelAllPendingOAuth() - await cleanupGitWatchers() + + // Bound the watcher cleanup so a hung chokidar instance can't block quit. + // 1500ms is enough for well-behaved close handlers; OS will reclaim handles + // if we have to move on without them. + const WATCHER_CLEANUP_TIMEOUT_MS = 1500 + try { + await Promise.race([ + cleanupGitWatchers(), + new Promise((resolve) => + setTimeout(() => { + console.warn( + "[App] cleanupGitWatchers() exceeded timeout; continuing shutdown", + ) + resolve() + }, WATCHER_CLEANUP_TIMEOUT_MS), + ), + ]) + } catch (err) { + console.warn("[App] cleanupGitWatchers() threw during shutdown:", err) + } + await shutdownAnalytics() // Auto-delete sub-chats that were never named and never used (messages = "[]"). diff --git a/src/main/lib/claude-token.ts b/src/main/lib/claude-token.ts index 204c45c56..7ecd33792 100644 --- a/src/main/lib/claude-token.ts +++ b/src/main/lib/claude-token.ts @@ -254,26 +254,35 @@ function getExtendedPath(): string { } /** - * Check if Claude CLI is installed (cross-platform) - * Uses extended PATH to find claude even when running from Finder/Dock + * Resolve the absolute path to the `claude` CLI using an extended PATH. + * Returns null if the binary cannot be found. */ -export function isClaudeCliInstalled(): boolean { +function resolveClaudeCliPath(): string | null { try { - // Use 'where' on Windows, 'which' on Unix-like systems - const command = isWindows() ? 'where claude' : 'which claude'; const fullPath = getExtendedPath(); - - execSync(command, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, PATH: fullPath } - }); - return true; + const result = execSync( + isWindows() ? 'where claude' : 'which claude', + { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, PATH: fullPath }, + } + ); + const firstLine = result.split(/\r?\n/).find((line) => line.trim().length > 0); + return firstLine?.trim() ?? null; } catch { - return false; + return null; } } +/** + * Check if Claude CLI is installed (cross-platform) + * Uses extended PATH to find claude even when running from Finder/Dock + */ +export function isClaudeCliInstalled(): boolean { + return resolveClaudeCliPath() !== null; +} + /** * Run `claude setup-token` to authenticate with Claude * Returns a promise that resolves when the process completes @@ -288,12 +297,22 @@ export function runClaudeSetupToken( onStatus('Starting Claude setup-token...'); const fullPath = getExtendedPath(); + const claudePath = resolveClaudeCliPath(); + + if (!claudePath) { + resolve({ + success: false, + error: 'Claude CLI not found on PATH. Install it and retry.', + }); + return; + } - const child = spawn('claude', ['setup-token'], { + // Spawn the resolved absolute binary directly — no `shell: true`, so + // metacharacters/spaces in PATH or env are treated as literal args. + const child = spawn(claudePath, ['setup-token'], { // Don't use 'inherit' - it causes hang in non-TTY environments // Use 'ignore' for stdin and 'pipe' for stdout/stderr stdio: ['ignore', 'pipe', 'pipe'], - shell: true, env: { ...process.env, PATH: fullPath }, }); diff --git a/src/main/lib/claude/env.ts b/src/main/lib/claude/env.ts index 44d4679aa..23fa6f650 100644 --- a/src/main/lib/claude/env.ts +++ b/src/main/lib/claude/env.ts @@ -1,5 +1,5 @@ import { app } from "electron" -import { execSync } from "node:child_process" +import { execFileSync } from "node:child_process" import fs from "node:fs" import os from "node:os" import path from "node:path" @@ -179,7 +179,10 @@ export function getClaudeShellEnvironment(): Record { const command = `echo -n "${DELIMITER}"; env; echo -n "${DELIMITER}"; exit` try { - const output = execSync(`${shell} -ilc '${command}'`, { + // Use execFileSync with argv array — no shell-string interpolation, so a + // compromised `shell` path can't smuggle additional commands via the outer + // template literal. + const output = execFileSync(shell, ["-ilc", command], { encoding: "utf8", timeout: 5000, env: { diff --git a/src/main/lib/db/index.ts b/src/main/lib/db/index.ts index d76e964ad..8a499dc12 100644 --- a/src/main/lib/db/index.ts +++ b/src/main/lib/db/index.ts @@ -3,7 +3,7 @@ import { drizzle } from "drizzle-orm/better-sqlite3" import { migrate } from "drizzle-orm/better-sqlite3/migrator" import { app } from "electron" import { join } from "path" -import { existsSync, mkdirSync } from "fs" +import { existsSync, mkdirSync, renameSync } from "fs" import * as schema from "./schema" let db: ReturnType> | null = null @@ -37,8 +37,22 @@ function getMigrationsPath(): string { return join(__dirname, "../../drizzle") } +function openConnection(dbPath: string) { + const conn = new Database(dbPath) + conn.pragma("journal_mode = WAL") + // synchronous=NORMAL is safe under WAL and materially reduces fsync load + // during high-frequency writes (e.g., streaming message persistence). + conn.pragma("synchronous = NORMAL") + conn.pragma("foreign_keys = ON") + return conn +} + /** - * Initialize the database with Drizzle ORM + * Initialize the database with Drizzle ORM. + * + * If migrations fail (e.g., corrupted DB, downgrade), the broken file is + * renamed to `agents.db.broken-` and a fresh DB is created so the + * app remains launchable. The broken file is kept for user/support triage. */ export function initDatabase() { if (db) { @@ -48,15 +62,9 @@ export function initDatabase() { const dbPath = getDatabasePath() console.log(`[DB] Initializing database at: ${dbPath}`) - // Create SQLite connection - sqlite = new Database(dbPath) - sqlite.pragma("journal_mode = WAL") - sqlite.pragma("foreign_keys = ON") - - // Create Drizzle instance + sqlite = openConnection(dbPath) db = drizzle(sqlite, { schema }) - // Run migrations const migrationsPath = getMigrationsPath() console.log(`[DB] Running migrations from: ${migrationsPath}`) @@ -65,7 +73,27 @@ export function initDatabase() { console.log("[DB] Migrations completed") } catch (error) { console.error("[DB] Migration error:", error) - throw error + + // Recovery: close the connection, quarantine the file, start fresh. + try { + sqlite?.close() + } catch {} + sqlite = null + db = null + + const brokenPath = `${dbPath}.broken-${Date.now()}` + try { + renameSync(dbPath, brokenPath) + console.warn(`[DB] Quarantined broken DB to: ${brokenPath}`) + } catch (renameErr) { + console.error("[DB] Could not rename broken DB:", renameErr) + throw error + } + + sqlite = openConnection(dbPath) + db = drizzle(sqlite, { schema }) + migrate(db, { migrationsFolder: migrationsPath }) + console.log("[DB] Recovery complete: fresh DB initialized") } return db diff --git a/src/main/lib/db/schema/index.ts b/src/main/lib/db/schema/index.ts index 5fe6ee082..a0f037ad4 100644 --- a/src/main/lib/db/schema/index.ts +++ b/src/main/lib/db/schema/index.ts @@ -53,6 +53,7 @@ export const chats = sqliteTable("chats", { prNumber: integer("pr_number"), }, (table) => [ index("chats_worktree_path_idx").on(table.worktreePath), + index("chats_project_id_idx").on(table.projectId), ]) export const chatsRelations = relations(chats, ({ one, many }) => ({ @@ -86,7 +87,10 @@ export const subChats = sqliteTable("sub_chats", { updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn( () => new Date(), ), -}) +}, (table) => [ + index("sub_chats_chat_id_idx").on(table.chatId), + index("sub_chats_stream_id_idx").on(table.streamId), +]) export const subChatsRelations = relations(subChats, ({ one }) => ({ chat: one(chats, { diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index 73bfd672d..6962fd232 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -860,8 +860,17 @@ export const claudeRouter = router({ // Track if observable is still active (not unsubscribed) let isObservableActive = true - // Helper to safely emit (no-op if already unsubscribed) - const safeEmit = (chunk: UIMessageChunk) => { + // text-delta coalescing: the Claude SDK emits many small delta chunks + // per second during streaming. Sending each one through tRPC+IPC churns + // the renderer. We buffer consecutive same-id deltas and flush on a + // short interval or when a non-delta chunk arrives. + const TEXT_DELTA_FLUSH_MS = 24 + let pendingTextDelta: { type: "text-delta"; id: string; delta: string } | null = null + let pendingTextDeltaTimer: ReturnType | null = null + + // Raw emit that bypasses the text-delta buffer (used by the buffer itself + // and by error paths). Returns false if the observer is closed. + const rawEmit = (chunk: UIMessageChunk): boolean => { if (!isObservableActive) return false try { emit.next(chunk) @@ -872,8 +881,49 @@ export const claudeRouter = router({ } } + const flushPendingTextDelta = (): boolean => { + if (pendingTextDeltaTimer !== null) { + clearTimeout(pendingTextDeltaTimer) + pendingTextDeltaTimer = null + } + if (pendingTextDelta === null) return true + const chunk = pendingTextDelta + pendingTextDelta = null + return rawEmit(chunk as UIMessageChunk) + } + + // Helper to safely emit (no-op if already unsubscribed). + // Coalesces text-delta chunks; flushes any pending delta before + // emitting chunks of other types so ordering stays correct. + const safeEmit = (chunk: UIMessageChunk) => { + if (!isObservableActive) return false + + if (chunk.type === "text-delta") { + const { id, delta } = chunk as { id: string; delta: string } + if (pendingTextDelta && pendingTextDelta.id === id) { + pendingTextDelta.delta += delta + } else { + // Different id → flush the previous buffer first. + if (!flushPendingTextDelta()) return false + pendingTextDelta = { type: "text-delta", id, delta } + } + if (pendingTextDeltaTimer === null) { + pendingTextDeltaTimer = setTimeout( + flushPendingTextDelta, + TEXT_DELTA_FLUSH_MS, + ) + } + return isObservableActive + } + + // Any non-text-delta chunk → flush the buffer first so ordering is preserved. + if (!flushPendingTextDelta()) return false + return rawEmit(chunk) + } + // Helper to safely complete (no-op if already closed) const safeComplete = () => { + flushPendingTextDelta() try { emit.complete() } catch { @@ -2854,6 +2904,11 @@ ${prompt} `[SD] M:CLEANUP sub=${subId} sessionId=${currentSessionId || "none"}`, ) isObservableActive = false // Prevent emit after unsubscribe + if (pendingTextDeltaTimer !== null) { + clearTimeout(pendingTextDeltaTimer) + pendingTextDeltaTimer = null + } + pendingTextDelta = null abortController.abort() activeSessions.delete(input.subChatId) clearPendingApprovals("Session ended.", input.subChatId) diff --git a/src/main/lib/trpc/routers/voice.ts b/src/main/lib/trpc/routers/voice.ts index 63fc8014b..709443275 100644 --- a/src/main/lib/trpc/routers/voice.ts +++ b/src/main/lib/trpc/routers/voice.ts @@ -6,8 +6,24 @@ * For open-source users: requires OPENAI_API_KEY in environment */ -import { execSync } from "node:child_process" +import { execFileSync } from "node:child_process" import os from "node:os" + +// Allowed shell path prefixes — prevents SHELL= command-injection via interpolation. +const SAFE_SHELL_PREFIXES = [ + "/bin/", + "/usr/bin/", + "/usr/local/bin/", + "/opt/homebrew/bin/", +] + +function resolveSafeShell(): string { + const candidate = process.env.SHELL + if (candidate && SAFE_SHELL_PREFIXES.some((p) => candidate.startsWith(p))) { + return candidate + } + return "/bin/zsh" +} import { z } from "zod" import { publicProcedure, router } from "../index" import { getApiUrl } from "../../config" @@ -151,8 +167,8 @@ function getOpenAIApiKey(): string | null { // Try to get from shell environment (for production builds) try { - const shell = process.env.SHELL || "/bin/zsh" - const result = execSync(`${shell} -ilc 'echo $OPENAI_API_KEY'`, { + const shell = resolveSafeShell() + const result = execFileSync(shell, ["-ilc", "echo $OPENAI_API_KEY"], { encoding: "utf8", timeout: 5000, env: {