Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`/compact` or an automatic compaction shows "Compacting…" under the session's
name with a spinner, instead of the finished turn's check or a spinner that
names nothing. Needs the next lich-plugin release.
- **A subagent card can open subagent cards of its own.** A Claude Code worker
that splits its task now opens each part as a card too, filed beside it, and
reports back once those parts are done. One level of that is allowed: a card
opened by a card keeps its own subagents inside Claude Code, and is told so
when it starts. Needs lich-plugin 0.19.0 or later; with an older plugin a
worker's subagents stay inside Claude Code as before.

### Changed

Expand All @@ -23,6 +29,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **A subagent card reports back only once the work it handed off is back.** A
Claude Code worker that handed part of its task to another session used to
report "handed it off" as its result the moment its turn ended, and closed
before the other session answered. Its report now waits for that answer, and
for a result that reached it mid-turn, and the worker stays open until then.
If the session it handed work to is closed first, the worker is told so
instead of waiting forever.
- **Dragging a footer item in Settings keeps its shape.** The chip under the pointer
stays on one line at its own size instead of wrapping into a taller, narrower block.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ reads what each CLI writes down and no two of them write down the same things.
| 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 |
| A message from another session arrives without being typed into the terminal | yes | no | no | no | no | no | no | no |
| A subagent the agent starts runs as a lich session with its own card, in the asking session's checkout, hands its full report back and closes once done | yes | no | no | no | no | no | no | no |
| A subagent the agent starts runs as a lich session with its own card, in the asking session's checkout, hands its full report back and closes once done; its own subagents get cards too, one level down | yes | no | no | no | no | no | no | no |
| Ask a running session a side question without stopping it (`lich ask`) | yes | no | no | no | no | no | no | no |
| Context, cost and plan usage as the CLI measured them itself | yes | no | no | no | no | no | no | no |
| The terminal's status line shows who the session owes an answer, who it is waiting on, and answers ready to collect | yes | no | no | no | no | no | no | no |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
| 以 ultracode 模式打开会话 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
| 用 `lich control` 或智能体操控运行中的会话:发提示、停止、换模型、推理强度、斜杠命令 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
| 其他会话发来的消息直接送达,不经终端键入 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
| 智能体启动的子智能体作为 lich 会话运行,有自己的卡片,在发起会话的检出目录中工作,交回完整报告后自动关闭 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
| 智能体启动的子智能体作为 lich 会话运行,有自己的卡片,在发起会话的检出目录中工作,交回完整报告后自动关闭;它自己启动的子智能体也有卡片,再往下一层为止 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
| 向运行中的会话问一个旁支问题,不打断它(`lich ask`) | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
| 上下文、花费和套餐用量取 CLI 自己量出的数 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
| 终端状态栏显示本会话欠谁一个回复、在等谁,以及待收取的回复 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
Expand Down
22 changes: 16 additions & 6 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,10 @@ work when nobody knows it and that the call site never shows. The mechanism and
`[lich]` note through the asker's mod. The worker's own mod answers for it (docs/hooks/mod-answer.md), so it is
handed the task with no ticket and its last message is its report; a worker whose mod had not polled when the
task went in, or runs a lich-plugin older than 0.17.0, is handed the ticket instead, and one whose final turn
ends blank, aborted, in an API error or a refusal leaves its errand open with nothing reported. Closing a worker
ends blank, aborted, in an API error or a refusal leaves its errand open with nothing reported. A worker's report
waits for the errands it sent itself (`relay.awaitsOutcomeLocked`), since Claude Code does not list them as
background work; one of those that ages out on `ticketTTL` reaches no prompt, and the report then waits for the
next turn something else starts in the worker. Closing a worker
ends its errand without a word to the asker (`relay.SessionClosed`, wired before the PTY is killed so the
SessionEnd its CLI reports finds nothing to call unanswered). Both reach the asker the way Claude Code's own background agent's
completion does, one `● lich session "…" finished` line on screen and the note whole in the model's context
Expand All @@ -511,15 +514,22 @@ work when nobody knows it and that the call site never shows. The mechanism and
a prompt from the plugin. Without a mod polling there the note is typed short and the report waits
for `wait_for_answer`, because typing a long report into a TUI is what the mod route exists to avoid. A worker in
the asker's checkout is parked once it answered and the turn it answered in ended (`relay.SetWorkerFinished`,
`spawn.CloseFinishedWorker`), so typing into its card after its report means resuming it from the history; one
`spawn.CloseFinishedWorker`) with nothing it sent still out, so typing into its card after its report means resuming it from the history; one
on its own worktree, pinned, stopped with Esc, holding another errand, or whose asker is gone stays open. Esc in
the asker leaves its workers running, as it leaves Claude Code's own background agents (measured on 2.1.289);
the mod's TaskStop stops one, Claude Code's `ctrl+x ctrl+k` does not, and the asker's status line counts them
from `lich sessions --json` every 5 seconds. The
worker spawns with `LICH_SUBAGENT_CARDS=off`, so its own subagents stay native. The other seven
from `lich sessions --json` every 5 seconds. Cards
nest two levels deep (`maxSubagentDepth`, internal/terminal): a worker turns its own subagents into cards,
and a worker it opened spawns with `LICH_SUBAGENT_CARDS=off`, so that one's stay native, and is briefed to fan
out with them rather than with new lich sessions; nothing but that briefing stops it opening plain sessions
with `lich open`. A worker needs lich-plugin 0.19.0 to nest, since an older mod tells a worker only by
`LICH_SUBAGENT_CARDS=off`; under one every worker keeps its subagents native. A nested card's report climbs
back through each worker above it, and each answers only once its own outcomes are in, so a card stopped deep
in the tree reaches its asker as a stop, not as silence. Closing a worker leaves the cards it opened running,
as it leaves its own. The other seven
providers have no mod system, so nothing can take a subagent call from them, and their subagents stay inside their CLI. On Claude
Code too, a typed agent (Explore, Plan, a plugin's), a workflow step, another plugin's spawn, a remote one and a
worker's own subagents stay native, and the worker is reached with `send_to_session` or `lich send`, never with
Code too, a typed agent (Explore, Plan, a plugin's), a workflow step, another plugin's spawn, a remote one and the
subagents of a worker two levels down stay native, and the worker is reached with `send_to_session` or `lich send`, never with
Claude Code's `SendMessage`. Settings › Providers › Claude Code turns it off with "Subagents as lich sessions"
(`store.SubagentCards`), which reaches the mod as `LICH_SUBAGENT_CARDS=off` at spawn, so a session already
running keeps the value it started with.
Expand Down
18 changes: 14 additions & 4 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,17 +452,27 @@ It answers to "auth-fix" and to "auth-fix-9f8e". Its agent may still be starting
`close_session`, the caller's TaskStop or the card's own close: the caller
hears nothing and nothing waits in its inbox, since whoever closed it
stopped the work. A caller holding the line on the ticket hears `stopped`
(exit 3).
(exit 3), and so does a caller that is itself a worker somebody waits on,
through its inbox: its own report waits for this outcome.
- **reports only after the work it handed off is back**: while an errand it
sent is open, or a result for it is unread, its mod's report is ignored,
and the turn that outcome starts is the one that answers.
- **closes once it is done** when it runs in the caller's checkout: after it
answers the errand and the turn it answered in ends, lich parks it the way
`lich close` does, like a native subagent that returned its result. A worker
on a worktree of its own stays open, since that checkout holds its work, and
so does one the user pinned, one whose caller is gone, one with another
errand still open at it, and one whose turn was stopped with Esc.
errand still open at it, one with an errand of its own still out, and one
whose turn was stopped with Esc.
- tells the caller, in one short note per block, when it is **waiting on a
permission** prompt in its card.
- spawns with `LICH_SUBAGENT_CARDS=off` on every start, a resume included, so
its own subagents stay inside its CLI: a card does not open cards.
- runs **one level below the caller**, recorded on its row and handed to the
plugin as `LICH_SUBAGENT_DEPTH` on every start, a resume included. A
Claude Code worker one level down still turns its own general-purpose
subagents into cards (lich-plugin 0.19.0 or later); one two levels down, a
card a card opened, spawns with `LICH_SUBAGENT_CARDS=off` and keeps them
inside its CLI, and its briefing says so. Under an older plugin every
worker keeps them native.
`--json` keeps its shape: the session and its `delivery`.
- `--folder` files the new session under that sidebar folder, written before the
window hears of the session, so the card arrives in the folder's block instead
Expand Down
25 changes: 16 additions & 9 deletions docs/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,22 @@ the agent in the PTY calls to reach the sessions beside it. That surface has its
own contract in [cli.md](../cli.md).

`LICH_SUBAGENT_CARDS=off` is the fifth, and the only one set from a setting: a
Claude Code session gets it when the user turned "Subagents as lich sessions"
off, and the plugin's agent-cards mod then leaves every subagent native. A
session opened with `lich open --subagent` gets it too, whatever the setting,
on every spawn of it including a resume, so a worker's own subagents never open
cards of their own. It is absent otherwise, which the mod reads as on. The mod
also reads it as "this session may be a worker" and reports its turns'
answers ([mod-answer.md](mod-answer.md)); lich ignores the report from a
session with no subagent errand open, which is every session the setting
turned it on for.
Claude Code session gets it when its subagents stay native, which is when the
user turned "Subagents as lich sessions" off, and in a session opened with
`lich open --subagent` that runs as deep as lich nests cards (two levels: a card
a card opened) or whose installed plugin is older than 0.19.0, since such a mod
tells a worker only by this variable. The plugin's agent-cards mod then leaves
every subagent native. It is absent otherwise, which the mod reads as on.

`LICH_SUBAGENT_DEPTH` is the sixth, set on every Claude Code session lich
starts: `0` for one nobody opened as a subagent, `n` for a worker opened `n`
`--subagent` levels down, on every spawn of it including a resume and a fork.
The mod reads a depth above 0 as "this session is a worker" and reports its
turns' answers ([mod-answer.md](mod-answer.md)); lich ignores the report from a
session with no subagent errand open. A plugin from 0.19.0 that finds it absent
is under an older lich, and falls back to reading `LICH_SUBAGENT_CARDS=off` as
both "leave subagents native" and "this session may be a worker", the way every
release before it did.

### The plugin release

Expand Down
30 changes: 25 additions & 5 deletions docs/hooks/mod-answer.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ otherwise: an ordinary session, a worker whose errand was already answered
`reply_to_session` itself), and one holding two subagent errands, where nothing
says which one the text belongs to.

It also ignores the report of a worker still awaiting an outcome of its own:
an errand it sent to another session (a subagent card of its own, or a
`send_to_session` that handed back a ticket) is still open, or a result for it
has not been read by a turn of its yet. None of those is in Claude Code's
`background_tasks`, so the turn that handed the work off ends with the list
empty and reports "I handed it off". The outcome reaches the worker's prompt
and starts the turn whose report answers the errand.

Both sides test against the payloads in
[`fixtures/mod-answer.jsonl`](fixtures/mod-answer.jsonl).

Expand All @@ -51,8 +59,10 @@ Both sides test against the payloads in
| `classic.Stop` on the main loop, then `turn.complete` | none (no mod system) | none (no mod system) | none (no mod system) | none (no mod system) | none (no mod system) | none (no mod system) | none (no mod system) |

The mod posts once per main-loop turn, and only from a worker: a session
spawned with `LICH_SUBAGENT_CARDS=off` (README, Shared transport), which every
`--subagent` session is. A turn is reported when all of these hold, measured on
whose `LICH_SUBAGENT_DEPTH` is above 0 (README, Shared transport), which every
`--subagent` session is. A mod from 0.19.0 under a lich that sets no depth, and
every mod before 0.19.0, takes `LICH_SUBAGENT_CARDS=off` for that instead, which
lich then sets on every worker. A turn is reported when all of these hold, measured on
Claude Code 2.1.289:

- `classic.Stop` fired without an `agent_id` (the main loop, not a subagent or
Expand All @@ -75,9 +85,15 @@ Claude Code 2.1.289:
wired with `terminal.SetWorkerAnswer`).
- **Answer** (`relay.WorkerAnswered`): finds the one open subagent errand at the
session, delivered or lapsed, and answers it through `Reply`, so the caller
gets the report the way it gets one sent with `reply_to_session`. A worker in
its caller's checkout is closed once the answer is in and its turn has
ended, whichever of the two lands first.
gets the report the way it gets one sent with `reply_to_session`, unless the
worker still awaits an outcome of its own (`relay.awaitsOutcomeLocked`). A
result counts as read once a turn of the worker's starts after the worker was
told about it (`inboxEntry.seen`). A worker in its caller's checkout is
closed once the answer is in, its turn has ended and nothing it sent is still
out, whichever lands last.
- **A worker's own worker closed** (`relay.SessionClosed`): the stop is filed in
the inbox of a caller that is itself a worker somebody waits on, so it
resumes and answers instead of waiting on an errand that ended in silence.
- **The task** (`relay.handOff`): a subagent errand handed to a worker whose
mod polls from a plugin release that posts here (`agentplugin.ModAnswerRelease`)
is composed without the ticket and the reply instructions, and is marked as
Expand All @@ -102,3 +118,7 @@ Claude Code 2.1.289:
instructions; its mod still reports, and whichever answer lands first wins.
- **A mod older than 0.17.0 does not post here.** Its worker is handed the
ticket, as before.
- **An errand the worker sent that ages out holds its answer.** An ordinary
errand with nobody holding the line is dropped after an hour without
activity (`ticketTTL`) and nothing reaches the worker's prompt, so its report
waits for the next turn something else starts there.
19 changes: 19 additions & 0 deletions internal/agentplugin/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ func runsSubagentCards(version string) bool {
return !semver.Less(version, SubagentCardsRelease)
}

// NestedSubagentCardsRelease is the first lich-plugin release whose mod tells a
// worker by LICH_SUBAGENT_DEPTH rather than by LICH_SUBAGENT_CARDS=off
// (hooks/worker-answer.js), so a worker can open cards of its own and still
// answer its errand. An older one reads a worker without that variable as a
// session that answers nothing.
const NestedSubagentCardsRelease = "0.19.0"

// ClaudeNestsSubagentCards reports whether the installed Claude Code plugin is a
// release a worker can open cards from. It reads Claude Code's plugin state on
// each call.
func ClaudeNestsSubagentCards() bool {
version, ok := claudeInstalledVersion()
return ok && nestsSubagentCards(version)
}

func nestsSubagentCards(version string) bool {
return !semver.Less(version, NestedSubagentCardsRelease)
}

// claudeInstalledVersion reads the plugin's installed version from Claude
// Code's plugin state, or ("", false) when absent or unreadable.
func claudeInstalledVersion() (string, bool) {
Expand Down
17 changes: 17 additions & 0 deletions internal/agentplugin/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@ package agentplugin

import "testing"

// A worker opens cards only under a release that tells it a worker by its
// depth, since an older one would stop answering for it.
func TestNestsSubagentCardsFromTheFirstNestingRelease(t *testing.T) {
cases := map[string]bool{
"0.18.4": false,
"0.19.0-rc.1": false,
"0.19.0": true,
"0.19.2": true,
"not-a-semver": false,
}
for version, want := range cases {
if got := nestsSubagentCards(version); got != want {
t.Errorf("nestsSubagentCards(%q) = %v, want %v", version, got, want)
}
}
}

// The briefing points Claude Code at its own Agent tool only once the
// installed plugin is a release whose mod turns subagents into lich cards.
func TestRunsSubagentCardsFromTheFirstCardRelease(t *testing.T) {
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,13 +245,17 @@ func (s *spawnStore) AddSessionFrom(_, _, _, _, _ string, _ int, _, _ string) er
return nil
}

func (s *spawnStore) SetSessionSubagent(string) error {
func (s *spawnStore) SetSessionSubagentDepth(string, int) error {
s.mu.Lock()
defer s.mu.Unlock()
s.subagent = true
return nil
}

func (s *spawnStore) SessionSubagentDepth(string) int {
return 0
}

func (s *spawnStore) SetSessionModel(_, model string) error {
s.mu.Lock()
defer s.mu.Unlock()
Expand Down
Loading
Loading