From 5ece75c0798ec028f9243c7e6f49ce89ee94e73e Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 8 Oct 2026 12:58:54 -0300 Subject: [PATCH 1/2] feat: race one task across several agents and consolidate the best of them --- CHANGELOG.md | 14 + README.md | 5 + README.zh-CN.md | 4 + docs/adding-a-provider.md | 1 + docs/ceilings.md | 21 + .../src/components/sidebar/AgentPicker.tsx | 68 +++ .../components/sidebar/BaseBranchPicker.tsx | 149 ++++++ .../components/sidebar/ConsolidateDialog.tsx | 203 ++++++++ .../components/sidebar/RaceAgentsDialog.tsx | 208 ++++++++ .../src/components/sidebar/RaceDialogs.tsx | 52 ++ .../src/components/sidebar/RaceDoneBanner.tsx | 46 ++ .../src/components/sidebar/SessionCard.tsx | 17 + .../src/components/sidebar/SessionGroup.tsx | 39 ++ .../components/sidebar/SessionGroupHeader.tsx | 31 +- .../components/sidebar/SessionKeepItem.tsx | 31 ++ .../sidebar/SessionLaunchMenuItems.tsx | 12 +- .../src/components/sidebar/SessionSidebar.tsx | 18 + .../sidebar/WorktreeCloseDialogs.tsx | 97 +++- .../src/components/sidebar/WorktreeDialog.tsx | 448 ++---------------- .../components/sidebar/WorktreeNameField.tsx | 56 +++ .../sidebar/agent-race-flow.test.tsx | 214 +++++++++ .../components/sidebar/race-dialogs.test.tsx | 168 +++++++ .../src/components/sidebar/useAgentRace.ts | 111 +++++ .../src/components/sidebar/useBaseBranch.ts | 257 ++++++++++ .../src/components/sidebar/useRaceFlow.ts | 90 ++++ .../components/sidebar/useWorktreeClose.ts | 87 +++- .../src/components/sidebar/useWorktreeName.ts | 90 ++++ .../sidebar/worktree-close-adopted.test.tsx | 5 + frontend/src/lib/issue.test.ts | 10 +- frontend/src/lib/issue.ts | 8 +- frontend/src/lib/rpc.ts | 10 + frontend/src/lib/session/agent-race.test.ts | 155 ++++++ frontend/src/lib/session/agent-race.ts | 164 +++++++ frontend/src/lib/session/use-panes.ts | 10 +- frontend/src/lib/session/use-session-agent.ts | 6 + .../src/lib/terminal/write-at-prompt.test.ts | 57 ++- frontend/src/lib/terminal/write-at-prompt.ts | 38 +- frontend/src/providers/projects-context.ts | 8 +- frontend/src/providers/projects.tsx | 30 +- internal/relay/prompt.go | 23 + internal/relay/prompt_test.go | 81 ++++ 41 files changed, 2710 insertions(+), 432 deletions(-) create mode 100644 frontend/src/components/sidebar/AgentPicker.tsx create mode 100644 frontend/src/components/sidebar/BaseBranchPicker.tsx create mode 100644 frontend/src/components/sidebar/ConsolidateDialog.tsx create mode 100644 frontend/src/components/sidebar/RaceAgentsDialog.tsx create mode 100644 frontend/src/components/sidebar/RaceDialogs.tsx create mode 100644 frontend/src/components/sidebar/RaceDoneBanner.tsx create mode 100644 frontend/src/components/sidebar/SessionKeepItem.tsx create mode 100644 frontend/src/components/sidebar/WorktreeNameField.tsx create mode 100644 frontend/src/components/sidebar/agent-race-flow.test.tsx create mode 100644 frontend/src/components/sidebar/race-dialogs.test.tsx create mode 100644 frontend/src/components/sidebar/useAgentRace.ts create mode 100644 frontend/src/components/sidebar/useBaseBranch.ts create mode 100644 frontend/src/components/sidebar/useRaceFlow.ts create mode 100644 frontend/src/components/sidebar/useWorktreeName.ts create mode 100644 frontend/src/lib/session/agent-race.test.ts create mode 100644 frontend/src/lib/session/agent-race.ts create mode 100644 internal/relay/prompt.go create mode 100644 internal/relay/prompt_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 879302ed8..047b5102f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Headless runs and sub-agents are left out, and so is a session lich opened and you closed for good. +- **Race one task across several agents, then consolidate the best of them.** + **Race agents…** in the New session menu takes a task and two or more agents, + makes a worktree for each off one base, files them in a folder named after the + race, and puts every agent side by side in a split. Each agent gets the task + once it is at its own prompt, so a trust question on its first start in the + worktree is yours to answer on its card first. When they are done, + **Consolidate…** on the folder opens one more agent in a new worktree with a + prompt listing every raced branch, asking it to combine the best of each and + say what it took from where. Once that agent finishes its turn, the folder + offers **Remove the race**, which removes the raced worktrees in one + confirmation that lists what each one never committed; their branches stay. + **Keep this, remove the others** on a card does the same when one agent simply + won. Antigravity, opencode and Crush cannot race yet. + - **Take uncommitted work into a new worktree.** When the project's checkout has changes it has not committed, the **+** dialog lists it as a base of its own, ` · working tree`. Pick it and the new worktree starts on the same diff --git a/README.md b/README.md index 881bc39de..b898fcb82 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ reads what each CLI writes down and no two of them write down the same things. | Search the conversation from the palette | yes | yes | yes | yes | yes | yes | no | yes | | Resume a conversation started outside lich, from the palette's History tab | yes | yes | yes | yes | yes | Linux; macOS and Windows untested | yes | yes | | Fork a conversation into a new worktree | yes | yes | no | yes | no | no | no | no | +| Race one task across several agents, a worktree each, and consolidate the best of them | yes | yes | no | no | yes | no | yes | yes | | Open a session at a chosen reasoning effort | yes | yes | yes | no | yes | no | in the model name | yes | | Open a session with ultracode on | yes | no | no | no | no | no | no | no | | Drive a running session from `lich control` or an agent: prompt, stop, model, effort, slash command | yes | no | no | no | no | no | no | no | @@ -144,6 +145,10 @@ closed for good stays out of it, except one closed before this release: lich tel its own Claude Code conversations apart by the name it starts them under, and the other seven CLIs write down nothing that could tell them apart, so theirs show up there once. +Antigravity, opencode and Crush sit out a race: a racer gets its task once it tells +lich it is at its own prompt, and none of the three says so before its first turn. +Antigravity and Crush ask a question first in a new worktree, which the task would +answer, and opencode goes quiet mid-boot, where a task typed then never reaches it. Every gap is deliberate, none of them is lich withholding something the CLI reports, and [`docs/ceilings.md`](docs/ceilings.md) says what was measured behind diff --git a/README.zh-CN.md b/README.zh-CN.md index 70a4a7b4b..27f69f1b1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -96,6 +96,7 @@ | 从命令面板搜索对话里说过的内容 | 有 | 有 | 有 | 有 | 有 | 有 | 无 | 有 | | 在命令面板的 History 页恢复在 lich 之外开始的对话 | 有 | 有 | 有 | 有 | 有 | Linux;macOS 和 Windows 未测 | 有 | 有 | | 把一段对话分叉到新的 worktree | 有 | 有 | 无 | 有 | 无 | 无 | 无 | 无 | +| 把同一个任务交给多个智能体并行完成(各自一个 worktree),再合并出最好的版本 | 有 | 有 | 无 | 无 | 有 | 无 | 有 | 有 | | 按指定的推理强度打开会话 | 有 | 有 | 有 | 无 | 有 | 无 | 写在模型名里 | 有 | | 以 ultracode 模式打开会话 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 | | 用 `lich control` 或智能体操控运行中的会话:发提示、停止、换模型、推理强度、斜杠命令 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 | @@ -124,6 +125,9 @@ History 页的 **Outside lich** 分组列出在你某个项目的检出目录里 lich 打开过、又被你彻底关掉的会话不会出现在里面,只有本版本之前关掉的例外:lich 靠启动时 给 Claude Code 起的名字认出自己的对话,其他七个 CLI 没有写下任何能区分的东西,所以它们的 这类对话会在那里出现一次。 +Antigravity、opencode 和 Crush 不参加并行比拼:参赛的智能体要先告诉 lich 自己已经到了提示符, +才会收到任务,而这三个在第一轮之前都不会这样报告。Antigravity 和 Crush 在新的 worktree 里会先问 +一个问题,任务会落到那个问题上;opencode 启动到一半会安静一阵,这时键入的任务到不了它那里。 每一处缺口都是刻意的,没有一处是 lich 藏起了 CLI 已经报告的东西, [`docs/ceilings.md`](docs/ceilings.md) 写着每一处背后量到了什么。 diff --git a/docs/adding-a-provider.md b/docs/adding-a-provider.md index 5ea901075..9d91337a6 100644 --- a/docs/adding-a-provider.md +++ b/docs/adding-a-provider.md @@ -83,6 +83,7 @@ everything on the id (`provider..bin`, `.enabled`, `.sandbox`, | `frontend/src/components/ProviderIcon.tsx` | a brand path, or a lucide fallback | | `frontend/src/lib/session/delegate-prompt.ts` | `TOOL_KINDS` only if it is handed lich's tools at spawn | | `frontend/src/lib/session/tool-label.ts` | a rule only if it spells MCP tool names in a shape not already handled | +| `frontend/src/lib/session/agent-race.ts` | its `RACES` entry: `true` only if its hook reports session-start from the agent's own prompt before the first turn, which is what a race's task waits for; `false` keeps it out of races. Measure it in a fresh worktree; the record is exhaustive over `ProviderKind` | | `frontend/src/lib/session/hands-on.ts` | a rung in `RUNG`: whether the hands-on clock hears that provider through a turn its hooks open, or only through its tool calls. The record is exhaustive over `ProviderKind`, so `tsc` refuses a provider that has not picked a side | | `frontend/src/lib/api-types.ts` | nothing, unless the change moves a JSON tag — the `DetectedProvider` mirror is hand-owned and moves in the same commit | diff --git a/docs/ceilings.md b/docs/ceilings.md index abb8f6c9d..f30b99e3f 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -99,6 +99,27 @@ work when nobody knows it and that the call site never shows. The mechanism and lich has no "setup finished" signal to hang an automatic start on — `terminal.Ready` answers a different question, going false again for every turn the agent takes. So the card is one gesture, which is also what keeps eight worktrees from meaning eight dev servers. +- **A race's task waits for proof of the agent's own prompt, and three providers cannot race** + (`frontend/src/lib/session/agent-race.ts`, `RACES`): in a fresh worktree Claude Code, Codex, + Cursor CLI and Antigravity ask whether the folder is trusted and Crush asks whether to initialize the + project, and `terminal.Ready` reads that quiet screen as a prompt. The Enter that sends the task answers the + question instead: Claude Code and Cursor CLI exit, Codex and Antigravity lose the task to a second screen, + Crush runs an init turn (measured 2026-10-08). So a race waits for the provider's session-start report, + which its hook sends only from the agent's own prompt. Antigravity, Crush and opencode report nothing before + their first turn, so they are drawn dead in the race and consolidate dialogs. opencode asks no question, but + under the load of a race it goes quiet mid-boot, `Ready` latches there, and the task lands on the terminal + before its TUI does (measured in the same run). The wait is the handoff's five minutes: a trust question left + unanswered that long, or a provider without lich-plugin's hooks, gets the task never and a toast instead. + `lich open --worktree --prompt` still types after `Ready` alone and falls into the same question. +- **A race is remembered by the window that started it** (`agent-race.ts`, `rememberRaceTask`): nothing about + a race is stored beyond its folder. The task the consolidation prompt quotes and the consolidating session + the folder offers to keep live in memory, so after a reload the prompt has a line to fill in, and the + folder's "Consolidated in" banner is gone; "Keep this, remove the others" on the consolidation's card is + the same removal. The consolidation's base is preselected as the project's current branch, not the base + the race used. +- **A race's agents share one sandbox answer and run the setup script at once** + (`frontend/src/components/sidebar/RaceAgentsDialog.tsx`): the confinement box is read off the first agent's + rung and recorded on every racer, and `.lich/setup-worktree.sh` starts in all K worktrees together. - **The cost readout bills per `(session, transcript)`** (`internal/pricing`, `internal/terminal/usage_cost.go`): a conversation forked inside the PTY bills its copied history twice — lich's own resume continues the same transcript and is unaffected — and each sub-agent's own transcript is counted in, so one unreadable or diff --git a/frontend/src/components/sidebar/AgentPicker.tsx b/frontend/src/components/sidebar/AgentPicker.tsx new file mode 100644 index 000000000..f0930b55e --- /dev/null +++ b/frontend/src/components/sidebar/AgentPicker.tsx @@ -0,0 +1,68 @@ +import { ProviderIcon } from "@/components/ProviderIcon" +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" +import type { ProviderState } from "@/lib/providers-store" +import { canRace } from "@/lib/session/agent-race" +import type { ProviderKind } from "@/lib/session/sessions" +import { cn } from "@/lib/utils" + +// "Crush", "Antigravity and Crush", "Antigravity, opencode and Crush". +const listed = (names: string[]): string => + names.length < 2 + ? names.join("") + : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` + +interface AgentPickerProps { + label: string + /** The providers New session offers, in its order. */ + providers: ProviderState[] + picked: ProviderKind[] + onToggle: (kind: ProviderKind) => void +} + +// AgentPicker is the row of providers a race or a consolidation runs. A +// provider that cannot race is drawn dead with the reason under the row, +// SessionForkItem's idiom: a user who finds Crush missing has no way to learn +// that it was withheld rather than forgotten. +export function AgentPicker({ label, providers, picked, onToggle }: AgentPickerProps) { + const out = providers.filter((provider) => !canRace(provider.id)) + return ( +
+ +
+ {providers.map((provider) => { + const on = picked.includes(provider.id) + const dead = !canRace(provider.id) + return ( + // biome-ignore lint/a11y/noLabelWithoutControl: the Checkbox inside is the control; base-ui renders it as a span the wrapping label activates + + ) + })} +
+ {out.length > 0 && ( + + {listed(out.map((provider) => provider.name))} cannot run here:{" "} + {out.length === 1 ? "its" : "their"} first start in a worktree asks a question lich cannot + see past. + + )} +
+ ) +} diff --git a/frontend/src/components/sidebar/BaseBranchPicker.tsx b/frontend/src/components/sidebar/BaseBranchPicker.tsx new file mode 100644 index 000000000..2240ede38 --- /dev/null +++ b/frontend/src/components/sidebar/BaseBranchPicker.tsx @@ -0,0 +1,149 @@ +import { SearchInput } from "@/components/common/SearchInput" +import { Label } from "@/components/ui/label" +import { cn, count } from "@/lib/utils" +import { type BaseBranch, rowValue } from "./useBaseBranch" + +interface GroupProps { + title: string + items: ReadonlyArray<{ value: string; label: string; note?: string }> + base: string + onSelect: (value: string) => void +} + +function Group({ title, items, base, onSelect }: GroupProps) { + if (items.length === 0) { + return null + } + return ( +
+
+ {title} ({items.length}) +
+ {items.map((item) => ( + + ))} +
+ ) +} + +interface BaseBranchPickerProps { + picker: BaseBranch + /** Whose uncommitted work the working-tree row carries, for its wording. */ + sourceNoun: "session" | "checkout" +} + +// BaseBranchPicker draws useBaseBranch: the search, then the working-tree row, +// existing worktrees where they are offered, local and remote branches. +export function BaseBranchPicker({ picker, sourceNoun }: BaseBranchPickerProps) { + const { + branches, + loadError, + base, + setBase, + filter, + onFilter, + onSearchKeyDown, + vis, + noMatches, + listRef, + } = picker + return ( +
+
+ + {!branches && !loadError && ( + Loading branches… + )} +
+ onFilter(e.target.value)} + onKeyDown={onSearchKeyDown} + placeholder="Search branches…" + aria-label="Search base branches" + autoComplete="off" + spellCheck={false} + className="font-mono" + /> +
+ + ({ + value: rowValue("worktree", wt.path), + label: wt.name, + }))} + base={base} + onSelect={setBase} + /> + ({ + value: rowValue("local", branch), + label: branch, + }))} + base={base} + onSelect={setBase} + /> + ({ + value: rowValue("remote", branch), + label: branch, + }))} + base={base} + onSelect={setBase} + /> + {noMatches && ( +
+ {filter.trim() ? ( + <> + No branches match{" "} + {filter.trim()} + + ) : ( + "No branches found" + )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/sidebar/ConsolidateDialog.tsx b/frontend/src/components/sidebar/ConsolidateDialog.tsx new file mode 100644 index 000000000..5e6f72a15 --- /dev/null +++ b/frontend/src/components/sidebar/ConsolidateDialog.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState } from "react" +import { WorktreeSandboxRow } from "@/components/sidebar/WorktreeSandboxRow" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import type { ProviderState } from "@/lib/providers-store" +import { ProjectService } from "@/lib/rpc" +import { canRace, consolidationPrompt, type ConsolidationSource } from "@/lib/session/agent-race" +import type { ProviderKind } from "@/lib/session/sessions" +import { useSandboxChoice, type SandboxAnswer } from "@/lib/use-sandbox-choice" +import { errorText } from "@/lib/utils" +import { AgentPicker } from "./AgentPicker" +import { BaseBranchPicker } from "./BaseBranchPicker" +import type { RaceBase } from "./useAgentRace" +import { useBaseBranch } from "./useBaseBranch" +import { WorktreeNameField } from "./WorktreeNameField" +import { useWorktreeName } from "./useWorktreeName" + +interface ConsolidateDialogProps { + /** The race's folder, or null while the dialog is closed. */ + folder: string | null + onClose: () => void + projectPath: string + projectId: string + /** The race's worktrees, which the prompt lists. */ + paths: string[] + /** The race's task when this window remembers it, "" when it does not. */ + task: string + providers: ProviderState[] + defaultProvider: ProviderKind + currentBranch: string + onConsolidate: ( + name: string, + base: RaceBase, + sandbox: SandboxAnswer, + prompt: string, + agent: ProviderKind, + ) => Promise +} + +// ConsolidateDialog is "Consolidate…" on a race's folder: one agent in a new +// worktree, handed a prompt that lists every raced branch and asks for the best +// of each in one implementation. The prompt is written out here and editable; +// until it is edited it follows the base picked below, which it names. +export function ConsolidateDialog({ + folder, + onClose, + projectPath, + projectId, + paths, + task, + providers, + defaultProvider, + currentBranch, + onConsolidate, +}: ConsolidateDialogProps) { + const open = folder !== null + const [agent, setAgent] = useState(null) + const [sources, setSources] = useState([]) + const [edited, setEdited] = useState(null) + const [submitError, setSubmitError] = useState("") + const [submitting, setSubmitting] = useState(false) + const naming = useWorktreeName(projectPath, open) + const sandbox = useSandboxChoice(agent ?? defaultProvider, projectId, true, open) + const picker = useBaseBranch({ + open, + projectPath, + currentBranch, + forkOf: null, + offerResume: false, + onEnter: () => void submit(), + }) + const choice = picker.choice() + const baseName = choice && !choice.resume ? choice.branch : currentBranch + const prompt = edited ?? consolidationPrompt(baseName, sources, task) + const { setName } = naming + + useEffect(() => { + if (!open) { + return + } + setAgent(canRace(defaultProvider) ? defaultProvider : null) + setName(`${folder}-consolidated`) + setEdited(null) + setSubmitError("") + setSubmitting(false) + }, [open, folder, defaultProvider, setName]) + + // The branch each raced worktree is on, asked of git rather than read off the + // card: a card's label can be renamed, a checkout's branch is what the diff + // the prompt asks for is taken against. + useEffect(() => { + if (!open) { + return + } + let stale = false + ProjectService.BranchesOf(paths) + .then((byPath) => { + if (!stale) { + setSources(paths.map((path) => ({ branch: byPath[path] ?? path, path }))) + } + }) + .catch((err: unknown) => { + if (!stale) { + setSubmitError(errorText(err)) + } + }) + return () => { + stale = true + } + }, [open, paths]) + + const submit = async () => { + if (!choice || choice.resume || !agent || !naming.newBranch || submitting) { + return + } + setSubmitting(true) + setSubmitError("") + try { + await onConsolidate( + naming.newBranch, + { branch: choice.branch, remote: choice.remote, carryFrom: choice.carryFrom }, + sandbox.answer, + prompt, + agent, + ) + } catch (err) { + setSubmitError(errorText(err)) + setSubmitting(false) + } + } + + return ( + !next && onClose()}> + + + Consolidate {folder} + + A new worktree with an agent that reads all {paths.length} and combines the best of + each. + + + +
+ + Branch: {naming.newBranch || ""}} + /> +
+ +