From 93996b14ebea2d4850c36318b18c3a8369cf1f35 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Sat, 11 Apr 2026 10:00:24 +0800 Subject: [PATCH 1/2] fix: auto-commit worktree changes and validate commits before PR creation (fixes #1928) When the agent writes files to the worktree but does not call `git commit`, the subsequent `gh pr create` fails with a cryptic GraphQL error: 'Head sha can\'t be blank, Base sha can\'t be blank, No commits between main and '. Fix: - Auto-stage and commit any uncommitted changes in the worktree before pushing (handles agents that skip the commit step) - After pushing, verify at least one commit exists ahead of the base branch and return a clear, actionable error message if none are found --- .../src/main/ai/runners/github/pr-creator.ts | 91 +++++++++++++++++-- 1 file changed, 83 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/ai/runners/github/pr-creator.ts b/apps/desktop/src/main/ai/runners/github/pr-creator.ts index e42dbb2870..5e8332ffc1 100644 --- a/apps/desktop/src/main/ai/runners/github/pr-creator.ts +++ b/apps/desktop/src/main/ai/runners/github/pr-creator.ts @@ -208,6 +208,56 @@ Write a professional PR description. Output ONLY the Markdown body — no preamb } } +// ============================================================================= +// Auto-Commit Uncommitted Changes +// ============================================================================= + +/** + * Stage and commit any uncommitted changes in the worktree. + * Called before pushing to ensure the branch has commits to push. + * Returns an error string on failure, or undefined on success (or no changes). + */ +function autoCommitWorktreeChanges( + worktreePath: string, + gitPath: string, + specId: string, +): string | undefined { + try { + // Check for uncommitted changes (staged or unstaged, tracked or untracked) + const status = execFileSync( + gitPath, + ['status', '--porcelain'], + { cwd: worktreePath, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + + if (!status) { + // No uncommitted changes — nothing to do + return undefined; + } + + // Stage all changes + execFileSync( + gitPath, + ['add', '.'], + { cwd: worktreePath, encoding: 'utf-8', stdio: 'pipe' }, + ); + + // Commit with a descriptive message + execFileSync( + gitPath, + ['commit', '-m', `auto-claude: implement ${specId}`], + { cwd: worktreePath, encoding: 'utf-8', stdio: 'pipe' }, + ); + + return undefined; + } catch (err: unknown) { + const stderr = err instanceof Error && 'stderr' in err + ? String((err as NodeJS.ErrnoException & { stderr?: string }).stderr) + : String(err); + return stderr || 'Auto-commit failed'; + } +} + // ============================================================================= // Push Branch // ============================================================================= @@ -296,7 +346,17 @@ export async function createPR(config: CreatePRConfig): Promise thinkingLevel = 'low', } = config; - // Step 1: Push the branch to origin + // Strip remote prefix from base branch once — used in all steps below + const effectiveBase = baseBranch.startsWith('origin/') + ? baseBranch.slice('origin/'.length) + : baseBranch; + + // Step 1a: Auto-commit any uncommitted changes in the worktree before pushing. + // This handles the case where the agent wrote files but didn't run `git commit`. + // A failure here is non-fatal — we still attempt the push in case prior commits exist. + autoCommitWorktreeChanges(worktreePath, gitPath, specId); + + // Step 1b: Push the branch to origin const pushError = pushBranch(worktreePath, gitPath, branchName); if (pushError) { // If it looks like the branch is already up-to-date, don't bail @@ -307,6 +367,26 @@ export async function createPR(config: CreatePRConfig): Promise } } + // Step 1c: Verify there is at least one commit ahead of the base branch. + // Without this check, `gh pr create` fails with a confusing GraphQL error. + try { + const commitCount = execFileSync( + gitPath, + ['rev-list', '--count', `origin/${effectiveBase}..HEAD`], + { cwd: worktreePath, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + if (commitCount === '0') { + return { + success: false, + error: `No commits found between '${effectiveBase}' and '${branchName}'. ` + + 'The agent may not have committed any changes. ' + + 'Please verify the implementation completed successfully before creating a PR.', + }; + } + } catch { + // Unable to count commits — proceed and let `gh pr create` surface the error + } + // Step 2: Gather context for AI description const { diffSummary, commitLog } = gatherPRContext(worktreePath, gitPath, baseBranch); @@ -324,12 +404,7 @@ export async function createPR(config: CreatePRConfig): Promise const prBody = aiBody || extractSpecSummary(projectDir, specId); - // Step 4: Strip remote prefix from base branch if present - const effectiveBase = baseBranch.startsWith('origin/') - ? baseBranch.slice('origin/'.length) - : baseBranch; - - // Step 5: Build gh pr create command + // Step 4: Build gh pr create command const ghArgs = [ 'pr', 'create', '--base', effectiveBase, @@ -342,7 +417,7 @@ export async function createPR(config: CreatePRConfig): Promise ghArgs.push('--draft'); } - // Step 6: Execute gh pr create with retry on network errors + // Step 5: Execute gh pr create with retry on network errors for (let attempt = 0; attempt < 3; attempt++) { try { const output = execFileSync(ghPath, ghArgs, { From 5fda3e6a72b74831e525ed2ee6b6e52e69a354cf Mon Sep 17 00:00:00 2001 From: octo-patch Date: Sun, 12 Apr 2026 10:06:33 +0800 Subject: [PATCH 2/2] fix(terminal): use Electron clipboard IPC for paste on Windows (fixes #1911) On Windows, navigator.clipboard.readText() in the Electron renderer may be silently denied due to the clipboard-read permission not being granted. This caused Ctrl+V in the agent terminal to briefly show pasted text and then clear it, with no feedback to the user. Add a TERMINAL_READ_CLIPBOARD IPC channel that reads clipboard text from the main process using Electron's clipboard module, which has full clipboard access without renderer permission restrictions. The renderer falls back to navigator.clipboard.readText() if the IPC call fails. --- .../src/main/ipc-handlers/terminal-handlers.ts | 10 +++++++++- apps/desktop/src/preload/api/terminal-api.ts | 5 +++++ .../renderer/components/terminal/useXterm.ts | 18 +++++++++++++++--- apps/desktop/src/shared/constants/ipc.ts | 1 + 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/ipc-handlers/terminal-handlers.ts b/apps/desktop/src/main/ipc-handlers/terminal-handlers.ts index e1cb0d3fae..f88774bd13 100644 --- a/apps/desktop/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/terminal-handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron'; +import { ipcMain, clipboard } from 'electron'; import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'; import { IPC_CHANNELS } from '../../shared/constants'; import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSettings, ClaudeUsageSnapshot, AllProfilesUsage } from '../../shared/types'; @@ -730,6 +730,14 @@ export function registerTerminalHandlers( } } ); + + // Read clipboard text via main process + // navigator.clipboard.readText() in the renderer requires the "clipboard-read" permission + // which can be silently denied on Windows, causing paste to fail. Using Electron's + // clipboard module from the main process bypasses this permission requirement. + ipcMain.handle(IPC_CHANNELS.TERMINAL_READ_CLIPBOARD, (): string => { + return clipboard.readText(); + }); } /** diff --git a/apps/desktop/src/preload/api/terminal-api.ts b/apps/desktop/src/preload/api/terminal-api.ts index 3cf2557603..1af90aaff5 100644 --- a/apps/desktop/src/preload/api/terminal-api.ts +++ b/apps/desktop/src/preload/api/terminal-api.ts @@ -65,6 +65,8 @@ export interface TerminalAPI { projectPath: string, orders: Array<{ terminalId: string; displayOrder: number }> ) => Promise; + /** Read clipboard text via main process (avoids renderer permission issues on Windows) */ + readClipboardText: () => Promise; // Terminal Worktree Operations (isolated development) createTerminalWorktree: (request: CreateTerminalWorktreeRequest) => Promise; @@ -197,6 +199,9 @@ export const createTerminalAPI = (): TerminalAPI => ({ ): Promise => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_UPDATE_DISPLAY_ORDERS, projectPath, orders), + readClipboardText: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_READ_CLIPBOARD), + // Terminal Worktree Operations (isolated development) createTerminalWorktree: (request: CreateTerminalWorktreeRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_CREATE, request), diff --git a/apps/desktop/src/renderer/components/terminal/useXterm.ts b/apps/desktop/src/renderer/components/terminal/useXterm.ts index 2f43641b94..e145fd773f 100644 --- a/apps/desktop/src/renderer/components/terminal/useXterm.ts +++ b/apps/desktop/src/renderer/components/terminal/useXterm.ts @@ -169,7 +169,10 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // Cap paste size to prevent GPU/memory pressure from extremely large clipboard contents. const MAX_PASTE_BYTES = 1_048_576; // 1 MB const handlePasteFromClipboard = (): void => { - navigator.clipboard.readText() + // Use Electron's main-process clipboard API via IPC, which is reliable on all + // platforms including Windows (where navigator.clipboard.readText() may silently + // fail due to renderer permission restrictions, causing paste to appear and vanish). + window.electronAPI.readClipboardText() .then((text) => { if (text) { if (text.length > MAX_PASTE_BYTES) { @@ -180,8 +183,17 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea } } }) - .catch((err) => { - console.error('[useXterm] Failed to read clipboard:', err); + .catch(() => { + // Fall back to navigator.clipboard if IPC unavailable + navigator.clipboard.readText() + .then((text) => { + if (text) { + xterm.paste(text.length > MAX_PASTE_BYTES ? text.slice(0, MAX_PASTE_BYTES) : text); + } + }) + .catch((err) => { + console.error('[useXterm] Failed to read clipboard:', err); + }); }); }; diff --git a/apps/desktop/src/shared/constants/ipc.ts b/apps/desktop/src/shared/constants/ipc.ts index 80e500b0e5..bdf1546bbc 100644 --- a/apps/desktop/src/shared/constants/ipc.ts +++ b/apps/desktop/src/shared/constants/ipc.ts @@ -88,6 +88,7 @@ export const IPC_CHANNELS = { TERMINAL_RESTORE_FROM_DATE: 'terminal:restoreFromDate', TERMINAL_CHECK_PTY_ALIVE: 'terminal:checkPtyAlive', TERMINAL_UPDATE_DISPLAY_ORDERS: 'terminal:updateDisplayOrders', // Persist terminal display order after drag-drop reorder + TERMINAL_READ_CLIPBOARD: 'terminal:readClipboard', // Read clipboard text via main process (avoids renderer permission issues on Windows) // Terminal worktree operations (isolated development in worktrees) TERMINAL_WORKTREE_CREATE: 'terminal:worktreeCreate',