Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions resources/mcp-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) : ''
Expand Down
64 changes: 64 additions & 0 deletions src/main/control-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,6 +67,16 @@ let captureResult: CaptureResult | null = null
* evaluated (load failed, or the eval timed out). */
let domResult: () => Promise<string | null> = 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<ControlServerDeps['parkChatFork']>(() =>
parkFailure
? { ok: false as const, error: parkFailure }
: { ok: true as const, forkSessionId: FORK_SESSION, remaining: 2 }
)

const deps: ControlServerDeps = {
getRepoRoots: () => ['/repo'],
getWorktreeBase: () => 'remote',
Expand All @@ -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: {
Expand Down Expand Up @@ -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 () => ({
Expand Down
49 changes: 49 additions & 0 deletions src/main/control-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading