From c663e68ca23115ac84702ef9f4e6dba05fa7c3ac Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Fri, 22 May 2026 17:00:31 -0600 Subject: [PATCH 1/7] feat: create_worktree MCP tool supports prNumber for PR-review worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets an agent spin up a worktree checked out at a PR's head in one tool call — same path as the UI's "Open PR as worktree" flow. The control server's POST /worktrees handler now accepts `prNumber` and routes to WorktreesFSM.runPendingPR, which fetches refs/pull//head into a local branch named after the PR head (with -pr- suffix if taken). The MCP tool schema documents both paths; either branchName OR prNumber is required, and prNumber wins when both are set. Co-Authored-By: Claude Opus 4.7 (1M context) --- resources/mcp-bridge.js | 39 ++++-- resources/mcp-bridge.test.js | 245 +++++++++++++++++++++++++++++++++++ src/main/control-server.ts | 44 ++++++- src/main/index.ts | 25 ++++ src/main/worktrees-fsm.ts | 9 +- 5 files changed, 342 insertions(+), 20 deletions(-) create mode 100644 resources/mcp-bridge.test.js diff --git a/resources/mcp-bridge.js b/resources/mcp-bridge.js index 7c688b64..fc537054 100644 --- a/resources/mcp-bridge.js +++ b/resources/mcp-bridge.js @@ -75,13 +75,20 @@ const TOOLS = [ { name: 'create_worktree', description: - "Create a new git worktree in a Harness-managed repo. Harness will open a new Claude chat tab inside the new worktree automatically. Defaults to the caller's current repo when repoRoot is omitted.", + "Create a new git worktree in a Harness-managed repo. Either create a brand-new branch (set branchName) OR check out an existing GitHub PR for review (set prNumber). Harness will open a new Claude chat tab inside the new worktree automatically. Defaults to the caller's current repo when repoRoot is omitted.", inputSchema: { type: 'object', properties: { branchName: { type: 'string', - description: 'Name of the new branch to create for the worktree.' + description: + 'Name of the new branch to create for the worktree. Required when not creating from a PR.' + }, + prNumber: { + type: 'integer', + minimum: 1, + description: + 'GitHub PR number to check out for review. When set, Harness fetches refs/pull//head into a local branch named after the PR head (or `-pr-` if taken locally) and opens a worktree against it. Useful for "review this PR" workflows.' }, repoRoot: { type: 'string', @@ -91,15 +98,14 @@ const TOOLS = [ baseBranch: { type: 'string', description: - "Branch to fork the new worktree from. Defaults to the repo's configured base." + "Branch to fork the new worktree from. Defaults to the repo's configured base. Ignored when prNumber is provided." }, initialPrompt: { type: 'string', description: - 'A prompt to automatically send to the Claude chat tab when it opens in the new worktree.' + 'A prompt to automatically send to the Claude chat tab when it opens in the new worktree. Useful for "review this PR for X" or "implement feature Y" prompts.' } - }, - required: ['branchName'] + } } }, { @@ -438,21 +444,26 @@ function filterToolsByPerms(tools, perms) { async function handleToolCall(name, args) { if (name === 'create_worktree') { - if (!args || !args.branchName) throw new Error('branchName is required') + const prNumber = args && args.prNumber + if (!args || (!args.branchName && !prNumber)) { + throw new Error('branchName or prNumber is required') + } + if (prNumber !== undefined && prNumber !== null) { + if (!Number.isInteger(prNumber) || prNumber <= 0) { + throw new Error('prNumber must be a positive integer') + } + } const r = await callControl('POST', '/worktrees', { terminalId: TERMINAL_ID, repoRoot: args.repoRoot, branchName: args.branchName, + prNumber: prNumber, baseBranch: args.baseBranch, initialPrompt: args.initialPrompt }) - return ( - 'Created worktree ' + - r.path + - ' on branch ' + - r.branch + - '. Harness will open a new Claude chat tab in it.' - ) + return prNumber + ? `Created worktree ${r.path} on branch ${r.branch} for PR #${prNumber}. Harness will open a new Claude chat tab in it.` + : `Created worktree ${r.path} on branch ${r.branch}. Harness will open a new Claude chat tab in it.` } if (name === 'list_worktrees') { const q = diff --git a/resources/mcp-bridge.test.js b/resources/mcp-bridge.test.js new file mode 100644 index 00000000..11dacc96 --- /dev/null +++ b/resources/mcp-bridge.test.js @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { createServer } from 'http' +import { spawn } from 'child_process' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const BRIDGE = join(__dirname, 'mcp-bridge.js') + +function startStub(handler) { + return new Promise((resolve) => { + const captured = [] + const server = createServer((req, res) => { + const chunks = [] + req.on('data', (c) => chunks.push(c)) + req.on('end', () => { + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf-8')) : {} + captured.push({ + method: req.method, + url: req.url, + auth: req.headers.authorization, + terminalId: req.headers['x-harness-terminal-id'], + body + }) + try { + handler(req, body, res, captured) + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: String(e) })) + } + }) + }) + server.listen(0, '127.0.0.1', () => { + const port = server.address().port + resolve({ port, captured, close: () => new Promise((r) => server.close(() => r())) }) + }) + }) +} + +function spawnBridge(port, token) { + const proc = spawn(process.execPath, [BRIDGE], { + env: { + ...process.env, + HARNESS_PORT: String(port), + HARNESS_TOKEN: token, + HARNESS_TERMINAL_ID: 'test-terminal' + }, + stdio: ['pipe', 'pipe', 'pipe'] + }) + let stdoutBuf = '' + const responses = [] + const pending = [] + proc.stdout.on('data', (chunk) => { + stdoutBuf += chunk.toString() + let idx + while ((idx = stdoutBuf.indexOf('\n')) !== -1) { + const line = stdoutBuf.slice(0, idx) + stdoutBuf = stdoutBuf.slice(idx + 1) + if (!line.trim()) continue + try { + const msg = JSON.parse(line) + if (pending.length) { + pending.shift()(msg) + } else { + responses.push(msg) + } + } catch { + /* ignore non-JSON */ + } + } + }) + proc.stderr.on('data', () => { + /* swallow */ + }) + function send(msg) { + proc.stdin.write(JSON.stringify(msg) + '\n') + } + function next() { + return new Promise((resolve) => { + if (responses.length) resolve(responses.shift()) + else pending.push(resolve) + }) + } + function kill() { + proc.kill() + return new Promise((resolve) => proc.once('exit', () => resolve())) + } + return { send, next, kill, proc } +} + +describe('mcp-bridge create_worktree', () => { + let stub + let bridge + + afterEach(async () => { + if (bridge) await bridge.kill() + if (stub) await stub.close() + }) + + it('forwards prNumber to POST /worktrees and reports PR # in result text', async () => { + stub = await startStub((req, body, res) => { + if (req.url === '/scope') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ scope: null, browser: { enabled: true, mode: 'full' } })) + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ path: '/tmp/wt', branch: 'feature/pr-head' })) + }) + bridge = spawnBridge(stub.port, 'secret-token') + + bridge.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + await bridge.next() + bridge.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'create_worktree', + arguments: { prNumber: 47, initialPrompt: 'review please' } + } + }) + const response = await bridge.next() + + expect(response.result.isError).toBeFalsy() + expect(response.result.content[0].text).toContain('PR #47') + expect(response.result.content[0].text).toContain('/tmp/wt') + expect(response.result.content[0].text).toContain('feature/pr-head') + + const postCall = stub.captured.find((c) => c.method === 'POST' && c.url === '/worktrees') + expect(postCall).toBeDefined() + expect(postCall.auth).toBe('Bearer secret-token') + expect(postCall.terminalId).toBe('test-terminal') + expect(postCall.body.prNumber).toBe(47) + expect(postCall.body.initialPrompt).toBe('review please') + }) + + it('still supports the new-branch flow when prNumber is absent', async () => { + stub = await startStub((req, body, res) => { + if (req.url === '/scope') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ scope: null, browser: { enabled: true, mode: 'full' } })) + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ path: '/tmp/wt-new', branch: 'mybranch' })) + }) + bridge = spawnBridge(stub.port, 'tok') + + bridge.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + await bridge.next() + bridge.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'create_worktree', + arguments: { branchName: 'mybranch' } + } + }) + const response = await bridge.next() + + expect(response.result.isError).toBeFalsy() + const text = response.result.content[0].text + expect(text).toContain('/tmp/wt-new') + expect(text).toContain('mybranch') + expect(text).not.toContain('PR #') + + const postCall = stub.captured.find((c) => c.method === 'POST' && c.url === '/worktrees') + expect(postCall.body.branchName).toBe('mybranch') + expect(postCall.body.prNumber).toBeUndefined() + }) + + it('returns an error when neither branchName nor prNumber is provided', async () => { + stub = await startStub((req, body, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ scope: null, browser: { enabled: true, mode: 'full' } })) + }) + bridge = spawnBridge(stub.port, 'tok') + + bridge.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + await bridge.next() + bridge.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'create_worktree', arguments: {} } + }) + const response = await bridge.next() + + expect(response.result.isError).toBe(true) + expect(response.result.content[0].text).toMatch(/branchName or prNumber/) + }) + + it('rejects non-integer prNumber locally without hitting the server', async () => { + stub = await startStub((req, body, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ scope: null, browser: { enabled: true, mode: 'full' } })) + }) + bridge = spawnBridge(stub.port, 'tok') + + bridge.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + await bridge.next() + bridge.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'create_worktree', arguments: { prNumber: -3 } } + }) + const response = await bridge.next() + + expect(response.result.isError).toBe(true) + expect(response.result.content[0].text).toMatch(/positive integer/) + const postCall = stub.captured.find((c) => c.method === 'POST' && c.url === '/worktrees') + expect(postCall).toBeUndefined() + }) + + it('surfaces server-side PR failures back to the caller', async () => { + stub = await startStub((req, body, res) => { + if (req.url === '/scope') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + return res.end(JSON.stringify({ scope: null, browser: { enabled: true, mode: 'full' } })) + } + res.writeHead(422, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: "Couldn't fetch PR #99999 from GitHub" })) + }) + bridge = spawnBridge(stub.port, 'tok') + + bridge.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + await bridge.next() + bridge.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'create_worktree', + arguments: { prNumber: 99999 } + } + }) + const response = await bridge.next() + + expect(response.result.isError).toBe(true) + expect(response.result.content[0].text).toMatch(/HTTP 422/) + expect(response.result.content[0].text).toMatch(/99999/) + }) +}) diff --git a/src/main/control-server.ts b/src/main/control-server.ts index 45ad6257..a27556d8 100644 --- a/src/main/control-server.ts +++ b/src/main/control-server.ts @@ -1,5 +1,5 @@ import { createServer, IncomingMessage, ServerResponse } from 'http' -import { randomBytes } from 'crypto' +import { randomBytes, randomUUID } from 'crypto' import { addWorktree, listWorktrees, defaultWorktreeDir, WorktreeInfo } from './worktree' import { log } from './debug' @@ -90,6 +90,16 @@ export interface ControlServerDeps { getWorktreeBase: () => 'remote' | 'local' broadcast: (channel: string, ...args: unknown[]) => void runWorktreeSetup: (ctx: { repoRoot: string; worktreePath: string; branch: string }) => Promise + /** Drive the full PR-creation FSM (fetch PR metadata, fetch refs/pull//head, + * create the worktree, run setup, fire panes init + PR poller refresh) and + * return the new worktree's path + branch. Host wires this to + * `worktreesFSM.runPendingPR` plus a renderer focus broadcast. */ + runPendingPRWorktree: (params: { + id: string + repoRoot: string + prNumber: number + initialPrompt?: string + }) => Promise<{ ok: true; path: string; branch: string } | { ok: false; error: string }> /** Returns the caller's current scope, or null if the terminal is not * associated with any known worktree (e.g. the worktree was deleted). */ resolveCallerScope: (terminalId: string) => CallerScope | null @@ -222,9 +232,38 @@ async function handleRequest( } } + const rawPrNumber = body.prNumber + let prNumber: number | undefined + if (rawPrNumber !== undefined && rawPrNumber !== null && rawPrNumber !== '') { + const n = typeof rawPrNumber === 'number' ? rawPrNumber : Number(rawPrNumber) + if (!Number.isInteger(n) || n <= 0) { + return sendJson(res, 400, { error: 'prNumber must be a positive integer' }) + } + prNumber = n + } + const branchName = String(body.branchName || '').trim() + const initialPrompt = typeof body.initialPrompt === 'string' ? body.initialPrompt : undefined + + if (prNumber !== undefined) { + if (branchName) { + log('control', `prNumber=${prNumber} provided — ignoring branchName=${branchName}`) + } + const result = await deps.runPendingPRWorktree({ + id: randomUUID(), + repoRoot, + prNumber, + initialPrompt + }) + if (!result.ok) { + const status = /couldn't fetch pr|not found|404/i.test(result.error) ? 422 : 502 + return sendJson(res, status, { error: result.error }) + } + return sendJson(res, 200, { path: result.path, branch: result.branch }) + } + if (!branchName) { - return sendJson(res, 400, { error: 'branchName required' }) + return sendJson(res, 400, { error: 'branchName or prNumber required' }) } const wtDir = defaultWorktreeDir(repoRoot) const mode = deps.getWorktreeBase() @@ -237,7 +276,6 @@ async function handleRequest( // spawned by ensureInitialized still sees shared settings. deps.runWorktreeSetup({ repoRoot, worktreePath: created.path, branch: created.branch }) .catch((err) => log('control', `setup script failed: ${err instanceof Error ? err.message : String(err)}`)) - const initialPrompt = typeof body.initialPrompt === 'string' ? body.initialPrompt : undefined deps.broadcast('worktrees:externalCreate', { repoRoot, worktree: created, initialPrompt }) return sendJson(res, 200, created) } diff --git a/src/main/index.ts b/src/main/index.ts index 7dfa5f53..dd3ab2d3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3087,6 +3087,31 @@ async function runBoot(): Promise { } }, runWorktreeSetup: (ctx) => worktreesFSM.runWorktreeSetup(ctx), + runPendingPRWorktree: async (params) => { + // The FSM already handles ensureInitialized (with the prompt) + PR poller + // refresh via onWorktreeCreated. We just look up the resolved branch + // from the freshly-refreshed store list and emit the focus broadcast + // directly so the heavy ensureInitialized/refreshList work doesn't + // run a second time through the broadcast path. + // 'setup-failed' still means the worktree exists on disk — surface + // it to the MCP caller as success so the agent can recover. + const outcome = await worktreesFSM.runPendingPR(params) + if (outcome.outcome === 'error') { + return { ok: false, error: outcome.error } + } + const found = store + .getSnapshot() + .state.worktrees.list.find((w) => w.path === outcome.createdPath) + if (!found) { + return { ok: false, error: `created worktree at ${outcome.createdPath} but couldn't resolve its branch` } + } + broadcastToAllWindows('worktrees:externalCreate', { + repoRoot: params.repoRoot, + worktree: found, + initialPrompt: params.initialPrompt + }) + return { ok: true, path: found.path, branch: found.branch } + }, broadcast: (channel, payload) => { if (channel === 'worktrees:externalCreate') { // Seed panes with the initial prompt BEFORE refreshList — the diff --git a/src/main/worktrees-fsm.ts b/src/main/worktrees-fsm.ts index 3726aa7a..68df9d8f 100644 --- a/src/main/worktrees-fsm.ts +++ b/src/main/worktrees-fsm.ts @@ -154,15 +154,17 @@ export class WorktreesFSM { id: string repoRoot: string prNumber: number + initialPrompt?: string }): Promise { - const { id, repoRoot, prNumber } = params + const { id, repoRoot, prNumber, initialPrompt } = params // Show *something* while we go ask GitHub for the head ref name. let branchName = `pr-${prNumber}` const pending: PendingWorktree = { id, repoRoot, branchName, - status: 'creating' + status: 'creating', + initialPrompt } this.store.dispatch({ type: 'worktrees/pendingAdded', payload: pending }) @@ -188,7 +190,8 @@ export class WorktreesFSM { return await this.finishCreate({ id, repoRoot, - created + created, + initialPrompt }) } catch (err) { const message = err instanceof Error ? err.message : String(err) From 170b9fa532d6fcb43279ef6d7dd22cff0824e190 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Fri, 22 May 2026 22:14:10 -0600 Subject: [PATCH 2/7] feat: configurable PR review prompt with per-creation textarea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `prReviewPrompt` setting (default ships with a sensible review prompt) and exposes it three ways: - Settings → Worktrees → PR review prompt: textarea + save/reset to manage the global default. - New Worktree → Open PR section: textarea pre-filled with the default; edits are one-shot for that creation and don't affect the global value. - MCP `create_worktree` with `prNumber`: an omitted `initialPrompt` falls back to the configured default. Pass an empty string to explicitly suppress. Threading: extends `runPendingPR` (FSM) + `runPendingPRWorktree` (renderer-facing backend method) with `initialPrompt?`, persists the setting in `config.json`, seeds it through `buildInitialAppState`. Co-Authored-By: Claude Opus 4.7 (1M context) --- resources/mcp-bridge.js | 2 +- src/main/build-initial-state.ts | 6 ++- src/main/control-server.ts | 10 +++- src/main/index.ts | 22 +++++++- src/main/persistence.ts | 3 ++ src/renderer/build-backend.ts | 2 + src/renderer/components/NewWorktreeScreen.tsx | 45 ++++++++++++---- src/renderer/components/Settings.tsx | 54 +++++++++++++++++++ src/renderer/hooks/useWorktreeHandlers.ts | 5 +- src/renderer/types.ts | 2 + src/shared/state/settings.test.ts | 9 ++++ src/shared/state/settings.ts | 17 +++++- 12 files changed, 158 insertions(+), 19 deletions(-) diff --git a/resources/mcp-bridge.js b/resources/mcp-bridge.js index fc537054..1703fcd2 100644 --- a/resources/mcp-bridge.js +++ b/resources/mcp-bridge.js @@ -103,7 +103,7 @@ const TOOLS = [ initialPrompt: { type: 'string', description: - 'A prompt to automatically send to the Claude chat tab when it opens in the new worktree. Useful for "review this PR for X" or "implement feature Y" prompts.' + 'A prompt to automatically send to the Claude chat tab when it opens in the new worktree. Useful for "review this PR for X" or "implement feature Y" prompts. When prNumber is set and this is omitted, Harness uses the configured PR review prompt (Settings → Worktrees → PR review prompt). Pass an empty string to explicitly suppress any kickoff prompt on the PR path.' } } } diff --git a/src/main/build-initial-state.ts b/src/main/build-initial-state.ts index c657f21d..f8e2adc4 100644 --- a/src/main/build-initial-state.ts +++ b/src/main/build-initial-state.ts @@ -13,7 +13,8 @@ import { initialSnooze } from '../shared/state/snooze' import { initialSettings, DEFAULT_LIGHT_THEME, - DEFAULT_DARK_THEME + DEFAULT_DARK_THEME, + DEFAULT_PR_REVIEW_PROMPT } from '../shared/state/settings' import { DEFAULT_CLAUDE_COMMAND, @@ -103,7 +104,8 @@ export function buildInitialAppState( ? Math.floor(config.autoSleepMinutes) : 30, snoozeDefaultDays: Math.max(1, Math.floor(config.snoozeDefaultDays ?? 7)), - expandedDiagnosticLoggingEnabled: config.expandedDiagnosticLoggingEnabled === true + expandedDiagnosticLoggingEnabled: config.expandedDiagnosticLoggingEnabled === true, + prReviewPrompt: config.prReviewPrompt || DEFAULT_PR_REVIEW_PROMPT } } } diff --git a/src/main/control-server.ts b/src/main/control-server.ts index a27556d8..56681675 100644 --- a/src/main/control-server.ts +++ b/src/main/control-server.ts @@ -88,6 +88,10 @@ export interface BrowserPerms { export interface ControlServerDeps { getRepoRoots: () => string[] getWorktreeBase: () => 'remote' | 'local' + /** Default prompt used when an MCP `create_worktree` call provides + * `prNumber` but no explicit `initialPrompt`. Resolved per-request so + * Settings edits take effect mid-session. */ + getPrReviewPrompt: () => string broadcast: (channel: string, ...args: unknown[]) => void runWorktreeSetup: (ctx: { repoRoot: string; worktreePath: string; branch: string }) => Promise /** Drive the full PR-creation FSM (fetch PR metadata, fetch refs/pull//head, @@ -249,11 +253,15 @@ async function handleRequest( if (branchName) { log('control', `prNumber=${prNumber} provided — ignoring branchName=${branchName}`) } + // No explicit prompt → fall back to the configured review-prompt default. + // Empty-string prompts ('') are honored as "no prompt" so callers can + // opt out explicitly. + const promptForPR = initialPrompt === undefined ? deps.getPrReviewPrompt() : initialPrompt const result = await deps.runPendingPRWorktree({ id: randomUUID(), repoRoot, prNumber, - initialPrompt + initialPrompt: promptForPR || undefined }) if (!result.ok) { const status = /couldn't fetch pr|not found|404/i.test(result.error) ? 422 : 502 diff --git a/src/main/index.ts b/src/main/index.ts index dd3ab2d3..02892e68 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -68,7 +68,11 @@ import { registerRepoRoot } from './repo-roots' import type { AddRepoResult } from '../shared/repo-pick' import { isWorktreeMerged } from '../shared/state/prs' import { MAX_WAKE } from '../shared/state/snooze' -import { DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME } from '../shared/state/settings' +import { + DEFAULT_LIGHT_THEME, + DEFAULT_DARK_THEME, + DEFAULT_PR_REVIEW_PROMPT +} from '../shared/state/settings' import { watchStatusDir } from './hooks' import { getAgent, type AgentKind } from './agents' import { buildClaudeLaunchSettings } from './claude-launch' @@ -1576,6 +1580,21 @@ function registerIpcHandlers(): void { return true }) + transport.onRequest('config:setPrReviewPrompt', (_ctx, prompt: string) => { + const trimmed = prompt.trim() + if (!trimmed || trimmed === DEFAULT_PR_REVIEW_PROMPT) { + delete config.prReviewPrompt + } else { + config.prReviewPrompt = prompt + } + saveConfig(config) + store.dispatch({ + type: 'settings/prReviewPromptChanged', + payload: config.prReviewPrompt || DEFAULT_PR_REVIEW_PROMPT + }) + return true + }) + transport.onRequest('config:setHarnessMcpEnabled', (_ctx, enabled: boolean) => { if (enabled) { delete config.harnessMcpEnabled @@ -2949,6 +2968,7 @@ async function runBoot(): Promise { startControlServer({ getRepoRoots: () => config.repoRoots, getWorktreeBase: () => config.worktreeBase || DEFAULT_WORKTREE_BASE, + getPrReviewPrompt: () => config.prReviewPrompt || DEFAULT_PR_REVIEW_PROMPT, resolveCallerScope, getBrowserPerms: () => ({ enabled: config.browserToolsEnabled !== false, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 32220712..fe998208 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -151,6 +151,9 @@ export interface Config { harnessSystemPromptEnabled?: boolean harnessSystemPrompt?: string harnessSystemPromptMain?: string + // Default kickoff prompt for "Open PR as worktree" / MCP create_worktree + // with prNumber. Absent = use the bundled DEFAULT_PR_REVIEW_PROMPT. + prReviewPrompt?: string // When false, Claude sessions spawn without CLAUDE_CODE_NO_FLICKER=1, so they // use the inline (non-fullscreen) TUI mode. Default is enabled (undefined/true). claudeTuiFullscreen?: boolean diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index 1e7e38a4..6e84c898 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -106,6 +106,7 @@ export function buildBackend( id: string repoRoot: string prNumber: number + initialPrompt?: string }) => req('worktrees:runPendingPR', params), retryPendingWorktree: (id: string) => req('worktrees:retryPending', id), dismissPendingWorktree: (id: string) => req('worktrees:dismissPending', id), @@ -247,6 +248,7 @@ export function buildBackend( setHarnessSystemPrompt: (prompt: string) => req('config:setHarnessSystemPrompt', prompt), setHarnessSystemPromptMain: (prompt: string) => req('config:setHarnessSystemPromptMain', prompt), + setPrReviewPrompt: (prompt: string) => req('config:setPrReviewPrompt', prompt), prepareMcpForTerminal: (terminalId: string) => req('mcp:prepareForTerminal', terminalId), onWorktreesExternalCreate: ( diff --git a/src/renderer/components/NewWorktreeScreen.tsx b/src/renderer/components/NewWorktreeScreen.tsx index 1fc6a597..2beb10a4 100644 --- a/src/renderer/components/NewWorktreeScreen.tsx +++ b/src/renderer/components/NewWorktreeScreen.tsx @@ -4,11 +4,12 @@ import iconUrl from '../../../resources/icon.png' import { sanitizeBranchInput, isValidBranchName } from '../branch-name' import { RepoIcon } from './RepoIcon' import { useBackend } from '../backend' +import { useSettings } from '../store' import type { PRSummary } from '../types' interface NewWorktreeScreenProps { onSubmit: (repoRoot: string, branchName: string, initialPrompt: string, teleportSessionId?: string) => Promise - onPRSubmit: (repoRoot: string, prNumber: number) => Promise + onPRSubmit: (repoRoot: string, prNumber: number, initialPrompt: string) => Promise onCancel: () => void repoRoots: string[] /** Repo to pre-select in the picker. Usually the repo of the currently active worktree. */ @@ -76,6 +77,8 @@ export function NewWorktreeScreen({ onSubmit, onPRSubmit, onCancel, repoRoots, d ) const [branch, setBranch] = useState('') const [prompt, setPrompt] = useState('') + const settings = useSettings() + const [reviewPrompt, setReviewPrompt] = useState(settings.prReviewPrompt) const [teleportInput, setTeleportInput] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) @@ -143,13 +146,13 @@ export function NewWorktreeScreen({ onSubmit, onPRSubmit, onCancel, repoRoots, d setPrClickPending(prNumber) setError(null) try { - await onPRSubmit(selectedRepo, prNumber) + await onPRSubmit(selectedRepo, prNumber, reviewPrompt.trim()) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to open PR') setPrClickPending(null) } }, - [onPRSubmit, prClickPending, selectedRepo] + [onPRSubmit, prClickPending, selectedRepo, reviewPrompt] ) const handleBranchChange = useCallback((e: React.ChangeEvent) => { @@ -402,14 +405,34 @@ export function NewWorktreeScreen({ onSubmit, onPRSubmit, onCancel, repoRoots, d )} {mode === 'pr' && ( - + <> +