diff --git a/resources/mcp-bridge.js b/resources/mcp-bridge.js index 7c688b64..67186989 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 agent 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,25 @@ 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 agent 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.' + }, + agentKind: { + type: 'string', + enum: ['claude', 'codex'], + description: + "Which CLI agent to spawn in the new worktree's first tab. Defaults to the user's configured default agent (Settings → Agent)." + }, + model: { + type: 'string', + description: + "Model string to pass to the agent CLI's --model flag for this worktree's first tab (e.g. 'opus', 'sonnet-4-5', 'gpt-5'). Pinned per-tab — survives reloads, doesn't affect other worktrees. Omit to use the global default (Settings → Agent)." } - }, - required: ['branchName'] + } } }, { @@ -438,21 +455,38 @@ 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') + } + } + if ( + args.agentKind !== undefined && + args.agentKind !== null && + args.agentKind !== 'claude' && + args.agentKind !== 'codex' + ) { + throw new Error('agentKind must be "claude" or "codex"') + } const r = await callControl('POST', '/worktrees', { terminalId: TERMINAL_ID, repoRoot: args.repoRoot, branchName: args.branchName, + prNumber: prNumber, baseBranch: args.baseBranch, - initialPrompt: args.initialPrompt + initialPrompt: args.initialPrompt, + agentKind: args.agentKind, + model: args.model }) - return ( - 'Created worktree ' + - r.path + - ' on branch ' + - r.branch + - '. Harness will open a new Claude chat tab in it.' - ) + const agentLabel = args.agentKind === 'codex' ? 'Codex' : 'Claude' + const modelSuffix = args.model ? ` (model: ${args.model})` : '' + return prNumber + ? `Created worktree ${r.path} on branch ${r.branch} for PR #${prNumber}. Harness will open a new ${agentLabel} chat tab in it${modelSuffix}.` + : `Created worktree ${r.path} on branch ${r.branch}. Harness will open a new ${agentLabel} chat tab in it${modelSuffix}.` } 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..4e669e1a --- /dev/null +++ b/resources/mcp-bridge.test.js @@ -0,0 +1,306 @@ +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('forwards agentKind + model to POST /worktrees and reports them 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-codex', branch: 'feat' })) + }) + 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: 'feat', agentKind: 'codex', model: 'gpt-5' } + } + }) + const response = await bridge.next() + + expect(response.result.isError).toBeFalsy() + const text = response.result.content[0].text + expect(text).toContain('Codex') + expect(text).toContain('gpt-5') + + const postCall = stub.captured.find((c) => c.method === 'POST' && c.url === '/worktrees') + expect(postCall).toBeDefined() + expect(postCall.body.agentKind).toBe('codex') + expect(postCall.body.model).toBe('gpt-5') + }) + + it('rejects unknown agentKind 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: { branchName: 'feat', agentKind: 'gemini' } + } + }) + const response = await bridge.next() + + expect(response.result.isError).toBe(true) + expect(response.result.content[0].text).toMatch(/claude.*codex/i) + 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/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/claude-launch.test.ts b/src/main/claude-launch.test.ts index 00d018cf..aa9be348 100644 --- a/src/main/claude-launch.test.ts +++ b/src/main/claude-launch.test.ts @@ -93,6 +93,35 @@ describe('buildClaudeLaunchSettings', () => { ).toBeUndefined() }) + it('modelOverride wins over claudeModel and trims whitespace', () => { + const wt = makeWorktree() + expect( + buildClaudeLaunchSettings({ + cwd: wt.path, + worktrees: [wt], + config: { claudeModel: 'opus' }, + modelOverride: 'sonnet-4-5' + }).model + ).toBe('sonnet-4-5') + expect( + buildClaudeLaunchSettings({ + cwd: wt.path, + worktrees: [wt], + config: {}, + modelOverride: ' haiku ' + }).model + ).toBe('haiku') + // Blank/whitespace override falls through to settings. + expect( + buildClaudeLaunchSettings({ + cwd: wt.path, + worktrees: [wt], + config: { claudeModel: 'opus' }, + modelOverride: ' ' + }).model + ).toBe('opus') + }) + it('builds sessionName from repoLabel/branch when nameClaudeSessions is on', () => { const wt = makeWorktree({ repoRoot: '/Users/x/code/myrepo', branch: 'feat-x' }) const out = buildClaudeLaunchSettings({ diff --git a/src/main/claude-launch.ts b/src/main/claude-launch.ts index a4aa5dd8..a26302d4 100644 --- a/src/main/claude-launch.ts +++ b/src/main/claude-launch.ts @@ -28,8 +28,9 @@ export function buildClaudeLaunchSettings(input: { cwd: string worktrees: Worktree[] config: ClaudeLaunchConfig + modelOverride?: string }): ClaudeLaunchSettings { - const { cwd, worktrees, config } = input + const { cwd, worktrees, config, modelOverride } = input const wt = worktrees.find((w) => w.path === cwd) const isMain = wt?.isMain ?? false @@ -46,7 +47,8 @@ export function buildClaudeLaunchSettings(input: { if (!systemPrompt.trim()) systemPrompt = undefined } - const model = config.claudeModel ? config.claudeModel : undefined + const override = modelOverride && modelOverride.trim() ? modelOverride.trim() : undefined + const model = override || (config.claudeModel ? config.claudeModel : undefined) let sessionName: string | undefined if (config.nameClaudeSessions && wt) { diff --git a/src/main/control-server.ts b/src/main/control-server.ts index 45ad6257..515f32a7 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' @@ -88,8 +88,24 @@ 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, + * 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 + agentKind?: 'claude' | 'codex' + model?: 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 +238,54 @@ 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 + + const rawAgent = typeof body.agentKind === 'string' ? body.agentKind.trim().toLowerCase() : '' + let agentKind: 'claude' | 'codex' | undefined + if (rawAgent) { + if (rawAgent !== 'claude' && rawAgent !== 'codex') { + return sendJson(res, 400, { error: 'agentKind must be "claude" or "codex"' }) + } + agentKind = rawAgent + } + const model = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : undefined + + if (prNumber !== undefined) { + 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: promptForPR || undefined, + agentKind, + model + }) + 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,8 +298,13 @@ 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 }) + deps.broadcast('worktrees:externalCreate', { + repoRoot, + worktree: created, + initialPrompt, + agentKind, + model + }) return sendJson(res, 200, created) } diff --git a/src/main/index.ts b/src/main/index.ts index 7dfa5f53..a14d7867 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' @@ -314,11 +318,12 @@ const jsonClaudeManager = new JsonClaudeManager(store, { isMain: scope.isMain } }, - getLaunchSettings: (worktreePath) => + getLaunchSettings: (worktreePath, modelOverride) => buildClaudeLaunchSettings({ cwd: worktreePath, worktrees: store.getSnapshot().state.worktrees.list, - config + config, + modelOverride }) }) const perfMonitor = new PerfMonitor() @@ -688,7 +693,8 @@ function treeToPersistedNode(node: PaneNode): PersistedPaneNode | null { sessionId: stripped.sessionId, url: liveUrl || stripped.url, command: stripped.command, - cwd: stripped.cwd + cwd: stripped.cwd, + model: stripped.model } }) if (tabs.length === 0) return null @@ -764,6 +770,24 @@ const panesFSM = new PanesFSM(store, { } }) +/** Look up a json-claude tab's persisted `model` override by sessionId + * (which is also the tab id for json-claude tabs). Returned to the + * json-claude manager so resume/kickoff/wake all respect a per-tab pin + * that was set when the worktree was created. */ +function findJsonClaudeTabModel(sessionId: string): string | undefined { + const panes = store.getSnapshot().state.terminals.panes + for (const tree of Object.values(panes)) { + for (const leaf of getLeaves(tree)) { + for (const tab of leaf.tabs) { + if (tab.id === sessionId && tab.type === 'json-claude') { + return tab.model && tab.model.trim() ? tab.model.trim() : undefined + } + } + } + } + return undefined +} + /** Single source of truth for "spin up the json-claude subprocess for * this sessionId". Used by the jsonClaude:start IPC handler, the * panesFSM's startJsonClaudeWithPrompt + startJsonClaude options @@ -785,16 +809,16 @@ function startJsonClaudeSession(sessionId: string, worktreePath: string): void { const permMode = store.getSnapshot().state.jsonClaude.sessions[sessionId]?.permissionMode || 'default' - jsonClaudeManager.create(sessionId, worktreePath, permMode) + jsonClaudeManager.create(sessionId, worktreePath, permMode, findJsonClaudeTabModel(sessionId)) } const worktreesFSM = new WorktreesFSM(store, { getRepoRoots: () => config.repoRoots || [], getWorktreeSetupCmd: () => config.worktreeSetupCommand || '', getWorktreeBaseMode: () => config.worktreeBase || DEFAULT_WORKTREE_BASE, - onWorktreeCreated: ({ createdPath, initialPrompt, teleportSessionId }) => { + onWorktreeCreated: ({ createdPath, initialPrompt, teleportSessionId, agentKind, model }) => { void prPoller.refreshAll() - panesFSM.ensureInitialized(createdPath, { initialPrompt, teleportSessionId }) + panesFSM.ensureInitialized(createdPath, { initialPrompt, teleportSessionId, agentKind, model }) if (teleportSessionId) { setTimeout(() => void worktreesFSM.refreshList(), 10_000) } @@ -970,13 +994,25 @@ function registerIpcHandlers(): void { branchName: string initialPrompt?: string teleportSessionId?: string + agentKind?: 'claude' | 'codex' + model?: string }) => { return worktreesFSM.runPending(params) } ) transport.onRequest( 'worktrees:runPendingPR', - async (_ctx, params: { id: string; repoRoot: string; prNumber: number }) => { + async ( + _ctx, + params: { + id: string + repoRoot: string + prNumber: number + initialPrompt?: string + agentKind?: 'claude' | 'codex' + model?: string + } + ) => { return worktreesFSM.runPendingPR(params) } ) @@ -1576,6 +1612,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 @@ -2060,7 +2111,8 @@ function registerIpcHandlers(): void { (_ctx, agentKind: string, opts: { terminalId: string; cwd: string; sessionId?: string; initialPrompt?: string; teleportSessionId?: string; - sessionName?: string + sessionName?: string; + modelOverride?: string }): string => { const kind = toAgentKind(agentKind) const agent = getAgent(kind) @@ -2072,6 +2124,7 @@ function registerIpcHandlers(): void { resolveCallerScope(opts.terminalId) ) + const override = opts.modelOverride && opts.modelOverride.trim() ? opts.modelOverride.trim() : undefined let systemPrompt: string | undefined let tuiFullscreen: boolean | undefined let model: string | null @@ -2079,13 +2132,14 @@ function registerIpcHandlers(): void { const launch = buildClaudeLaunchSettings({ cwd: opts.cwd, worktrees: store.getSnapshot().state.worktrees.list, - config + config, + modelOverride: override }) systemPrompt = launch.systemPrompt tuiFullscreen = launch.tuiFullscreen model = launch.model ?? null } else { - model = config.codexModel || null + model = override || config.codexModel || null } return agent.buildSpawnArgs({ ...opts, command, mcpConfigPath, model, systemPrompt, tuiFullscreen }) @@ -2949,6 +3003,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, @@ -3087,6 +3142,37 @@ 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` } + } + // agentKind + model are already applied via the FSM's onWorktreeCreated + // path; we still ship them in the broadcast payload so its shape stays + // in sync with the new-branch path through deps.broadcast — keeps + // future refactors that consolidate the two from silently losing data. + broadcastToAllWindows('worktrees:externalCreate', { + repoRoot: params.repoRoot, + worktree: found, + initialPrompt: params.initialPrompt, + agentKind: params.agentKind, + model: params.model + }) + 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 @@ -3099,9 +3185,13 @@ async function runBoot(): Promise { repoRoot: string worktree: { path: string } initialPrompt?: string + agentKind?: 'claude' | 'codex' + model?: string } panesFSM.ensureInitialized(p.worktree.path, { - initialPrompt: p.initialPrompt + initialPrompt: p.initialPrompt, + agentKind: p.agentKind, + model: p.model }) void worktreesFSM.refreshList().then(() => { broadcastToAllWindows(channel, payload) diff --git a/src/main/json-claude-manager.ts b/src/main/json-claude-manager.ts index 01af3db5..cb6f3d62 100644 --- a/src/main/json-claude-manager.ts +++ b/src/main/json-claude-manager.ts @@ -120,7 +120,7 @@ export interface JsonClaudeManagerOptions { * --name) at spawn time from the live config + worktree list. Same * source of truth as the xterm spawn path; see buildClaudeLaunchSettings * in claude-launch.ts. */ - getLaunchSettings: (worktreePath: string) => ClaudeLaunchSettings + getLaunchSettings: (worktreePath: string, modelOverride?: string) => ClaudeLaunchSettings } /** Path to the bundled stdio MCP server we point Claude's @@ -364,7 +364,8 @@ export class JsonClaudeManager { create( sessionId: string, worktreePath: string, - permissionMode: JsonClaudePermissionMode = 'default' + permissionMode: JsonClaudePermissionMode = 'default', + modelOverride?: string ): void { if (this.instances.has(sessionId)) { log('json-claude', `create no-op — already running sessionId=${sessionId}`) @@ -432,7 +433,7 @@ export class JsonClaudeManager { ? ['--resume', sessionId] : ['--session-id', sessionId] - const launchSettings = this.opts.getLaunchSettings(worktreePath) + const launchSettings = this.opts.getLaunchSettings(worktreePath, modelOverride) const args = [ '-p', '--input-format', diff --git a/src/main/panes-fsm.ts b/src/main/panes-fsm.ts index 9b2f58dd..e2625e4d 100644 --- a/src/main/panes-fsm.ts +++ b/src/main/panes-fsm.ts @@ -165,7 +165,8 @@ export class PanesFSM { sessionId: t.sessionId, url: t.url, command: t.command, - cwd: t.cwd + cwd: t.cwd, + model: t.model } // Persisted json-claude tabs hydrate as 'asleep' so app launch // doesn't spawn one subprocess per tab. The renderer wakes them @@ -190,7 +191,12 @@ export class PanesFSM { ensureInitialized( wtPath: string, - opts?: { initialPrompt?: string; teleportSessionId?: string } + opts?: { + initialPrompt?: string + teleportSessionId?: string + agentKind?: AgentKind + model?: string + } ): PaneNode { const existing = this.getTree(wtPath) if (existing && hasAnyTabs(existing)) return existing @@ -202,8 +208,9 @@ export class PanesFSM { return sleeping } - const agentKind = this.opts.getDefaultAgentKind?.() ?? 'claude' + const agentKind = opts?.agentKind ?? this.opts.getDefaultAgentKind?.() ?? 'claude' const agentInfo = getAgentInfo(agentKind) + const model = opts?.model && opts.model.trim() ? opts.model.trim() : undefined const shellTabId = `shell-${wtPath}-${Date.now()}` // Branch to a json-claude default tab when the user has opted in // and the kind is Claude. teleport sessions stay on xterm (json- @@ -216,7 +223,7 @@ export class PanesFSM { this.opts.getDefaultClaudeTabType?.() === 'json' && !opts?.teleportSessionId let agentTab: TerminalTab - let jsonClaudeKickoff: { sessionId: string; initialPrompt?: string } | null = null + let jsonClaudeKickoff: { sessionId: string; initialPrompt?: string; model?: string } | null = null if (wantsJson) { const sessionId = crypto.randomUUID() agentTab = { @@ -224,9 +231,10 @@ export class PanesFSM { type: 'json-claude', label: 'Claude (JSON)', sessionId, - mode: 'awake' + mode: 'awake', + model } - jsonClaudeKickoff = { sessionId, initialPrompt: opts?.initialPrompt } + jsonClaudeKickoff = { sessionId, initialPrompt: opts?.initialPrompt, model } } else { const agentTabId = `agent-${wtPath.replace(/[^a-zA-Z0-9]/g, '-')}-${Date.now()}` agentTab = { @@ -236,7 +244,8 @@ export class PanesFSM { label: agentInfo.displayName, sessionId: agentInfo.assignsSessionId ? crypto.randomUUID() : undefined, initialPrompt: opts?.teleportSessionId ? undefined : opts?.initialPrompt, - teleportSessionId: opts?.teleportSessionId + teleportSessionId: opts?.teleportSessionId, + model } } const tabs: TerminalTab[] = [agentTab, { id: shellTabId, type: 'shell', label: 'Shell' }] diff --git a/src/main/persistence-migrations.ts b/src/main/persistence-migrations.ts index 74581b6c..2ef867b0 100644 --- a/src/main/persistence-migrations.ts +++ b/src/main/persistence-migrations.ts @@ -28,6 +28,9 @@ export interface PersistedTab { command?: string /** For shell tabs: cwd (absolute or relative to worktree root). */ cwd?: string + /** For agent + json-claude tabs: per-tab model pin. Wins over the + * global claudeModel/codexModel setting at spawn time. */ + model?: string } export interface PersistedPane { 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/main/worktrees-fsm.ts b/src/main/worktrees-fsm.ts index 3726aa7a..9cb53a28 100644 --- a/src/main/worktrees-fsm.ts +++ b/src/main/worktrees-fsm.ts @@ -62,6 +62,8 @@ interface WorktreesFSMOptions { createdPath: string initialPrompt?: string teleportSessionId?: string + agentKind?: 'claude' | 'codex' + model?: string }) => void } @@ -111,8 +113,10 @@ export class WorktreesFSM { branchName: string initialPrompt?: string teleportSessionId?: string + agentKind?: 'claude' | 'codex' + model?: string }): Promise { - const { id, repoRoot, branchName, initialPrompt, teleportSessionId } = params + const { id, repoRoot, branchName, initialPrompt, teleportSessionId, agentKind, model } = params const pending: PendingWorktree = { id, repoRoot, @@ -134,7 +138,9 @@ export class WorktreesFSM { repoRoot, created, initialPrompt, - teleportSessionId + teleportSessionId, + agentKind, + model }) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -154,15 +160,19 @@ export class WorktreesFSM { id: string repoRoot: string prNumber: number + initialPrompt?: string + agentKind?: 'claude' | 'codex' + model?: string }): Promise { - const { id, repoRoot, prNumber } = params + const { id, repoRoot, prNumber, initialPrompt, agentKind, model } = 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 +198,10 @@ export class WorktreesFSM { return await this.finishCreate({ id, repoRoot, - created + created, + initialPrompt, + agentKind, + model }) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -208,8 +221,10 @@ export class WorktreesFSM { created: WorktreeInfo initialPrompt?: string teleportSessionId?: string + agentKind?: 'claude' | 'codex' + model?: string }): Promise { - const { id, repoRoot, created, initialPrompt, teleportSessionId } = args + const { id, repoRoot, created, initialPrompt, teleportSessionId, agentKind, model } = args const setupCmd = this.resolveSetupCmd(repoRoot) let setupFailed = false @@ -243,7 +258,9 @@ export class WorktreesFSM { this.opts.onWorktreeCreated({ createdPath: created.path, initialPrompt, - teleportSessionId + teleportSessionId, + agentKind, + model }) await this.refreshList() diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index 1e7e38a4..59c3cf48 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -101,11 +101,16 @@ export function buildBackend( branchName: string initialPrompt?: string teleportSessionId?: string + agentKind?: 'claude' | 'codex' + model?: string }) => req('worktrees:runPending', params), runPendingPRWorktree: (params: { id: string repoRoot: string prNumber: number + initialPrompt?: string + agentKind?: 'claude' | 'codex' + model?: string }) => req('worktrees:runPendingPR', params), retryPendingWorktree: (id: string) => req('worktrees:retryPending', id), dismissPendingWorktree: (id: string) => req('worktrees:dismissPending', id), @@ -247,6 +252,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: ( @@ -314,6 +320,7 @@ export function buildBackend( initialPrompt?: string teleportSessionId?: string sessionName?: string + modelOverride?: string } ) => req('agent:buildSpawnArgs', agentKind, opts), diff --git a/src/renderer/components/MobileTerminal.tsx b/src/renderer/components/MobileTerminal.tsx index 09e22bfe..1b743a83 100644 --- a/src/renderer/components/MobileTerminal.tsx +++ b/src/renderer/components/MobileTerminal.tsx @@ -306,6 +306,7 @@ export function MobileTerminal({ worktreePath, tab }: MobileTerminalProps): JSX. visible={true} sessionName={tab.label} sessionId={tab.sessionId} + modelOverride={tab.type === 'agent' ? tab.model : undefined} /> {/* Hidden textarea — pointer-events:none so touch scrolling on the wrapper above isn't eaten; the wrapper's onClick focuses the diff --git a/src/renderer/components/NewWorktreeScreen.tsx b/src/renderer/components/NewWorktreeScreen.tsx index 1fc6a597..998ee347 100644 --- a/src/renderer/components/NewWorktreeScreen.tsx +++ b/src/renderer/components/NewWorktreeScreen.tsx @@ -1,14 +1,29 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { Sparkles, Loader2, X, Map, ListChecks, BookOpen, Radio, GitPullRequest } from 'lucide-react' +import { Sparkles, Loader2, X, Map, ListChecks, BookOpen, Radio, GitPullRequest, ChevronRight, ChevronDown } from 'lucide-react' 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 { CLAUDE_MODELS, CODEX_MODELS } from '../../shared/agent-registry' import type { PRSummary } from '../types' interface NewWorktreeScreenProps { - onSubmit: (repoRoot: string, branchName: string, initialPrompt: string, teleportSessionId?: string) => Promise - onPRSubmit: (repoRoot: string, prNumber: number) => Promise + onSubmit: ( + repoRoot: string, + branchName: string, + initialPrompt: string, + teleportSessionId?: string, + agentKind?: 'claude' | 'codex', + model?: string + ) => Promise + onPRSubmit: ( + repoRoot: string, + prNumber: number, + initialPrompt: string, + agentKind?: 'claude' | 'codex', + model?: string + ) => Promise onCancel: () => void repoRoots: string[] /** Repo to pre-select in the picker. Usually the repo of the currently active worktree. */ @@ -76,7 +91,17 @@ 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('') + // Per-creation overrides for agent + model. Default agent comes from + // settings; model defaults to empty (= use settings.claudeModel/codexModel + // at spawn time). Teleport mode pins to Claude — codex has no equivalent + // "resume by id" flow today. + const [agentKindOverride, setAgentKindOverride] = useState<'claude' | 'codex'>( + settings.defaultAgent === 'codex' ? 'codex' : 'claude' + ) + const [modelOverride, setModelOverride] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const branchRef = useRef(null) @@ -143,13 +168,19 @@ export function NewWorktreeScreen({ onSubmit, onPRSubmit, onCancel, repoRoots, d setPrClickPending(prNumber) setError(null) try { - await onPRSubmit(selectedRepo, prNumber) + await onPRSubmit( + selectedRepo, + prNumber, + reviewPrompt.trim(), + agentKindOverride, + modelOverride.trim() || undefined + ) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to open PR') setPrClickPending(null) } }, - [onPRSubmit, prClickPending, selectedRepo] + [onPRSubmit, prClickPending, selectedRepo, reviewPrompt, agentKindOverride, modelOverride] ) const handleBranchChange = useCallback((e: React.ChangeEvent) => { @@ -161,12 +192,23 @@ export function NewWorktreeScreen({ onSubmit, onPRSubmit, onCancel, repoRoots, d setSubmitting(true) setError(null) try { - await onSubmit(selectedRepo, effectiveBranch, prompt.trim(), parsedTeleport || undefined) + // Teleport mode always Claude — codex has no resume-by-session-id + // analog today. + const effectiveAgent: 'claude' | 'codex' = + mode === 'teleport' ? 'claude' : agentKindOverride + await onSubmit( + selectedRepo, + effectiveBranch, + prompt.trim(), + parsedTeleport || undefined, + effectiveAgent, + modelOverride.trim() || undefined + ) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create worktree') setSubmitting(false) } - }, [effectiveBranch, prompt, canSubmit, onSubmit, parsedTeleport, selectedRepo]) + }, [effectiveBranch, prompt, canSubmit, onSubmit, parsedTeleport, selectedRepo, mode, agentKindOverride, modelOverride]) const cycleRepo = useCallback((direction: 1 | -1) => { if (repoRoots.length <= 1) return @@ -402,13 +444,56 @@ export function NewWorktreeScreen({ onSubmit, onPRSubmit, onCancel, repoRoots, d )} {mode === 'pr' && ( - +