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: {