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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
`<branch> · working tree`. Pick it and the new worktree starts on the same
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
| 从命令面板搜索对话里说过的内容 | 有 | 有 | 有 | 有 | 有 | 有 | 无 | 有 |
| 在命令面板的 History 页恢复在 lich 之外开始的对话 | 有 | 有 | 有 | 有 | 有 | Linux;macOS 和 Windows 未测 | 有 | 有 |
| 把一段对话分叉到新的 worktree | 有 | 有 | 无 | 有 | 无 | 无 | 无 | 无 |
| 把同一个任务交给多个智能体并行完成(各自一个 worktree),再合并出最好的版本 | 有 | 无 | 无 | 无 | 有 | 无 | 有 | 有 |
| 按指定的推理强度打开会话 | 有 | 有 | 有 | 无 | 有 | 无 | 写在模型名里 | 有 |
| 以 ultracode 模式打开会话 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
| 用 `lich control` 或智能体操控运行中的会话:发提示、停止、换模型、推理强度、斜杠命令 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
Expand Down Expand Up @@ -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) 写着每一处背后量到了什么。
Expand Down
2 changes: 2 additions & 0 deletions docs/adding-a-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ everything on the id (`provider.<id>.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 |

Expand Down
23 changes: 23 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/hooks/session-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions frontend/src/components/sidebar/AgentPicker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-1.5">
<Label className="text-xs uppercase tracking-wide">{label}</Label>
<div className="flex flex-wrap gap-1">
{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
<label
key={provider.id}
className={cn(
"flex items-center gap-1.5 rounded-md py-1 pr-2.5 pl-1.5 text-sm transition-colors",
dead && "opacity-50",
!dead && "cursor-pointer",
on ? "bg-accent text-accent-foreground" : "text-muted-foreground",
!on && !dead && "hover:bg-accent/50",
)}
>
<Checkbox
checked={on}
disabled={dead}
onCheckedChange={() => onToggle(provider.id)}
/>
<ProviderIcon kind={provider.id} size={14} />
{provider.name}
</label>
)
})}
</div>
{out.length > 0 && (
<span className="text-xs text-muted-foreground">
{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.
</span>
)}
</div>
)
}
149 changes: 149 additions & 0 deletions frontend/src/components/sidebar/BaseBranchPicker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div className="px-2 pb-1 pt-2 text-2xs font-semibold tracking-wider text-muted-foreground uppercase">
{title} <span className="font-normal">({items.length})</span>
</div>
{items.map((item) => (
<button
key={item.value}
type="button"
role="option"
aria-selected={base === item.value}
onClick={() => onSelect(item.value)}
className={cn(
"flex w-full flex-col items-start rounded-md px-2 py-1.5 text-left font-mono text-xs outline-none transition-colors",
base === item.value ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
<span className="w-full truncate">{item.label}</span>
{item.note && (
<span className="w-full truncate font-sans text-2xs text-muted-foreground">
{item.note}
</span>
)}
</button>
))}
</div>
)
}

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 (
<div className="flex min-h-0 flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs uppercase tracking-wide">Base branch</Label>
{!branches && !loadError && (
<span className="text-xs text-muted-foreground">Loading branches…</span>
)}
</div>
<SearchInput
value={filter}
onChange={(e) => onFilter(e.target.value)}
onKeyDown={onSearchKeyDown}
placeholder="Search branches…"
aria-label="Search base branches"
autoComplete="off"
spellCheck={false}
className="font-mono"
/>
<div
ref={listRef}
role="listbox"
aria-label="Base branch"
className="min-h-0 flex-1 overflow-y-auto rounded-md border border-input p-1"
>
<Group
title={`This ${sourceNoun}`}
items={
vis.tree
? [
{
value: rowValue("tree", vis.tree.path),
label: `${vis.tree.branch} · working tree`,
note: `Same commit, plus the ${count(vis.tree.files, "file")} this ${sourceNoun} has not committed.`,
},
]
: []
}
base={base}
onSelect={setBase}
/>
<Group
title="Worktrees"
items={vis.worktrees.map((wt) => ({
value: rowValue("worktree", wt.path),
label: wt.name,
}))}
base={base}
onSelect={setBase}
/>
<Group
title="Local branches"
items={vis.local.map((branch) => ({
value: rowValue("local", branch),
label: branch,
}))}
base={base}
onSelect={setBase}
/>
<Group
title="Remote branches"
items={vis.remote.map((branch) => ({
value: rowValue("remote", branch),
label: branch,
}))}
base={base}
onSelect={setBase}
/>
{noMatches && (
<div className="px-2 py-6 text-center text-xs text-muted-foreground">
{filter.trim() ? (
<>
No branches match{" "}
<span className="font-mono text-foreground/80">{filter.trim()}</span>
</>
) : (
"No branches found"
)}
</div>
)}
</div>
</div>
)
}
Loading
Loading