diff --git a/CHANGELOG.md b/CHANGELOG.md index 879302ed8..06853dcab 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. Codex, 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 @@ -95,6 +109,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Work handed to a new Claude Code or Cursor CLI session no longer answers its + trust question.** In a fresh worktree both first ask whether to trust the + folder, and a task sent then (from `lich open --prompt`, another agent, a + scheduled prompt, an issue or pull request handed over) used to land on that + question and quit the session. lich now waits until the agent itself reports + it is at its prompt, so the task arrives once you have answered the question + on its card. oh-my-pi and Kiro CLI get the same wait. Codex, Antigravity, Crush + and opencode still can't tell lich when they are ready, so text sent to their + first start in a worktree can still be lost to a question. + - **Dragging a session card in a worktree keeps the card its own size.** The worktree's long path no longer stretches the cards wider than the sidebar and pushes the rest of the list sideways while the drag is in flight. diff --git a/README.md b/README.md index 881bc39de..f188142f5 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 | no | 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,11 @@ 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. +Codex, 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 four says so before its first +turn. Codex, 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..c20dfec6d 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,10 @@ History 页的 **Outside lich** 分组列出在你某个项目的检出目录里 lich 打开过、又被你彻底关掉的会话不会出现在里面,只有本版本之前关掉的例外:lich 靠启动时 给 Claude Code 起的名字认出自己的对话,其他七个 CLI 没有写下任何能区分的东西,所以它们的 这类对话会在那里出现一次。 +Codex、Antigravity、opencode 和 Crush 不参加并行比拼:参赛的智能体要先告诉 lich 自己已经到了 +提示符,才会收到任务,而这四个在第一轮之前都不会这样报告。Codex、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..1fcd0216c 100644 --- a/docs/adding-a-provider.md +++ b/docs/adding-a-provider.md @@ -83,6 +83,8 @@ 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 | +| `internal/terminal/startgate.go` | an entry in `startsAtPrompt` only if its hook reports session-start from the agent's own prompt before the first turn, past any trust question; that report is then what makes its sessions ready for work. Measure it in a fresh worktree | +| `frontend/src/lib/session/agent-race.ts` | its `RACES` entry, the same answer as `startsAtPrompt`: `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..1ff618701 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -99,6 +99,29 @@ 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 session is ready for work only once its agent reports from its own prompt, where it can** + (`internal/terminal/startgate.go`): 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 a quiet screen is all + `terminal.Ready` used to read. Anything typed then (a relayed task, `lich open --prompt`, a scheduled + prompt, an issue or pull request handed over, a race's task) went into the question, and the Enter behind + it answered it: Claude Code and Cursor CLI exit, Codex and Antigravity lose the text to a second screen, + Crush runs an init turn (measured 2026-10-08). Claude Code, Cursor CLI, oh-my-pi and Kiro CLI now stay + unready until their session-start report, which their hooks send only from the agent's prompt. That hold + needs lich-plugin's hooks in the provider: without them nothing would ever release it, so nothing is held + and the old trap is back. Codex (0.161.0), Antigravity, Crush and opencode report nothing before their + first turn, Codex not even at its prompt in a trusted folder, so a quiet screen is still all lich has for + them, a trust, hooks or init question included; opencode asks nothing, but under load it goes quiet + mid-boot and text typed then lands before its TUI does. All four are left out of races + (`frontend/src/lib/session/agent-race.ts`, `RACES`). +- **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/docs/hooks/session-start.md b/docs/hooks/session-start.md index d467de804..e4dba3990 100644 --- a/docs/hooks/session-start.md +++ b/docs/hooks/session-start.md @@ -46,6 +46,13 @@ deprecated alias and the defaulted `provider`. | | | | | | | | | session row, and mark the card as running | | | | | | | | | | `provider` (the `session-agent` app event)| +For Claude Code, Cursor CLI, oh-my-pi and Kiro CLI the report is also +what makes a session ready for work: lich holds anything it would type at the +session (a relayed task, a scheduled prompt, a handoff) until the first report +of its PTY, because these hooks run only from the agent's own prompt, past the +trust question a fresh directory raises (`internal/terminal/startgate.go`). A +hook that stops reporting leaves those sessions unable to take typed work. + `SessionStart` fires on startup, resume, `/clear` and compaction. A resume reports the resumed session's id and overwrites the stored value — lich always holds the id of the provider session currently in the card. 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 || ""}} + /> +
+ +