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' && (
-
+ <>
+
+
+ >
)}
{error && (
diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx
index 4dc1944d..8ab3fcb3 100644
--- a/src/renderer/components/Settings.tsx
+++ b/src/renderer/components/Settings.tsx
@@ -189,6 +189,7 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:
harnessSystemPromptEnabled,
harnessSystemPrompt,
harnessSystemPromptMain,
+ prReviewPrompt,
claudeTuiFullscreen,
browserToolsEnabled,
browserToolsMode,
@@ -252,6 +253,10 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:
useEffect(() => { setSystemPromptMainDraft(harnessSystemPromptMain) }, [harnessSystemPromptMain])
const [systemPromptSaveResult, setSystemPromptSaveResult] = useState<{ ok: boolean; message: string } | null>(null)
+ const [prReviewPromptDraft, setPrReviewPromptDraft] = useState(prReviewPrompt)
+ useEffect(() => { setPrReviewPromptDraft(prReviewPrompt) }, [prReviewPrompt])
+ const [prReviewPromptSaveResult, setPrReviewPromptSaveResult] = useState<{ ok: boolean; message: string } | null>(null)
+
const [defaultTerminalFontFamily, setDefaultTerminalFontFamily] = useState('')
const [availableEditors, setAvailableEditors] = useState<{ id: string; name: string }[]>([])
const [scriptsSaveResult, setScriptsSaveResult] = useState<{ ok: boolean; message: string } | null>(null)
@@ -666,6 +671,18 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:
setTimeout(() => setSystemPromptSaveResult(null), 2000)
}, [])
+ const handleSavePrReviewPrompt = useCallback(async () => {
+ await backend.setPrReviewPrompt(prReviewPromptDraft)
+ setPrReviewPromptSaveResult({ ok: true, message: 'Saved' })
+ setTimeout(() => setPrReviewPromptSaveResult(null), 2000)
+ }, [prReviewPromptDraft])
+
+ const handleResetPrReviewPrompt = useCallback(async () => {
+ await backend.setPrReviewPrompt('')
+ setPrReviewPromptSaveResult({ ok: true, message: 'Reset to default' })
+ setTimeout(() => setPrReviewPromptSaveResult(null), 2000)
+ }, [])
+
const effectiveClaudeCommand = claudeCommandDraft.trim() || defaultClaudeCommand
const modelPart = claudeModel && !effectiveClaudeCommand.includes('--model') ? ` --model ${claudeModel}` : ''
const mcpPart = harnessMcpEnabled ? ' --mcp-config ' : ''
@@ -1586,6 +1603,43 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:
Controls how new worktrees are created from the sidebar.
+
PR review prompt
+
+
+ Default kickoff prompt sent to Claude when you open a PR as a worktree (or when the MCP{' '}
+ create_worktree tool is invoked with{' '}
+ prNumber and no explicit prompt). You can
+ edit the prompt per-PR from the New Worktree screen.
+
+
+
{repoList.length > 0 && (
diff --git a/src/renderer/hooks/useWorktreeHandlers.ts b/src/renderer/hooks/useWorktreeHandlers.ts
index 64dbcb85..e8219771 100644
--- a/src/renderer/hooks/useWorktreeHandlers.ts
+++ b/src/renderer/hooks/useWorktreeHandlers.ts
@@ -188,7 +188,7 @@ export function useWorktreeHandlers(args: UseWorktreeHandlersArgs) {
)
const handleSubmitNewPRWorktree = useCallback(
- async (repoRoot: string, prNumber: number) => {
+ async (repoRoot: string, prNumber: number, initialPrompt: string) => {
const id = `pending:${crypto.randomUUID()}`
setActiveWorktreeId(id)
setShowNewWorktree(false)
@@ -196,7 +196,8 @@ export function useWorktreeHandlers(args: UseWorktreeHandlersArgs) {
const result = await backend.runPendingPRWorktree({
id,
repoRoot,
- prNumber
+ prNumber,
+ initialPrompt: initialPrompt || undefined
})
if (result.outcome === 'success') {
diff --git a/src/renderer/types.ts b/src/renderer/types.ts
index ce649bea..3c867e43 100644
--- a/src/renderer/types.ts
+++ b/src/renderer/types.ts
@@ -178,6 +178,7 @@ export interface ElectronAPI {
id: string
repoRoot: string
prNumber: number
+ initialPrompt?: string
}): Promise<
| { id: string; outcome: 'success'; createdPath: string }
| { id: string; outcome: 'setup-failed'; createdPath: string }
@@ -303,6 +304,7 @@ export interface ElectronAPI {
setHarnessSystemPromptEnabled(enabled: boolean): Promise
setHarnessSystemPrompt(prompt: string): Promise
setHarnessSystemPromptMain(prompt: string): Promise
+ setPrReviewPrompt(prompt: string): Promise
prepareMcpForTerminal(terminalId: string): Promise
onWorktreesExternalCreate(
callback: (payload: { repoRoot: string; worktree: Worktree; initialPrompt?: string }) => void
diff --git a/src/shared/state/settings.test.ts b/src/shared/state/settings.test.ts
index 1e1c9de0..ac2f8751 100644
--- a/src/shared/state/settings.test.ts
+++ b/src/shared/state/settings.test.ts
@@ -103,6 +103,15 @@ describe('settingsReducer', () => {
expect(off.expandedDiagnosticLoggingEnabled).toBe(false)
})
+ it('prReviewPromptChanged overrides the default review prompt', () => {
+ expect(initialSettings.prReviewPrompt.length).toBeGreaterThan(0)
+ const next = apply(initialSettings, {
+ type: 'settings/prReviewPromptChanged',
+ payload: 'Focus on security issues only.'
+ })
+ expect(next.prReviewPrompt).toBe('Focus on security issues only.')
+ })
+
it('autoUpdateEnabledChanged toggles auto-update flag', () => {
expect(initialSettings.autoUpdateEnabled).toBe(true)
const off = apply(initialSettings, {
diff --git a/src/shared/state/settings.ts b/src/shared/state/settings.ts
index eb0b3aa0..b35f3d56 100644
--- a/src/shared/state/settings.ts
+++ b/src/shared/state/settings.ts
@@ -46,6 +46,11 @@ export const EMPTY_CUSTOM_THEMES: CustomTheme[] = []
export const DEFAULT_LIGHT_THEME = 'solarized-light'
export const DEFAULT_DARK_THEME = 'dark'
+/** Default kickoff prompt for "Open PR as worktree". Editable globally in
+ * Settings (`prReviewPrompt`) and per-creation in the New Worktree screen. */
+export const DEFAULT_PR_REVIEW_PROMPT =
+ "Review this PR. Read the diff, then check for correctness issues, design problems, security concerns, and missing edge cases. Cite file paths and line numbers for anything you flag. Skip restating what the PR does — focus on what could go wrong or be improved."
+
export interface SettingsState {
/** Whether the active theme is the light theme, the dark theme, or follows
* the OS appearance. Default 'system'. */
@@ -141,6 +146,12 @@ export interface SettingsState {
* volume is high during PR refresh bursts. HUD metrics like "GH API"
* rate are always on regardless of this flag. */
expandedDiagnosticLoggingEnabled: boolean
+ /** Default prompt pre-filled into the "Open PR as worktree" screen and
+ * used as the kickoff prompt when an MCP `create_worktree` call provides
+ * a `prNumber` without an explicit `initialPrompt`. The textarea on the
+ * PR-creation screen is seeded from this value but edits there are
+ * one-shot — managing the default happens in Settings. */
+ prReviewPrompt: string
}
export type SettingsEvent =
@@ -192,6 +203,7 @@ export type SettingsEvent =
| { type: 'settings/autoSleepMinutesChanged'; payload: number }
| { type: 'settings/snoozeDefaultDaysChanged'; payload: number }
| { type: 'settings/expandedDiagnosticLoggingEnabledChanged'; payload: boolean }
+ | { type: 'settings/prReviewPromptChanged'; payload: string }
// Client-side placeholder. Real values are seeded in the main-process Store
// constructor from the on-disk config and secrets.
@@ -240,7 +252,8 @@ export const initialSettings: SettingsState = {
jsonModeDefaultPermissionMode: 'acceptEdits',
autoSleepMinutes: 30,
snoozeDefaultDays: 7,
- expandedDiagnosticLoggingEnabled: false
+ expandedDiagnosticLoggingEnabled: false,
+ prReviewPrompt: DEFAULT_PR_REVIEW_PROMPT
}
export function settingsReducer(state: SettingsState, event: SettingsEvent): SettingsState {
@@ -335,6 +348,8 @@ export function settingsReducer(state: SettingsState, event: SettingsEvent): Set
return { ...state, snoozeDefaultDays: event.payload }
case 'settings/expandedDiagnosticLoggingEnabledChanged':
return { ...state, expandedDiagnosticLoggingEnabled: event.payload }
+ case 'settings/prReviewPromptChanged':
+ return { ...state, prReviewPrompt: event.payload }
default: {
const _exhaustive: never = event
void _exhaustive
From 497013e68d82aa4b68cefb1bab4ce9dd7ea6cd84 Mon Sep 17 00:00:00 2001
From: Mike Lyons
Date: Fri, 22 May 2026 22:35:13 -0600
Subject: [PATCH 3/7] move PR review prompt below other Worktrees defaults
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Tucks the textarea in next to "Share Claude Code permissions" — both
are global "default behavior" settings most users won't touch, so the
section now leads with the per-creation knobs (merge strategy, scripts)
and ends with the defaults.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/renderer/components/Settings.tsx | 72 ++++++++++++++--------------
1 file changed, 35 insertions(+), 37 deletions(-)
diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx
index 8ab3fcb3..5be1f053 100644
--- a/src/renderer/components/Settings.tsx
+++ b/src/renderer/components/Settings.tsx
@@ -1603,43 +1603,6 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:
Controls how new worktrees are created from the sidebar.
-
PR review prompt
-
-
- Default kickoff prompt sent to Claude when you open a PR as a worktree (or when the MCP{' '}
- create_worktree tool is invoked with{' '}
- prNumber and no explicit prompt). You can
- edit the prompt per-PR from the New Worktree screen.
-
-
-
{repoList.length > 0 && (
@@ -1920,6 +1883,41 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:
Share settings.local.json across worktrees
+
+
PR review prompt
+
+ Default kickoff prompt sent to Claude when you open a PR as a worktree (or when the MCP{' '}
+ create_worktree tool is invoked
+ with prNumber and no explicit
+ prompt). You can edit the prompt per-PR from the New Worktree screen.
+