From c851b422f3b436a11ebde0e85b921668a37b3e0b Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Thu, 27 Aug 2026 09:12:06 -0400 Subject: [PATCH 1/2] Let agents park a fork of the chat when they find a tangent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents routinely notice a second problem while answering the first, and today they have no sanctioned move: derail into it, or bury it in a closing bullet. The new fork_chat MCP tool copies the caller's transcript into a second session and stops there — no tab, no subprocess, nothing running until the user clicks the card. That asymmetry is the safety story; an autonomous fork that spawned a sibling agent in the same working tree would not be. There is deliberately no slice behind a parked fork. The topic and prompt live in the tool_use input, the fork's session id in the tool_result, and "has it been opened" is answered by the panes tree — all already durable and already synced, so a restart can't disagree with a parallel record. The cap of 3 unopened forks per conversation derives from the same walk. Co-Authored-By: Claude Opus 4.7 --- resources/mcp-bridge.js | 56 ++++-- src/main/control-server.test.ts | 64 +++++++ src/main/control-server.ts | 49 ++++++ src/main/index.ts | 119 +++++++++++++ src/renderer/build-backend.ts | 2 + src/renderer/components/JsonModeChat.tsx | 78 ++++++++- .../components/json-mode-cards/ForkCard.tsx | 107 ++++++++++++ .../components/json-mode-cards/index.tsx | 9 + src/renderer/types.ts | 4 + src/shared/fork-chat.test.ts | 161 ++++++++++++++++++ src/shared/fork-chat.ts | 117 +++++++++++++ src/shared/state/json-claude.ts | 11 +- 12 files changed, 760 insertions(+), 17 deletions(-) create mode 100644 src/renderer/components/json-mode-cards/ForkCard.tsx create mode 100644 src/shared/fork-chat.test.ts create mode 100644 src/shared/fork-chat.ts diff --git a/resources/mcp-bridge.js b/resources/mcp-bridge.js index cb86d3291..b365e533a 100644 --- a/resources/mcp-bridge.js +++ b/resources/mcp-bridge.js @@ -225,6 +225,27 @@ const TOOLS = [ } } }, + { + name: 'fork_chat', + description: + "Park a fork of THIS conversation to chase a tangent YOU found, without derailing what you're currently doing. Ness copies the conversation as it stands into a second chat in this same worktree, queues `prompt` as its first message, and leaves it idle. Nothing runs: the fork does not start until the user clicks it, so this never puts a second agent on these files behind their back. It shows up as a card at this point in the transcript, so the user sees what you noticed next to the work that made you notice it.\n\nUSE IT when, while doing what the user asked, you turn up something real that they did NOT ask about and that deserves its own thread — a bug next to the one you were sent for, a config that contradicts the docs you just read, a second cause you can prove but that isn't yours to fix right now. The test is whether you'd otherwise be tempted to either derail into it or bury it in a closing bullet. Both of those lose it; this keeps it, with the context that produced it.\n\nDO NOT use it as a way to avoid answering, or to shard the task the user actually gave you into pieces — sub-tasks of the current job are your job, and the Task tool is for parallelising them. Not for something you can just fix correctly in the next thirty seconds; fix it and say so. Not for speculative improvements ('we could add tests here', 'this could be faster'), which are opinions rather than findings, and belong in your answer where the user can wave them off in one word. Not for anything the user already told you about — they know.\n\nAFTER forking: say ONE line about what you parked, and go back to the original task. Do not start investigating the tangent — that is what the fork is for, and the user has not agreed to it yet. Do not ask whether they want it; parking it IS the low-cost way to ask.\n\nBudget: a few unopened forks per conversation, then the tool refuses. Each result tells you what's left. If you're near the cap you're forking too eagerly — the rest goes in your answer as prose.", + inputSchema: { + type: 'object', + properties: { + topic: { + type: 'string', + description: + 'A few words naming the tangent, in the user\'s vocabulary — it becomes the card title and the new tab\'s name. "Cron job never fires", "auth.ts swallows 401s". Not "investigation" or "follow-up".' + }, + prompt: { + type: 'string', + description: + "The fork's first message: what you want it to look into. It already holds this entire conversation, so do not re-explain the background — say what you noticed, where, and what you want established. Write it to your future self, not to the user." + } + }, + required: ['topic', 'prompt'] + } + }, { name: 'list_worktrees', description: @@ -664,18 +685,21 @@ function filterToolsByPerms(tools, perms) { }) } -// Strip every trace of forking from create_worktree when it's disabled, rather -// than advertising a parameter whose only outcome is a rejection. +// Strip every trace of forking when it's disabled, rather than advertising +// affordances whose only outcome is a rejection. fork_chat goes entirely; +// create_worktree keeps everything except its forkConversation parameter. function stripForkAffordance(tools) { - return tools.map((t) => { - if (t.name !== 'create_worktree') return t - const { forkConversation, ...rest } = t.inputSchema.properties - return { - ...t, - description: t.description.replace(FORK_DESCRIPTION_SENTENCE, ''), - inputSchema: { ...t.inputSchema, properties: rest } - } - }) + return tools + .filter((t) => t.name !== 'fork_chat') + .map((t) => { + if (t.name !== 'create_worktree') return t + const { forkConversation, ...rest } = t.inputSchema.properties + return { + ...t, + description: t.description.replace(FORK_DESCRIPTION_SENTENCE, ''), + inputSchema: { ...t.inputSchema, properties: rest } + } + }) } async function handleToolCall(name, args) { @@ -725,6 +749,16 @@ async function handleToolCall(name, args) { ? `Created worktree ${r.path} on branch ${r.branch} for PR #${prNumber}${aliasSuffix}. Ness will open a new ${agentLabel} chat tab in it${modelSuffix}.` : `Created worktree ${r.path} on branch ${r.branch}${aliasSuffix}. Ness will open a new ${agentLabel} chat tab in it${modelSuffix}.${forkSuffix}` } + if (name === 'fork_chat') { + const topic = args && typeof args.topic === 'string' ? args.topic.trim() : '' + const prompt = args && typeof args.prompt === 'string' ? args.prompt.trim() : '' + if (!topic) throw new Error('topic is required') + if (!prompt) throw new Error('prompt is required') + // The message is built server-side because it carries the fork id in the + // exact shape the chat card parses back out. + const r = await callControl('POST', '/forks', { topic, prompt }) + return r.message + } if (name === 'list_worktrees') { const q = args && args.repoRoot ? '?repoRoot=' + encodeURIComponent(args.repoRoot) : '' diff --git a/src/main/control-server.test.ts b/src/main/control-server.test.ts index 4aac26f5c..873d20bf1 100644 --- a/src/main/control-server.test.ts +++ b/src/main/control-server.test.ts @@ -8,6 +8,7 @@ import { import type { ChatDeliveryResult } from './chat-delivery' import type { CaptureResult } from './browser-manager-types' import { parseAutomatedMessage } from '../shared/state/json-claude' +import { parseForkSessionId } from '../shared/fork-chat' // Integration test for the local HTTP control server. Exercises the // `/aliases` endpoint end-to-end (POST + DELETE, both scoped and @@ -66,6 +67,16 @@ let captureResult: CaptureResult | null = null * evaluated (load failed, or the eval timed out). */ let domResult: () => Promise = async () => null +const FORK_SESSION = '3f2504e0-4f89-11d3-9a0c-0305e82c3301' +/** Stands in for the real transcript copy. `parkFailure` lets a test drive + * the cap-refusal branch without building a transcript on disk. */ +let parkFailure: string | null = null +const parkChatFork = vi.fn(() => + parkFailure + ? { ok: false as const, error: parkFailure } + : { ok: true as const, forkSessionId: FORK_SESSION, remaining: 2 } +) + const deps: ControlServerDeps = { getRepoRoots: () => ['/repo'], getWorktreeBase: () => 'remote', @@ -77,6 +88,8 @@ const deps: ControlServerDeps = { terminalId === CALLER_TERMINAL || terminalId === NO_TRANSCRIPT_TERMINAL ? scope : null, hasForkableTranscript: (sessionId) => sessionId === CALLER_TERMINAL, getConversationForkEnabled: () => conversationForkEnabled, + parkChatFork: (parentSessionId, worktreePath) => + parkChatFork(parentSessionId, worktreePath), getBrowserPerms: () => ({ enabled: browserEnabled, mode: 'full' }), getWorktreeStatus: () => ({ status: 'no-pr', statusLabel: 'Active' }), browser: { @@ -377,6 +390,57 @@ describe('control-server POST /worktrees forkConversation', () => { }) }) +describe('control-server POST /forks', () => { + const body = { topic: 'drop the cron', prompt: 'check whether the cron is dead' } + + it('parks a fork scoped to the calling terminal', async () => { + parkChatFork.mockClear() + const r = await call('POST', '/forks', body) + expect(r.status).toBe(200) + expect(parkChatFork).toHaveBeenCalledWith(CALLER_TERMINAL, CALLER_WORKTREE) + expect(r.json.forkSessionId).toBe(FORK_SESSION) + }) + + it('returns a result the card can recover the session id from', async () => { + const r = await call('POST', '/forks', body) + expect(parseForkSessionId(String(r.json.message))).toBe(FORK_SESSION) + expect(String(r.json.message)).toContain('"drop the cron"') + }) + + it('requires both topic and prompt', async () => { + expect((await call('POST', '/forks', { prompt: 'x' })).status).toBe(400) + expect((await call('POST', '/forks', { topic: 'x' })).status).toBe(400) + }) + + it('rejects a caller whose terminal has no forkable transcript', async () => { + const r = await call('POST', '/forks', body, { terminalId: NO_TRANSCRIPT_TERMINAL }) + expect(r.status).toBe(400) + expect(r.json.error).toMatch(/only available from a Ness Chat tab/) + }) + + it('rejects when the setting is disabled', async () => { + conversationForkEnabled = false + try { + const r = await call('POST', '/forks', body) + expect(r.status).toBe(400) + expect(r.json.error).toMatch(/disabled in Ness settings/) + } finally { + conversationForkEnabled = true + } + }) + + it('surfaces the cap refusal as 409 with the reason intact', async () => { + parkFailure = 'this conversation already has 3 forks parked and unopened' + try { + const r = await call('POST', '/forks', body) + expect(r.status).toBe(409) + expect(r.json.error).toMatch(/3 forks parked/) + } finally { + parkFailure = null + } + }) +}) + describe('control-server POST /worktrees kickoff wrapping', () => { beforeAll(() => { runPendingPR.mockImplementation(async () => ({ diff --git a/src/main/control-server.ts b/src/main/control-server.ts index 703215f9f..2d98e60cd 100644 --- a/src/main/control-server.ts +++ b/src/main/control-server.ts @@ -10,6 +10,7 @@ import type { PRStatus } from '../shared/state/prs' import type { ChatDeliveryResult } from './chat-delivery' import type { CaptureResult } from './browser-manager-types' import { wrapAutomatedMessage } from '../shared/state/json-claude' +import { formatForkResult } from '../shared/fork-chat' import { log } from './debug' export interface BrowserTabSummary { @@ -158,6 +159,16 @@ export interface ControlServerDeps { /** Whether conversation forking is enabled in settings. Re-read per request * so a toggle takes effect without restarting the bridge. */ getConversationForkEnabled: () => boolean + /** Copy the caller's own transcript into a parked fork — a jsonl on disk + * with no tab and no subprocess. Returns how many more the conversation may + * park, which the tool result passes back to the model so it can pace + * itself instead of discovering the cap by being refused. */ + parkChatFork: ( + parentSessionId: string, + worktreePath: string + ) => + | { ok: true; forkSessionId: string; remaining: number } + | { ok: false; error: string } /** Current browser-tool permissions. Re-read on every request so user * toggles take effect mid-session without restarting the bridge. */ getBrowserPerms: () => BrowserPerms @@ -439,6 +450,44 @@ async function handleRequest( return sendJson(res, 200, created) } + // fork_chat — the caller forking ITSELF, mid-answer, over a tangent it + // found rather than one the user asked about. Same self-scoping rule as + // forkConversation: the session comes from the terminal id, never from an + // argument. Unlike create_worktree this stays in the caller's worktree, so + // there is no branch, no relocation preamble, and nothing running until the + // user opens it. + if (req.method === 'POST' && path === '/forks') { + const body = await readJson(req) + const topic = String(body.topic || '').trim() + const prompt = String(body.prompt || '').trim() + if (!topic) return sendJson(res, 400, { error: 'topic is required' }) + if (!prompt) return sendJson(res, 400, { error: 'prompt is required' }) + if (!deps.getConversationForkEnabled()) { + return sendJson(res, 400, { + error: + 'conversation forking is disabled in Ness settings. Mention what you noticed in your answer instead.' + }) + } + const { scope, terminalId } = resolveScope(req, deps) + if (!scope || !deps.hasForkableTranscript(terminalId, scope.worktreePath)) { + return sendJson(res, 400, { + error: + 'fork_chat is only available from a Ness Chat tab that already has conversation history. Mention what you noticed in your answer instead.' + }) + } + const parked = deps.parkChatFork(terminalId, scope.worktreePath) + if (!parked.ok) return sendJson(res, 409, { error: parked.error }) + log('control', `fork_chat parked ${parked.forkSessionId} topic="${topic}"`) + return sendJson(res, 200, { + forkSessionId: parked.forkSessionId, + message: formatForkResult({ + forkSessionId: parked.forkSessionId, + topic, + remaining: parked.remaining + }) + }) + } + // rename_worktree — the git-level counterpart to /aliases. Renames the // branch and/or sets the display alias in one call, because the auto-naming // flow (a worktree created from just a kickoff prompt) always wants both. diff --git a/src/main/index.ts b/src/main/index.ts index ca525a43a..e4b49ff00 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -106,6 +106,11 @@ import { wrapAutomatedMessage, type JsonClaudePermissionMode } from '../shared/state/json-claude' +import { + MAX_UNOPENED_FORKS, + collectParkedForks, + type ParkedFork +} from '../shared/fork-chat' import { deriveWorktreeStatus } from './worktree-status' import { DEFAULT_LIGHT_THEME, @@ -1082,6 +1087,67 @@ async function resolveForkedKickoff(args: { } } +function findTab( + worktreePath: string, + tabId: string +): { paneId: string; tab: TerminalTab } | undefined { + const tree = store.getSnapshot().state.terminals.panes[worktreePath] + if (!tree) return undefined + for (const leaf of getLeaves(tree)) { + for (const tab of leaf.tabs) { + if (tab.id === tabId) return { paneId: leaf.id, tab } + } + } + return undefined +} + +/** Forks this conversation has parked but the user hasn't opened. "Opened" + * is the existence of a tab, not a flag: panes persist, so this stays + * correct across a restart without any bookkeeping of our own. */ +function unopenedForks(parentSessionId: string, worktreePath: string): ParkedFork[] { + const session = store.getSnapshot().state.jsonClaude.sessions[parentSessionId] + if (!session) return [] + return collectParkedForks(session.entries).filter( + (f) => !findTab(worktreePath, f.forkSessionId) + ) +} + +/** Agent-initiated fork. Copies the caller's transcript as it stands into a + * fresh session id and stops there — no tab, no subprocess, nothing running. + * A parked fork is a jsonl on disk plus the tool_result naming it; it only + * becomes a chat when the user opens the card. That asymmetry is the whole + * safety story: an agent can leave a thought behind mid-answer without + * spawning a sibling that edits the same working tree unattended. */ +function parkChatFork( + parentSessionId: string, + worktreePath: string +): + | { ok: true; forkSessionId: string; remaining: number } + | { ok: false; error: string } { + const unopened = unopenedForks(parentSessionId, worktreePath) + if (unopened.length >= MAX_UNOPENED_FORKS) { + const topics = unopened.map((f) => `"${f.topic}"`).join(', ') + return { + ok: false, + error: `this conversation already has ${unopened.length} forks parked and unopened (${topics}). Mention what you noticed in your answer instead — the user can ask you to fork it if they want it pursued.` + } + } + const outcome = forkTranscript({ + sourceSessionId: parentSessionId, + sourceWorktreePath: worktreePath, + destWorktreePath: worktreePath + }) + if (!outcome.ok || !outcome.newSessionId) { + return { ok: false, error: outcome.reason || 'could not copy the transcript' } + } + log('json-claude', `parked fork ${outcome.newSessionId} from ${parentSessionId}`) + return { + ok: true, + forkSessionId: outcome.newSessionId, + remaining: MAX_UNOPENED_FORKS - unopened.length - 1 + } +} + /** Interrupt an in-flight json-claude turn and wait for it to actually * reach a boundary. Callers that touch the session's stdin or jsonl * right after (interrupt-and-send, rewind, model swap) need the turn @@ -3770,6 +3836,58 @@ function registerIpcHandlers(): void { } ) + // Promote a parked fork (see parkChatFork) into a live chat: give it a tab + // and send the prompt the agent queued for it. Idempotent — a card clicked + // twice, or clicked after the tab already exists, focuses rather than + // respawning, because the tab id IS the fork's session id. + transport.onRequest( + 'jsonClaude:openParkedFork', + ( + _ctx, + parentSessionId: string, + forkSessionId: string + ): { ok: boolean; reason?: string } => { + if (!parentSessionId || !forkSessionId) { + return { ok: false, reason: 'missing args' } + } + const parent = store.getSnapshot().state.jsonClaude.sessions[parentSessionId] + if (!parent) return { ok: false, reason: 'unknown session' } + const fork = collectParkedForks(parent.entries).find( + (f) => f.forkSessionId === forkSessionId + ) + if (!fork) return { ok: false, reason: 'not a fork of this conversation' } + const existing = findTab(parent.worktreePath, forkSessionId) + if (existing) { + panesFSM.selectTab(parent.worktreePath, existing.paneId, forkSessionId) + return { ok: true } + } + + const model = findJsonClaudeTabModel(parentSessionId) + panesFSM.addTab( + parent.worktreePath, + { + id: forkSessionId, + type: 'json-claude', + label: fork.topic.slice(0, 40), + sessionId: forkSessionId, + mode: 'awake', + ...(model ? { model } : {}) + }, + // Same pane as the parent chat, so the fork lands as a sibling tab + // next to the conversation it came from rather than in pane zero. + findTab(parent.worktreePath, parentSessionId)?.paneId + ) + startJsonClaudeSession(forkSessionId, parent.worktreePath) + if (fork.prompt) { + jsonClaudeManager.send( + forkSessionId, + wrapAutomatedMessage('chat-fork', fork.prompt) + ) + } + return { ok: true } + } + ) + transport.onRequest( 'jsonClaude:openAuthLoginTab', (_ctx, worktreePath: string): { ok: true; tabId: string } | { ok: false; error: string } => { @@ -4846,6 +4964,7 @@ async function runBoot(): Promise { resolveCallerScope, hasForkableTranscript, getConversationForkEnabled: () => config.conversationForkEnabled === true, + parkChatFork, getBrowserPerms: () => ({ enabled: config.browserToolsEnabled !== false, mode: config.browserToolsMode === 'view' ? 'view' : 'full' diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index 7e948a23b..b917b2946 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -654,6 +654,8 @@ export function buildBackend( req('jsonClaude:rewindTo', id, entryId), forkJsonClaudeAt: (id: string, entryId: string) => req('jsonClaude:forkAt', id, entryId), + openParkedFork: (parentSessionId: string, forkSessionId: string) => + req('jsonClaude:openParkedFork', parentSessionId, forkSessionId), openJsonClaudeAuthLoginTab: (worktreePath: string) => req('jsonClaude:openAuthLoginTab', worktreePath), setJsonClaudePermissionMode: ( diff --git a/src/renderer/components/JsonModeChat.tsx b/src/renderer/components/JsonModeChat.tsx index ef7b90c62..c9737e163 100644 --- a/src/renderer/components/JsonModeChat.tsx +++ b/src/renderer/components/JsonModeChat.tsx @@ -31,10 +31,18 @@ import { ShieldAlert, Sparkles, GitBranch, - GitBranchPlus + GitBranchPlus, + GitFork } from 'lucide-react' import { openForkIntoWorktree } from './NewWorktreeScreen' -import { useAliases, useJsonClaudeSession, useSettings, useWorktrees } from '../store' +import { + useAliases, + useAppState, + useJsonClaudeSession, + useSettings, + useWorktrees +} from '../store' +import { getLeaves } from '../../shared/state/terminals' import { useBackend } from '../backend' import { useJsonClaudeApprovals } from '../hooks/useJsonClaudeApprovals' import { JsonClaudeApprovalCard } from './JsonClaudeApprovalCard' @@ -50,6 +58,7 @@ import { JsonModeChatImageThumb } from './JsonModeChatImageThumb' import { fuzzyMatch } from '../fuzzy' import { worktreeHandle } from '../../shared/state/worktrees' import { CLAUDE_MODELS } from '../../shared/agent-registry' +import { collectParkedForks, isForkChatTool } from '../../shared/fork-chat' import { QUESTION_TOOL_NAME, type JsonClaudeAutomationSource, @@ -769,6 +778,13 @@ function automationLabel( brand: false } } + if (source === 'chat-fork') { + return { + label: 'Forked Thread', + note: 'the agent parked this tangent · you opened it', + brand: true + } + } return { label: 'Ness · CI failure', note: 'sent automatically', brand: false } } @@ -1126,7 +1142,11 @@ function renderEntries( rows.push({ key: `${entry.entryId}-${block.id || 'tu'}`, entryId: entry.entryId, - type: 'tool', + // A parked fork is an offer to the user, not agent bookkeeping. + // Filing it as 'text' keeps it out of the collapsed tool group + // it would otherwise be buried in, which is the whole point of + // rendering it at the spot the agent had the thought. + type: isForkChatTool(block.name) ? 'text' : 'tool', toolName: block.name, hasError: !!result?.isError, hasPendingApproval: @@ -1161,7 +1181,11 @@ function renderEntries( subAgentDescendantHasPendingApproval, backgroundAgent: block.id ? ctx.backgroundAgents[block.id] - : undefined + : undefined, + fork: { + parentSessionId: ctx.sessionId, + worktreePath: ctx.worktreePath + } }) )} {ctx.approvalCard(block.id)} @@ -1626,6 +1650,23 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo const entriesHydrated = session?.entriesHydrated ?? false const deferredEntries = useDeferredValue(entries) const find = useFindController(entries, scrollRef) + // Forks this conversation parked that have no tab yet. Deliberately + // scoped to this chat and derived from its transcript — a global forks + // inbox is the thing this feature is trying not to become. Runs off the + // deferred entries so a streaming turn doesn't rewalk the transcript per + // token. + const paneTree = useAppState((s) => s.terminals.panes[worktreePath]) + const unopenedForks = useMemo(() => { + const parked = collectParkedForks(deferredEntries) + if (parked.length === 0) return parked + const openTabIds = new Set() + if (paneTree) { + for (const leaf of getLeaves(paneTree)) { + for (const t of leaf.tabs) openTabIds.add(t.id) + } + } + return parked.filter((f) => !openTabIds.has(f.forkSessionId)) + }, [deferredEntries, paneTree]) const outerDivRef = useRef(null) // Document-level Cmd+F so the shortcut works from anywhere in the app — // sidebar, composer, tab bar, etc. Every mounted JsonModeChat installs @@ -2704,6 +2745,35 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo )} + {unopenedForks.length > 0 && ( +
+ + + {unopenedForks.length} parked fork + {unopenedForks.length === 1 ? '' : 's'}: + + + {unopenedForks.map((f) => ( + + ))} + +
+ )} {session && session.sessionToolApprovals.length > 0 && (
auto-allowing: diff --git a/src/renderer/components/json-mode-cards/ForkCard.tsx b/src/renderer/components/json-mode-cards/ForkCard.tsx new file mode 100644 index 000000000..e999e0416 --- /dev/null +++ b/src/renderer/components/json-mode-cards/ForkCard.tsx @@ -0,0 +1,107 @@ +// The card an agent leaves behind when it calls fork_chat. Unlike every +// other card here it isn't a log of something that happened — it's an +// offer. The fork exists as a jsonl on disk and nothing else until this +// button is pressed, so the card is the entire mechanism by which a +// parked tangent becomes a running one. + +import { useState } from 'react' +import { GitFork, Loader2 } from 'lucide-react' +import type { ToolCardProps } from './index' +import { parseForkChatInput, parseForkSessionId } from '../../../shared/fork-chat' +import { HighlightedText } from '../JsonModeChatFind' +import { getBackend } from '../../backend' +import { useAppState } from '../../store' +import { getLeaves } from '../../../shared/state/terminals' + +/** Whether a tab already exists for this fork — which is also the answer + * to "has the user opened it", since opening is what creates the tab. + * Selects the one worktree's tree so an unrelated pane change elsewhere + * doesn't re-render every fork card in the transcript. */ +function useForkIsOpen(worktreePath: string, forkSessionId: string): boolean { + const tree = useAppState((s) => s.terminals.panes[worktreePath]) + if (!tree) return false + return getLeaves(tree).some((leaf) => + leaf.tabs.some((t) => t.id === forkSessionId) + ) +} + +export function ForkCard({ block, result, fork }: ToolCardProps): JSX.Element { + const { topic, prompt } = parseForkChatInput(block.input) + const forkSessionId = parseForkSessionId(result?.content) + const isOpen = useForkIsOpen(fork?.worktreePath ?? '', forkSessionId ?? '') + const [busy, setBusy] = useState(false) + + // No fork id means the call errored or is still in flight — there is + // nothing to open, so fall back to reporting what was asked for. + const openable = !!forkSessionId && !!fork && !result?.isError + + return ( +
+
+
+ +
+
+ {result?.isError + ? 'fork not parked' + : isOpen + ? 'forked thread · opened' + : 'forked thread · parked'} +
+
+ +
+ {prompt && ( +
+ +
+ )} + {result?.isError && ( +
+ +
+ )} +
+ {openable && ( + + )} +
+
+ ) +} diff --git a/src/renderer/components/json-mode-cards/index.tsx b/src/renderer/components/json-mode-cards/index.tsx index 8f532df69..7cc541d84 100644 --- a/src/renderer/components/json-mode-cards/index.tsx +++ b/src/renderer/components/json-mode-cards/index.tsx @@ -40,6 +40,10 @@ export interface ToolCardProps { * Such a call resolves its tool_result immediately, so the card needs * this to know it's still working rather than instantly finished. */ backgroundAgent?: JsonClaudeBackgroundAgent + /** Only the fork_chat case uses this. The card has to act on the + * conversation that parked the fork, not on the fork itself, so it + * needs the host chat's identity — no other card does. */ + fork?: { parentSessionId: string; worktreePath: string } } export function basename(p: string): string { @@ -176,8 +180,13 @@ import { GlobCard } from './GlobCard' import { TodoWriteCard } from './TodoWriteCard' import { TaskCard } from './TaskCard' import { GenericToolCard } from './GenericToolCard' +import { ForkCard } from './ForkCard' +import { isForkChatTool } from '../../../shared/fork-chat' export function dispatchToolCard(props: ToolCardProps): JSX.Element { + // Matched ahead of the switch because the tool name carries an MCP + // server prefix that has two spellings (see isForkChatTool). + if (isForkChatTool(props.block.name)) return switch (props.block.name) { case 'Read': return diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 051c75355..76f65882e 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -664,6 +664,10 @@ export interface ElectronAPI { id: string, entryId: string ): Promise<{ ok: boolean; newSessionId?: string; reason?: string }> + openParkedFork( + parentSessionId: string, + forkSessionId: string + ): Promise<{ ok: boolean; reason?: string }> openJsonClaudeAuthLoginTab( worktreePath: string ): Promise<{ ok: true; tabId: string } | { ok: false; error: string }> diff --git a/src/shared/fork-chat.test.ts b/src/shared/fork-chat.test.ts new file mode 100644 index 000000000..de406d073 --- /dev/null +++ b/src/shared/fork-chat.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest' +import { + collectParkedForks, + formatForkResult, + isForkChatTool, + parseForkChatInput, + parseForkSessionId +} from './fork-chat' +import type { JsonClaudeChatEntry } from './state/json-claude' + +const UUID = '3f2504e0-4f89-11d3-9a0c-0305e82c3301' +const UUID2 = '9c1e77aa-1111-4222-8333-444455556666' + +function forkCall(toolUseId: string, topic: string): JsonClaudeChatEntry { + return { + entryId: `a-${toolUseId}`, + kind: 'assistant', + timestamp: 0, + blocks: [ + { + type: 'tool_use', + id: toolUseId, + name: 'mcp__ness-control__fork_chat', + input: { topic, prompt: `look into ${topic}` } + } + ] + } +} + +function forkResult( + toolUseId: string, + forkSessionId: string, + opts?: { isError?: boolean } +): JsonClaudeChatEntry { + return { + entryId: `r-${toolUseId}`, + kind: 'tool_result', + timestamp: 0, + blocks: [ + { + type: 'tool_result', + toolUseId, + content: formatForkResult({ forkSessionId, topic: 't', remaining: 1 }), + isError: opts?.isError + } + ] + } +} + +describe('isForkChatTool', () => { + it('matches the ness-control tool', () => { + expect(isForkChatTool('mcp__ness-control__fork_chat')).toBe(true) + }) + + it('matches transcripts written before the Ness rename', () => { + expect(isForkChatTool('mcp__harness-control__fork_chat')).toBe(true) + }) + + it('rejects other tools and missing names', () => { + expect(isForkChatTool('mcp__ness-control__create_worktree')).toBe(false) + expect(isForkChatTool('fork_chat')).toBe(false) + expect(isForkChatTool(undefined)).toBe(false) + }) +}) + +describe('fork id round trip', () => { + it('recovers the session id the result was formatted with', () => { + const text = formatForkResult({ + forkSessionId: UUID, + topic: 'drop the cron', + remaining: 2 + }) + expect(parseForkSessionId(text)).toBe(UUID) + }) + + it('names the topic and the remaining budget for the model', () => { + const text = formatForkResult({ + forkSessionId: UUID, + topic: 'drop the cron', + remaining: 2 + }) + expect(text).toContain('"drop the cron"') + expect(text).toContain('2 more') + }) + + it('says so when the budget is spent', () => { + const text = formatForkResult({ + forkSessionId: UUID, + topic: 'x', + remaining: 0 + }) + expect(text).toContain('last one') + }) + + it('returns null for results with no marker', () => { + expect(parseForkSessionId('forking is disabled')).toBeNull() + expect(parseForkSessionId(undefined)).toBeNull() + expect(parseForkSessionId('(fork id: not-a-uuid)')).toBeNull() + }) +}) + +describe('collectParkedForks', () => { + it('pairs each fork call with the session id from its result', () => { + const forks = collectParkedForks([ + forkCall('t1', 'cron'), + forkResult('t1', UUID), + forkCall('t2', 'flaky test'), + forkResult('t2', UUID2) + ]) + expect(forks.map((f) => f.forkSessionId)).toEqual([UUID, UUID2]) + expect(forks.map((f) => f.topic)).toEqual(['cron', 'flaky test']) + expect(forks[0].prompt).toBe('look into cron') + }) + + it('skips a call whose result has not landed yet', () => { + expect(collectParkedForks([forkCall('t1', 'cron')])).toEqual([]) + }) + + it('skips a call whose result errored', () => { + const forks = collectParkedForks([ + forkCall('t1', 'cron'), + forkResult('t1', UUID, { isError: true }) + ]) + expect(forks).toEqual([]) + }) + + it('ignores other ness-control tool calls', () => { + const other: JsonClaudeChatEntry = { + entryId: 'x', + kind: 'assistant', + timestamp: 0, + blocks: [ + { + type: 'tool_use', + id: 't9', + name: 'mcp__ness-control__create_worktree', + input: {} + } + ] + } + expect(collectParkedForks([other])).toEqual([]) + }) +}) + +describe('parseForkChatInput', () => { + it('trims both fields', () => { + expect(parseForkChatInput({ topic: ' cron ', prompt: ' look ' })).toEqual({ + topic: 'cron', + prompt: 'look' + }) + }) + + it('falls back to a placeholder topic rather than rendering blank', () => { + expect(parseForkChatInput({ prompt: 'look' }).topic).toBe('Untitled fork') + expect(parseForkChatInput(undefined).topic).toBe('Untitled fork') + }) + + it('drops non-string fields', () => { + expect(parseForkChatInput({ topic: 12, prompt: null }).prompt).toBe('') + }) +}) diff --git a/src/shared/fork-chat.ts b/src/shared/fork-chat.ts new file mode 100644 index 000000000..2a1455c17 --- /dev/null +++ b/src/shared/fork-chat.ts @@ -0,0 +1,117 @@ +// Agent-initiated chat forking. An agent that notices a tangent mid-answer +// calls `fork_chat`, which copies its transcript so far into a new session +// and parks it. Nothing spawns until the human opens it. +// +// There is deliberately no slice backing a proposed fork. Everything the +// card needs is already durable in the parent's transcript: the topic and +// prompt are the tool_use input, the new session id is in the tool_result, +// and "has it been opened yet" is answered by the panes tree. A parallel +// record would be a second source of truth that a restart could disagree +// with, so the result text is the only carrier — hence the marker below. + +import type { JsonClaudeChatEntry } from './state/json-claude' + +/** Bare tool name. The wire name is MCP-prefixed; see `isForkChatTool`. */ +export const FORK_TOOL_NAME = 'fork_chat' + +/** How many forks may sit unopened on one conversation before the tool + * starts refusing. The point of a fork is that ignoring it is free, which + * stops being true once the header count is a chore to clear. */ +export const MAX_UNOPENED_FORKS = 3 + +const FORK_ID_PATTERN = /\(fork id: ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)/ + +/** Matches both the `ness-control` prefix and the pre-rename + * `harness-control` one, for the same reason the automated-message parser + * accepts both: transcripts written by older builds are still on disk. */ +export function isForkChatTool(name: string | undefined): boolean { + if (!name) return false + return ( + name === `mcp__ness-control__${FORK_TOOL_NAME}` || + name === `mcp__harness-control__${FORK_TOOL_NAME}` + ) +} + +/** The model-facing result of a `fork_chat` call. Written by the control + * server rather than the bridge so the id marker is produced by the same + * module that parses it back out. */ +export function formatForkResult(args: { + forkSessionId: string + topic: string + remaining: number +}): string { + const budget = + args.remaining > 0 + ? `You can park ${args.remaining} more before the tool starts refusing.` + : 'That is the last one this conversation can park until some are opened.' + return ( + `Parked a fork of this conversation for "${args.topic}" (fork id: ${args.forkSessionId}). ` + + 'It holds everything said here so far, and its first message is queued. ' + + 'Nothing is running: it stays idle until the user opens it from the card in this chat. ' + + `Say one line about what you noticed and carry on with the original task — do not start working on the tangent yourself. ${budget}` + ) +} + +/** Recover the forked session id from a `fork_chat` tool_result. Returns + * null for a result that failed or predates the marker, which the card + * renders as a fork it can no longer open. */ +export function parseForkSessionId(content: string | undefined): string | null { + if (!content) return null + const m = FORK_ID_PATTERN.exec(content) + return m ? m[1] : null +} + +export interface ForkChatInput { + topic: string + prompt: string +} + +export interface ParkedFork extends ForkChatInput { + /** tool_use id of the `fork_chat` call that parked it. */ + toolUseId: string + forkSessionId: string +} + +/** Every fork this conversation has parked, oldest first. Main counts these + * against the cap; the chat header counts the ones with no tab yet. Calls + * whose result failed or lacks the id marker are skipped — there is nothing + * to open. */ +export function collectParkedForks( + entries: readonly JsonClaudeChatEntry[] +): ParkedFork[] { + const resultById = new Map() + for (const entry of entries) { + if (entry.kind !== 'tool_result' || !entry.blocks) continue + for (const b of entry.blocks) { + if (b.type === 'tool_result' && b.toolUseId && !b.isError) { + resultById.set(b.toolUseId, b.content || '') + } + } + } + const forks: ParkedFork[] = [] + for (const entry of entries) { + if (!entry.blocks) continue + for (const b of entry.blocks) { + if (b.type !== 'tool_use' || !b.id || !isForkChatTool(b.name)) continue + const forkSessionId = parseForkSessionId(resultById.get(b.id)) + if (!forkSessionId) continue + forks.push({ + toolUseId: b.id, + forkSessionId, + ...parseForkChatInput(b.input) + }) + } + } + return forks +} + +/** Read the tool_use input back out, tolerating the arbitrary shape a model + * can produce. Both fields are required by the schema, but a malformed call + * should render a degraded card rather than throw inside a transcript. */ +export function parseForkChatInput( + input: Record | undefined +): ForkChatInput { + const topic = typeof input?.topic === 'string' ? input.topic.trim() : '' + const prompt = typeof input?.prompt === 'string' ? input.prompt.trim() : '' + return { topic: topic || 'Untitled fork', prompt } +} diff --git a/src/shared/state/json-claude.ts b/src/shared/state/json-claude.ts index 857cc575e..7baaaa126 100644 --- a/src/shared/state/json-claude.ts +++ b/src/shared/state/json-claude.ts @@ -78,13 +78,15 @@ export type JsonClaudeAutomationSource = | 'worktree-message' | 'worktree-kickoff' | 'worktree-autoname' + | 'chat-fork' const AUTOMATION_SOURCES: readonly string[] = [ 'ci-failure', 'merge-conflict', 'worktree-message', 'worktree-kickoff', - 'worktree-autoname' + 'worktree-autoname', + 'chat-fork' ] /** Model-facing footer appended inside the sentinel and stripped back off on @@ -100,7 +102,12 @@ const AUTOMATION_GUIDANCE: Partial> = // name interpolated: the footer is stripped by exact match on parse, and // the agent can read its own branch from git anyway. 'worktree-autoname': - 'The message above is the user\'s own kickoff prompt, typed by them. Ness created this worktree from it and guessed the branch name — before you start the work, call the `rename_worktree` tool from the ness-control MCP server once with a better `branchName` (kebab-case, e.g. `fix-login-redirect`) and a short Title Case `alias` for the sidebar (e.g. "Login Redirect"). One call, no need to ask first, then get on with the task. If you do not have that tool, skip this and carry on.' + 'The message above is the user\'s own kickoff prompt, typed by them. Ness created this worktree from it and guessed the branch name — before you start the work, call the `rename_worktree` tool from the ness-control MCP server once with a better `branchName` (kebab-case, e.g. `fix-login-redirect`) and a short Title Case `alias` for the sidebar (e.g. "Login Redirect"). One call, no need to ask first, then get on with the task. If you do not have that tool, skip this and carry on.', + // You are the fork. Everything above this message is inherited context in + // which a DIFFERENT question was being answered — without this note the + // natural reading is that the user just changed the subject on you. + 'chat-fork': + 'You are a fork of the conversation above. You wrote this note to yourself after noticing the tangent while working on something else, and the user has now chosen to pursue it. The transcript above is context you already hold, not the task — the task is only what this message asks for. The original conversation is still running separately and is still handling its own work, so do not pick that back up. You are in the same worktree and on the same branch, so uncommitted changes made above are really on disk.' } const AUTOMATION_TAG = 'ness-automated-message' From 25b0d48e33f0a0e5d1b09c68616ec3470002060a Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Thu, 27 Aug 2026 21:16:56 -0400 Subject: [PATCH 2/2] Let the user ask a side question without derailing the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-initiated fork_chat tool only fires when the model happens to notice a tangent, which is rare. This adds the user-initiated half: an "ask in fork" button (Cmd/Ctrl+Alt+Enter) next to Send that copies the conversation into a sibling tab, asks the draft there, and switches to it — leaving the original untouched even mid-stream, so a side question no longer costs an interrupt. Co-Authored-By: Claude Opus 4.7 --- src/main/index.ts | 118 ++++++++++++++++++----- src/renderer/build-backend.ts | 5 + src/renderer/components/JsonModeChat.tsx | 48 +++++++++ src/renderer/types.ts | 5 + src/shared/state/json-claude.ts | 11 ++- 5 files changed, 163 insertions(+), 24 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index e4b49ff00..64d01cf50 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1112,6 +1112,48 @@ function unopenedForks(parentSessionId: string, worktreePath: string): ParkedFor ) } +/** Give a fork session a tab next to its parent, start it resumed from the + * copied transcript, and hand it its first message. Shared by both fork + * paths — the agent's parked card and the user's side question — so they + * can't drift on pane placement, model inheritance, or send ordering. The + * send immediately after start mirrors the kickoff path: the manager + * queues until the subprocess is ready. */ +function launchForkTab(args: { + parentSessionId: string + worktreePath: string + forkSessionId: string + label: string + message: string + images?: Array<{ mediaType: string; data: string; path: string }> +}): void { + const model = findJsonClaudeTabModel(args.parentSessionId) + panesFSM.addTab( + args.worktreePath, + { + id: args.forkSessionId, + type: 'json-claude', + label: args.label, + sessionId: args.forkSessionId, + mode: 'awake', + ...(model ? { model } : {}) + }, + // Same pane as the parent chat, so the fork lands as a sibling tab next + // to the conversation it came from rather than in pane zero. + findTab(args.worktreePath, args.parentSessionId)?.paneId + ) + startJsonClaudeSession(args.forkSessionId, args.worktreePath) + if (args.message) { + jsonClaudeManager.send(args.forkSessionId, args.message, args.images) + } +} + +/** First line of a chat message, clamped to something that fits a tab. */ +function tabLabelFromText(text: string, fallback: string): string { + const firstLine = text.trim().split('\n')[0].trim() + if (!firstLine) return fallback + return firstLine.length > 40 ? `${firstLine.slice(0, 39)}…` : firstLine +} + /** Agent-initiated fork. Copies the caller's transcript as it stands into a * fresh session id and stops there — no tab, no subprocess, nothing running. * A parked fork is a jsonl on disk plus the tool_result naming it; it only @@ -3862,32 +3904,64 @@ function registerIpcHandlers(): void { return { ok: true } } - const model = findJsonClaudeTabModel(parentSessionId) - panesFSM.addTab( - parent.worktreePath, - { - id: forkSessionId, - type: 'json-claude', - label: fork.topic.slice(0, 40), - sessionId: forkSessionId, - mode: 'awake', - ...(model ? { model } : {}) - }, - // Same pane as the parent chat, so the fork lands as a sibling tab - // next to the conversation it came from rather than in pane zero. - findTab(parent.worktreePath, parentSessionId)?.paneId - ) - startJsonClaudeSession(forkSessionId, parent.worktreePath) - if (fork.prompt) { - jsonClaudeManager.send( - forkSessionId, - wrapAutomatedMessage('chat-fork', fork.prompt) - ) - } + launchForkTab({ + parentSessionId, + worktreePath: parent.worktreePath, + forkSessionId, + label: tabLabelFromText(fork.topic, 'Fork'), + message: fork.prompt + ? wrapAutomatedMessage('chat-fork', fork.prompt) + : '' + }) return { ok: true } } ) + // The user's own side question. Forks, opens, and starts in one step — + // there's no parking stage and no cap, because they asked it rather than + // an agent guessing they might want to. Sending this instead of typing + // into the parent IS the instruction: the parent is left completely + // untouched, including a turn that's still streaming, which is what makes + // this usable while an agent is mid-task. + transport.onRequest( + 'jsonClaude:forkForSideQuestion', + ( + _ctx, + parentSessionId: string, + text: string, + images?: Array<{ mediaType: string; data: string; path: string }> + ): { ok: boolean; forkSessionId?: string; reason?: string } => { + const question = (text || '').trim() + if (!parentSessionId || !question) { + return { ok: false, reason: 'missing args' } + } + const parent = store.getSnapshot().state.jsonClaude.sessions[parentSessionId] + if (!parent) return { ok: false, reason: 'unknown session' } + + const outcome = forkTranscript({ + sourceSessionId: parentSessionId, + sourceWorktreePath: parent.worktreePath, + destWorktreePath: parent.worktreePath + }) + if (!outcome.ok || !outcome.newSessionId) { + return { ok: false, reason: outcome.reason || 'could not copy the transcript' } + } + log( + 'json-claude', + `side-question fork ${outcome.newSessionId} from ${parentSessionId}` + ) + launchForkTab({ + parentSessionId, + worktreePath: parent.worktreePath, + forkSessionId: outcome.newSessionId, + label: tabLabelFromText(question, 'Side question'), + message: wrapAutomatedMessage('chat-side-question', question), + images + }) + return { ok: true, forkSessionId: outcome.newSessionId } + } + ) + transport.onRequest( 'jsonClaude:openAuthLoginTab', (_ctx, worktreePath: string): { ok: true; tabId: string } | { ok: false; error: string } => { diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index b917b2946..bcd5af03a 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -656,6 +656,11 @@ export function buildBackend( req('jsonClaude:forkAt', id, entryId), openParkedFork: (parentSessionId: string, forkSessionId: string) => req('jsonClaude:openParkedFork', parentSessionId, forkSessionId), + forkForSideQuestion: ( + parentSessionId: string, + text: string, + images?: Array<{ mediaType: string; data: string; path: string }> + ) => req('jsonClaude:forkForSideQuestion', parentSessionId, text, images), openJsonClaudeAuthLoginTab: (worktreePath: string) => req('jsonClaude:openAuthLoginTab', worktreePath), setJsonClaudePermissionMode: ( diff --git a/src/renderer/components/JsonModeChat.tsx b/src/renderer/components/JsonModeChat.tsx index c9737e163..a2d4b43fb 100644 --- a/src/renderer/components/JsonModeChat.tsx +++ b/src/renderer/components/JsonModeChat.tsx @@ -785,6 +785,14 @@ function automationLabel( brand: true } } + // Body is the user's own question — only the framing was Ness's. + if (source === 'chat-side-question') { + return { + label: 'Side Question', + note: 'forked off the conversation above', + brand: true + } + } return { label: 'Ness · CI failure', note: 'sent automatically', brand: false } } @@ -1297,6 +1305,12 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo ? `${modKeySymbol}⇧↵` : `${modKeySymbol}Shift+Enter` const interruptSendHotkeyAria = `${modKeyWord}+Shift+Enter` + // Ask-as-fork. Alt rather than Shift because Shift is already taken by + // interrupt & send, and a bare Alt+Enter has to stay a newline. + const forkSendHotkeyLabel = isMac + ? `${modKeySymbol}⌥↵` + : `${modKeySymbol}Alt+Enter` + const forkSendHotkeyAria = `${modKeyWord}+Alt+Enter` const composerPlaceholder = sendOnEnter ? 'Message Claude — Enter to send, Shift+Enter for newline' : `Message Claude — ${modKeyWord}+Enter to send` @@ -2357,6 +2371,20 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo backend.sendJsonClaudeMessage(sessionId, outgoing.text, outgoing.images) } + /** Ask this somewhere else. Forks the conversation and delivers the draft + * to the copy, which opens as a sibling tab and takes focus. This chat is + * left exactly as it was — including a turn that's still streaming, which + * is the point: a side question no longer costs you an interrupt. */ + function sendAsSideQuestion(): void { + const outgoing = takeDraft() + if (!outgoing) return + void backend.forkForSideQuestion( + sessionId, + outgoing.text, + outgoing.images + ) + } + async function attachImageFile( file: File, sourcePath: string | null @@ -2921,6 +2949,13 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo interruptAndSend() return } + // Cmd/Ctrl+Alt+Enter → ask as a side question. Checked + // before wantsSend for the same reason as the branch above. + if ((e.metaKey || e.ctrlKey) && e.altKey) { + e.preventDefault() + if (conversationForkEnabled) sendAsSideQuestion() + return + } const wantsSend = sendOnEnter ? !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey : e.metaKey || e.ctrlKey @@ -2992,6 +3027,19 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo )} )} + {conversationForkEnabled && ( + + )}