An agent running in a lich session has a shell and nothing else — no window, no
RPC client, no knowledge of this app. The lich command is the surface it uses
to reach the sessions beside it: list them, hand one a task, answer one that
handed it a task, open a new one.
It is not only for agents. The same commands run from any shell on the machine —
a script, a scheduled job, the user's own terminal — which is what makes lich
automatable without a provider in the loop. --json is there for exactly that.
Two commands are about lich itself rather than about the sessions: lich doctor
says whether lich would start on this machine and what would stop it, and
lich rage collects a bug report from the same facts plus the logs. Both are
the surface for the failure where there is no window to ask through.
Agents mostly do not run it as a command. lich registers the same operations as MCP tools for the providers that can be told at spawn, so they are in the agent's own tool list without anybody being told anything — see Registration. The command line stays because a tool list only reaches an agent: a script, a scheduled job or a plain terminal has no tool list, and the reply path has to work even where no server was registered.
This document is contract-first, exactly as hooks/ is.
A flag, a tool name or an output line that moves here can break the companion
plugin (lichdotdev/lich-plugin) and
whatever the user automated on top, neither of which this repo can see. Move the
contract first.
What semver promises about this surface, and what it leaves out, is in stability.md.
lich delivers a message by typing it at the target's prompt, exactly as the user would. It never reads the answer off the terminal: a TUI's output is boxes, spinners and ANSI, and parsing it would mean a parser per provider, each hostage to that provider's next release.
So the message lich types asks the agent to report back — with the
reply_to_session tool where lich registered one, and always by running
lich reply <ticket>, which needs nothing registered at all. The answer is
written by the agent that produced it. That is the whole reason this works on
every provider lich runs, rather than only on the ones that ship a messaging
channel of their own: anything that can run a shell command can answer.
Every command that addresses a session reads the coordinates lich already
injects into each PTY (see the hooks README) and posts to the
same loopback listener the window uses. lich doctor and lich rage are the
exceptions: they read the machine, not a running lich.
| Var | Purpose |
|---|---|
LICH_PORT |
endpoint port (loopback) |
LICH_TOKEN |
auth token (?token=) |
LICH_SESSION_ID |
the card this command is running in — the sender |
LICH_BIN |
the path of the lich this session belongs to |
LICH_PROMPT_LANG |
language of text written for the agent (en) |
POST http://127.0.0.1:${LICH_PORT}/rpc/relay.<Method>?token=${LICH_TOKEN}
POST http://127.0.0.1:${LICH_PORT}/rpc/spawn.<Method>?token=${LICH_TOKEN}
Outside a session those are absent, and the command finds the running lich
on its own: it reads the coordinates from runtime.json (internal/singleton,
mode 0600), the same file install.sh reads to reach a running lich for
/restart. LICH_DEV selects the dev instance's file, as everywhere else. With
no lich running at all, every command exits 1 with lich: no lich is running — open lich, or run this inside one of its sessions.
The environment wins over the runtime file, and that order is load-bearing: a
session belongs to the lich that spawned it, and on a machine running a daily
driver beside a task dev build the runtime file names only one of them.
Such a caller has no session of its own, so LICH_SESSION_ID is empty. That
is passed on rather than rejected: the relayed message is then attributed to the
command line instead of to a session (see below).
Always call it as "$LICH_BIN", not as lich. An installed lich is on
$PATH and a task dev build is not; on a machine running both, the bare name
resolves to whichever comes first rather than to the lich the session belongs
to. LICH_BIN is the one that is right by construction, which is why the
relayed message spells the reply command that way.
Every command prints its result on stdout and its failure as one lich: … line
on stderr.
Exit status is the part of the output a script reads without parsing any of it, so an outcome that is neither done nor failed has a code of its own:
| Code | Meaning |
|---|---|
| 0 | Done. For send and wait: an answer is in hand. |
| 1 | Failed. The lich: … line on stderr says why. |
| 2 | send / wait / control only: the wait ran out and a ticket came back. Nothing failed and the errand is still open: lich wait <ticket> picks the answer up later. For control: the session took the command and has not confirmed it yet; its id is printed, and it still goes through. |
| 3 | send / wait / control only: the errand is over and no answer is coming through lich: never read, never delivered, answered somewhere else, a subagent worker closed before it answered (stopped), or nobody answered within the ticket's hour (expired). Retrying the wait is pointless; the output says what to do instead. For control: the session ended before confirming the command. |
2 and 3 print their prose (or --json) on stdout like an answer does and write
nothing on stderr: they are outcomes, not failures. No other command has an
outcome that needs a code of its own. A refused close (no --worktree, a
dirty checkout without --force) is a failure with a message to act on, and
exits 1 like every other refusal.
lich help lists every command below; lich <command> --help prints that one's
own description and every flag it takes, generated from the flags the command
actually parses.
lich version prints this build's version, and on a second line the version of
the lich it would talk to, when one answers:
lich <cli-version>
server <server-version>
The two differ after an update the running lich has not been restarted into, or
when the lich on PATH is not the $LICH_BIN of the session. The server is
found the same way every other command finds it (the environment, then the
runtime file), and a lich that does not answer within 2 seconds is simply left
out: no server running is a normal state for this command, never exit 1.
--json prints {"cli":"<cli-version>","server":"<server-version>"}, with server absent when
none answered.
A word that names no subcommand is refused with lich: unknown command "…", a
guess at the one it resembles, and exit 1 — a typo does not open a window.
Arguments the app itself takes still do: bare lich, lich --shell <path>,
lich --no-window (the backend with no window, which lich native starts), and
lich -- with the Chromium flags behind it.
--json on sessions, whoami, send, insert, focus, wait, open, close, rename, control, ask, worktrees, folders, file,
rename-folder, color-folder, cost and version
replaces the prose with one JSON line: the peer array, the result object and the session
object exactly as this document describes them. An empty roster is [], never
null — a script should not have to tell those apart. One line is the contract:
open --prompt does two things and still prints one object (see below).
Lists the live sessions the caller can address, tab-separated with a header. The peer-roster name is there because both names reach a session, and a surface that showed only one is what once made an agent treat a single session as two. New columns are appended rather than inserted, so a script reading the first ones keeps working:
session project provider name state
docs lich codex lich-a1b2 busy
api revu crush revu-9f8e -
--json prints an array of
{"label","name","project","kind","state","id","path","projectPath"},
[] when there are none. path is the directory the session runs in (its
worktree's, for a worktree session) and projectPath the root of its project:
what an editor matches its open folder against to find the session beside it.
id is the session's lich id, the LICH_SESSION_ID
its process carries. lich-plugin's Claude Code mod reads it to count the
subagent workers still running, and to name the card behind a recorded session
id. The MCP tool list_sessions returns the same objects. No other live sessions. when there are none. A session is listed only while a
process is running in it: a card whose terminal was never opened has nothing to
type at.
The state column is what that session last reported through the session-state
hook (docs/hooks/session-state.md), and it is the same thing its card shows:
busy— mid-turn. A message sent now is queued and read when the turn ends.done— the turn ended. The session is at a prompt and free.waiting— blocked on a human. The agent hit a permission prompt and nothing there can move until someone answers it, so this session cannot pick up work at all: say so to the user instead of sending into it. What was typed would sit behind the prompt, unread, for as long as nobody is at that screen.-(""in--json) — not reported, which is not the same as idle. Only providers whose companion plugin reports state have one at all (Crush reports none, a Cursor session reports only where the plugin is installed in Claude Code and never reports waiting, and a session that has not had a turn yet has said nothing either), so an empty state says nothing about whether that session is free.
Prints the session the command runs in: the one entry sessions never lists.
Same columns as sessions, plus the lich id:
session project provider name state id
auth-fix lich claude lich-a1b2 busy 0b9c…
--json prints one object in the sessions shape,
{"label","name","project","kind","state","id","path","projectPath"}. It is how a caller learns its
own label and id without guessing: lich-plugin's Claude Code mod reads it to
tell that a control aimed at a label is aimed at its own session. Run outside
a lich session (LICH_SESSION_ID unset) it is an error, exit 1.
Types <prompt> at <session>'s prompt, submits it, and waits.
A Claude Code session running the lich-plugin mod is not typed at: it receives the task as a message from the plugin, so whatever its user was typing at its prompt is left alone and the task does not wait for them to finish it. A task the mod collected is never typed again; one no poll collected is typed as usual.
<session>is the label on the card, or the roster name that session answers to (myrepo-a1b2, the one lich passes as--nameand a mention writes at a prompt). Both name the same session and both are accepted, because an agent holding one of them should never have to know which door it is standing at — offering one name and accepting the other is what once made an agent treat a single session as two and use both channels at once. The label wins a tie. A/renametyped inside a Claude Code session moves that name, and lich reads it back off the session's own record, so both sides go on naming it the same thing; the name lich derived at spawn stops addressing it, exactly as it stops addressing it in/list-agents.<session>can also be the session's lich id (idinsessions --json,LICH_SESSION_IDin its process). It is matched before either name. The same holds for every command that takes a<session>:close,rename,control,askandfileresolve it the same way, and a caller naming itself by id gets exactly what naming itself by label gets.- Labels are unique within a project, not across them: a label two live sessions
answer to is an error naming both, and
--projectis what narrows it. Guessing which session a prompt lands in is the one mistake this must not make. A name that matches nothing comes back with every live session's two names, so a miss is one step from a hit. --timeoutbounds the wait in seconds. Default 100 — under the 120s an agent's shell tool typically allows a command, so the wait ends in an answer this side controls rather than a killed process. Capped at 30 minutes.--privatekeeps the result to its ticket: no note is typed at the sending session's prompt, the card does not count it, andlich waitwithout a ticket never returns it. Uncollected for an hour, it is dropped, andlich wait <ticket>says it expired (exit 3) rather than calling the ticket unknown. It is for a subagent or a workflow step running inside a session, which reaches lich as that session (see below).- Answered: prints the answer alone, exit 0.
- Not answered in time: the message was still delivered, so it says so and hands back a ticket, exit 2. The answer is not lost by giving up on it — see below.
- Never read: the task was typed at the target's prompt and nothing there
picked it up — the session never started working. What has that terminal is
usually a question of the provider's own (see the ceiling on trust prompts
below), and lich cannot see it. The ticket is dropped rather than left open,
and the output sends a person to that card: nothing is queued, so the task has
to be sent again once the screen is clear. Exit 3. Only reported for providers that
report their state at all (the plugin,
docs/hooks/) — silence has to mean something before it can be read as anything. A task handed to a Claude Code session's mod is never typed again: one its mod collected and the session never started on is reported here too. - Never delivered: the task was held for a session that never reached a prompt — it ended, or whatever had its terminal outlasted the queue (5 minutes). The ticket is dropped rather than left to expire, and the output says the task is gone rather than waiting anywhere: it has to be sent again once that card shows what happened. Exit 3. A sender that had already stopped waiting finds it in its inbox, like any other outcome.
- Answered somewhere else: the target worked through the request and ended
its turn without replying here — it answered over its provider's own channel,
or out loud to whoever was watching. The wait ends there rather than running
its clock out, and says the answer is in that session and has to be read
there, exit 3. The window raises a toast that opens the card. A sender that had
already stopped waiting is told at its own prompt instead, the same way an
answer would arrive — a pending result promises that prompt news, and a stall
is that news. A session working two requests at once ends a turn that says
this about both — it finished a turn and answered neither here — so both
senders are told it, which is what each would have been told alone. Which of
the two that turn actually was is the part nothing outside that session can
say, so it is not decided: a note at the worker's own prompt names the
requests that went home unanswered, and says every answer from here on has to
name its ticket. A turn that ends while the session still waits on an errand
it sent itself, a subagent of its own say, says nothing yet: that errand's
outcome reaching its prompt resumes it, and the turn after is the one read.
A late answer still lands: a worker that handed its work
to the background ends its turn before the work is done, so for an hour after
the turn ended
lich reply <ticket>on that errand still files the answer in the sender's inbox, announced like any other result (a private errand's waits for its ticket, as ever). It is taken once, and alich waiton that ticket before it lands answers with how the errand ended rather than as unknown. - Expired: nobody answered within the ticket's hour, counted from the send
or from the last wait that gave up on it, or a late answer never came for an
errand that ended without one. The ticket is dropped, and for one more hour
lich wait <ticket>says it expired, exit 3, rather than calling the ticket unknown. Nothing is announced for it, with one exception: a sender that is itself a subagent worker somebody waits on finds it in its inbox, since its own report waits for this outcome.
docs is still working. The errand is open — a message that session was not ready
for is held until it is — and a note will be typed at the sending session's
prompt when its result is ready. To hold the line for it instead:
lich wait a1b2c3d4
A prompt is capped at 8192 bytes and stripped of control characters — the text is typed into a terminal, and an escape sequence inside it would stop being text.
A target still running its setup script is queued for, not written to. The
ticket comes back at once and the message goes in when that session's agent is
the program reading its PTY — a fresh worktree installs its dependencies before
its agent, which routinely outlasts the caller's own --timeout, and losing the
task there is exactly what a fan-out cannot afford. send never blocks past
what was asked. A queue that can never end — the session dies, or 5 minutes
pass — is reported as a failure the sender can act on, never a ticket left to
expire. internal/terminal tells the setup script and the agent apart by a
marker the setup wrapper prints between them (setupDone): the PTY and the pid
are the same across the exec, so nothing else can.
lich insert [--project <name-or-path>] [--session <session>] [--timeout <seconds>] [--json] [<text> | -]
Pastes <text> at a session's prompt and leaves it there: nothing presses
Enter, so the person at that session reads what landed, writes the question
around it and sends it themselves. It is send for the other direction of work
— an editor handing over a selection or a file — and it is what lich's editor
plugins call.
- The text is the argument, or stdin when it is left out or is
-. A selection is multi-line and arbitrary, and a shell's quoting is the wrong place to carry it. It is pasted as a bracketed paste, so its newlines are not submissions; control characters, ESC included, are stripped. Over 64 KiB it is refused — insert the path of the file instead of its contents. --sessionnames the target the waysenddoes: label, roster name or lich id. Without it,--projectsays where to look, by project name or by the absolute path of a directory, and exactly one live session must be running there. A session running in that very directory wins over the others of its project, so an editor open on a worktree reaches the session in that worktree. Two candidates are an error naming both, never a guess: list them withsessions --json(seepath) and pick one with--session.- It waits up to
--timeoutseconds (default 10) for the session to be at its prompt, so one that is still starting gets its text; one stuck behind a checkout's setup script is refused with the reason. - Unlike
send, a half-written line is not a reason to wait. The text is added after it, which is where it was meant to go. - A session in
waiting— blocked on a permission prompt — is refused: what is typed there is read by the dialog, not the prompt. - Prints
Inserted <n> bytes at the prompt of "<label>" (<project>). Nothing was sent.;--jsonprints{"id","label","project","kind","bytes"}. Exit 1 when nothing was inserted.
It types at the terminal for every provider, a Claude Code session with the lich-plugin mod included: the mod takes prompts to submit, not text to leave on a line.
Brings the lich window to the front with <session>'s card open, the same as
clicking it: the window switches to that session's project and opens the card.
It is what an editor plugin calls to take the person to a session it just
pasted into, or one that just finished its turn.
<session>resolves the waysend's target does: label, roster name or lich id, narrowed with--project(a name or a directory) when a label is ambiguous. One that names no live session is refused with the list of the ones there are.- Raising the window is best effort, the same hand-off a second launch of lich
makes; the card is switched either way. See
docs/ceilings.mdfor where the window is not raised. - With the window closed, it opens a new one already on that card.
- Prints
Focused "<label>" (<project>).;--jsonprints{"id","label","project"}. Exit 1 when no session was focused.
With a ticket: waits again on that errand. Same output and exit codes as send. A result that
already came back unattended is handed over on the spot — it sits in the
sender's inbox (see below) until collected or expired. A ticket that was
collected already, or that nobody answered or waited on within an hour, is gone
and waiting on it is an error. A ticket somebody is waiting on never expires
under them: the hour runs from the last time a wait on it let go.
Without a ticket: collects. Every result waiting for this session is
printed at once, oldest first, each in the same words a single wait uses;
sessions still owing an answer are listed after (Still working: "docs").
When nothing is ready and errands are open, it holds the line for the next
result; when nothing is open either, it says so and returns at once. It exits 2 when it
held the line and came back with nothing while errands are still open, and 0
otherwise: a batch has no single status, so anything collected is done, and the
prose says what each result was. This is
the command the nudge at a sender's prompt names, and it needs a session of
its own — run from a plain shell it is an error, because there is no inbox to
drain.
--no-wait collects without holding the line: what is ready and who still owes
one, at once, with the same output and exit codes (2 when nothing is ready and
errands are open). It is the look for a sender busy in a turn of its own, where
the nudge cannot land; it takes no ticket. --timeout 0 still means the default
wait, not this. A lich wait killed mid-wait takes nothing with it: a result
that lands afterwards stays in the inbox and is nudged like any other.
Hands <answer> to the session waiting on <ticket>; prints Answer sent.
This is what a relayed message asks the receiving agent to run. An answer is
capped at 64 KiB. Replying twice to one ticket is an error — the first answer
already went home.
Called with the answer alone it hands it to the one request open against the calling session: a message actually delivered there and still unanswered. The ticket is written down in one place only — the message typed at the target's prompt — so an agent whose context was compacted past that message would otherwise be holding an answer with no route home. A task still queued for a prompt that has not received it is never picked, and neither is one still being typed in, its Enter not sent yet: an answer then is about something else.
With two or more open it is refused, because nothing in an answer says which request it belongs to and closing the wrong one sends both senders a confident report of work they never asked for. The refusal lists every open ticket with the opening of what it asked, so the retry names the right one:
lich: 2 requests are open against this session, and an answer that names no ticket would close the wrong one. Name the ticket the answer belongs to:
lich reply 1a2b3c4d "<answer>" — run the tests and report the failures
lich reply 5e6f7a8b "<answer>" — build the docs
Outside a session, or with nothing open, it is an error rather than a guess, and the ticket is still the way to name a specific errand.
lich open [--project <name-or-path>] [--kind <provider>] [--worktree <branch>] [--base <branch>] [--model <model>] [--effort <level>] [--ultracode] [--folder <name>] [--prompt <task> [--private | --subagent]] [--json]
Opens a new session, starts it, and prints the two names it is addressed by:
Opened session "auth-fix" (claude) in project "lich", in worktree /home/you/.local/share/lich/worktrees/1a2b/auth-fix.
It answers to "auth-fix" and to "auth-fix-9f8e". Its agent may still be starting — a fresh worktree runs the project's setup script first — so a task you send it is held until the agent is up rather than lost.
--projectnames the project it lands in; the default is the caller's own. Outside a session there is no default and the project must be named — the error lists what is open. It takes a name or a directory path, and a path is the only way to reach a project the window is not already holding: lich opens the directory as a project first, then the session in it. A directory it had open before comes back the way reopening it from the window does — same id, same name, and the sessions it was closed with parked on their cards — because the row is matched by path through the workspace's history rather than created again. A directory it has never seen becomes a new project, and its tab appears without a reload. The path must be absolute: there is no shell at lich's end, so a relative one would resolve against the directory the window was launched from rather than the caller's, and open a project somewhere else without saying so. A leading~is expanded, because an MCP tool call reaches lich through no shell at all. A bare word with no separator in it is always read as a project name, never as a path. Every other--project—close,rename,worktrees,folders,file,rename-folder,color-folder— takes a path in the same spelling, but only narrows with it: opening a project is something onlyopendoes.--kindis what the session runs: any provider id (claude,codex,antigravity,opencode,omp,crush,cursor,kiro) orshell. The default is the caller's own provider, so an agent opening a worker gets another of itself; a caller that is not a session at all gets Claude Code.--worktreeis the branch name of a git worktree, created off--base(the project's current branch by default) under the app data dir. The session is rooted there and labelled after it, and the project's worktree setup script (Settings › Project) runs in its terminal before the provider, exactly as when the window creates one. Without it the session opens in the project's own directory, beside the caller's. A branch that already exists is checked out as it stands, never recreated: naming a branch is naming the work on it, and a caller that cannot see the repository has no way to tell the two apart. That covers a branch prepared before the session — cut from an updated remote, linked to an issue — and a worktree closed but kept, which is resumed rather than made again. Only a branch already checked out somewhere else is refused, by git itself.--baseis checked against the repository's branches — local, remote (origin/…, fetched and tracked), or one another worktree already holds. A base that is not a branch is refused rather than resolved: git would happily branch off a typo that names a revision, leaving a checkout nobody asked for. It is ignored when--worktreenames a branch that already exists: a base says where a branch starts, and that one already started.- Confinement is the rung's own answer, and the output says so when the
answer is yes. Nobody is at this end to answer
Ask each time— the rung that puts the question in the New session menu and the New worktree dialog — so a session opened here takes its confined side, the same asEverywhereand asWorktrees onlyon a worktree. What that costs the session is in Settings › Sandbox; there is no flag to override it, because the rung is the user's answer and this end is the agent's. --modelis the model the session's provider starts on. The value is whatever that provider's own--modeltakes — an alias, a full name, aprovider/modelpair — and lich passes it through unchecked: the accepted names differ per provider and change with every release, so a wrong one fails in the provider's own error rather than against a list kept here (and no list here could go stale into a session on the wrong model). Crush andshellare refused: Crush spells--modelon its non-interactiverunsubcommand only, so the TUI lich spawns has nowhere to receive one, and naming a model for it would silently give you a session on the default. The model is recorded on the session and passed again whenever the card starts a new conversation (a restart, or declining to resume), never when it resumes or forks one: after birth, a/modeltyped inside the session is yours, and the conversation's own model is what the provider restores (docs/ceilings.mdnames the providers that fall back to their default instead).--effortis the reasoning effort the session's provider starts at, passed through unchecked like--model(low,high, and whatever else that provider lists). It reaches Claude Code, Antigravity and Kiro as--effort, oh-my-pi as--thinkingand Codex as-c model_reasoning_effort=<level>, and is recorded and repeated the same way, birth only. opencode, Crush, Cursor andshellare refused: opencode and Crush have no such flag on the TUI lich spawns, and Cursor names the effort inside the model id (claude-opus-4-8-high), so pass that as--modelinstead.--ultracodeturns Claude Code's ultracode on, at whatever effort the session runs, by passing--settings '{"ultracode":true}'. Unlike the model and effort it is passed on every spawn, a resume included, because Claude Code drops ultracode on--resume. Every other kind is refused: ultracode is Claude Code's alone. To turn it on for every Claude session, use Settings › Providers › Claude Code instead.--prompthands the new session that task as soon as its agent is up, so opening a worker for a task is one command rather thanopenthensend. It is the same deliverylich sendmakes — a worker still running its setup script is queued for, not written to — with the wait fixed at 20 seconds and no flag to raise it: the worker was created a moment ago and its task is minutes of work, so a ticket is the expected outcome and the caller carries on. Waiting for the answer islich wait's job, and it takes the timeout.--privatehands it over the waylich send --privatedoes, and is refused without--prompt, before anything is opened.--subagentopens the session as the calling session's subagent, the way lich-plugin's mod turns Claude Code's ownAgentcall into a card. Experimental: it may change outside semver (stability.md). It needs--promptand a calling session (LICH_SESSION_ID), and refuses--project,--folderand--private, all before anything is opened. The worker:- opens in the caller's checkout — its worktree, or the project directory
it runs in — so it edits what the caller sees, and takes the project's
counter for its label. With
--worktreeit opens that worktree exactly as without the flag. - is filed with the caller, so workers sit under whoever asked for them: in the caller's folder when the caller is in one, otherwise in a folder named after the caller's label, created if none is, and the caller is filed there too.
- hands its report back whole: when it answers and nobody is holding the line, the note that reaches the caller through its Claude Code mod carries the worker's label, its branch, the ticket and the full report, so the model uses it without a collect call (see The answer is announced, and collected).
- answers by itself where its own Claude Code mod does (lich-plugin
0.17.0 or later, polling when the task goes in): it is handed the task
under one line naming the caller, with no ticket and no reply command, and
the final message of a turn that ends with nothing left running in the
background is its answer, as a native subagent's last message is its
result (mod-answer). A turn that hands work to the
background does not end the errand; the turn Claude Code resumes in when
that work finishes answers it. A worker that still calls
reply_to_sessionitself is answered once: the first answer wins. Any other worker is handed the ticket and answers through it. - is not dropped by the ticket's one-hour TTL while its session runs.
- ends its errand when it is closed. Closed by its own caller (TaskStop
runs
lich closethere), it ends in silence: the caller stopped the work and nothing waits in its inbox. Closed by anyone else, from the card or by another session, the caller is told at its prompt and collectsstopped. A caller holding the line on the ticket hearsstopped(exit 3) either way, 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. An errand it
sent that nobody answers within the hour comes back to it as
expired. - 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 closedoes, 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, 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.
- runs one level below the caller, recorded on its row and handed to the
plugin as
LICH_SUBAGENT_DEPTHon 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 withLICH_SUBAGENT_CARDS=offand keeps them inside its CLI, and its briefing says so. Under an older plugin every worker keeps them native.--jsonkeeps its shape: the session and itsdelivery.
- opens in the caller's checkout — its worktree, or the project directory
it runs in — so it edits what the caller sees, and takes the project's
counter for its label. With
--folderfiles 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 of under its checkout. The name is matched exactly, as everywhere folders are (seelich file). A filing that cannot be written leaves the session open and running, unfiled, and exits 1 saying so.- A session without a worktree is labelled from the project's own counter
(
Session 4), continuing the numbering the window uses.
With --prompt the hand-off is printed under the session's own lines, in the
words send words it with, ticket included:
Opened session "auth-fix" (claude) in project "lich", in worktree /home/you/.local/share/lich/worktrees/1a2b/auth-fix.
It answers to "auth-fix" and to "auth-fix-9f8e". …
auth-fix is still working. The errand is open — a message that session was not ready
for is held until it is — and a note will be typed at the sending session's
prompt when its result is ready. To hold the line for it instead:
lich wait a1b2c3d4
--json prints one object either way: the session exactly as above, plus a
delivery key carrying the same result object send --json prints. The key is
absent without --prompt, so a reader that branches on it is never handed an
empty one to interpret:
{"id":"9f8e","projectId":"p1","project":"lich","label":"auth-fix","name":"auth-fix-9f8e","kind":"claude","path":"/wt/auth-fix","nextSeq":5,"originSessionId":"3c4d","originLabel":"planner","confined":false,"folder":"","delivery":{"ticket":"a1b2c3d4","target":"auth-fix","status":"pending","answer":""}}originSessionId and originLabel name the session this one was opened from —
the id it answers to, and the label its card carried at that moment. Both are
"" when lich open runs outside a session, which is the normal case for the
command line.
The card appears in the sidebar without taking focus — nobody in front of
the window asked for this session — and its PTY is started by lich itself rather
than by the first person to look at the card. That is what makes it addressable:
lich sessions lists only sessions with a process in them, so a session nobody
opened would be one nobody could send to.
Failure is one line on stderr and exit 1. A worktree that could not be created leaves nothing behind — no row, no card. A session whose terminal failed to start keeps both, and says so, because the card is the only place its error can be read.
A --prompt that never reached the session does not undo the open. The
session is printed as usual — prose or JSON, on stdout — and the failure is that
same lich: … line and exit 1, naming the lich send that tries again. The
delivery key stays absent rather than growing a shape for the failure: what
went wrong is one send, and the exit code is what says so. A task that did
reach the send exits 0 whatever its status, a ticket included: a session opened
seconds ago is expected to hand one back, and the open is what this command
does. delivery.status is where a script reads the rest.
Closes a session, addressed by either of its names, and settles what happens to the checkout it was the last one in.
Closed "auth-fix". Its worktree /home/you/.local/share/lich/worktrees/1a2b/auth-fix is still
there, and the session is parked: opening a session on that branch again picks its
conversation back up.
--worktreeis required when this is the checkout's last session, and only then.keepleaves it on disk and parks the row — the provider conversation goes with it, andopen --worktree <branch>on that branch resumes it.removedeletes the checkout, the row, and any parked rows pointing at it. Without the flag it is an error naming the two answers rather than a guess: the window asks this in a dialog, and there is nobody here to ask.--forceis needed to remove a checkout with uncommitted work, which is the second question the window asks. What it discards is in no commit and on no remote.- A session sharing its checkout with another, or living in the project's own directory, has nothing at stake and closes on the spot — parked like every other close, so the palette's History tab still finds it and a resume still picks its conversation back up.
- A session cannot close itself. The answer would have nowhere to go.
- Unlike
sessions, this reaches a card whose terminal was never opened: it is still a session, and closing it is the one thing you can do with it.
Renames a session — the name on its card, which is also the name it is addressed by. The window's rename, from outside the window.
$ lich rename auth-fix "the login bug"
Renamed "auth-fix" to "the login bug".
- One argument is the new name for the session the command runs in; two are
the target and the new name. The one-argument form is what an agent has to
work with:
sessionsshows it every session but its own, so it knows what it is doing long before it knows what its card is called. - The name becomes the user's. As in the window, renaming clears the row's
label_auto, so the provider's own auto-title never overwrites it again. - A name another session in that project already holds is refused. Two
sessions under one label is the one thing
sendcannot resolve. The window has no such rule — the user is pointing at the card they mean, and can see the other one. - The provider's own idea of the session's name is untouched: nothing here runs
/renameinside the terminal, exactly as the window's rename does not.
Drives a running Claude Code session from outside its terminal, through the
lich-plugin mod inside it (docs/hooks/mod-control.md). Experimental: it may
change outside semver (stability.md).
$ lich control auth-fix prompt "run the tests again"
"auth-fix" started a turn on the prompt.
$ lich control auth-fix command compact "keep the test plan"
"auth-fix" ran /compact.
| Action | Value | Done means |
|---|---|---|
prompt <text> |
the prompt, required | a turn started on it |
abort |
none | the running turn stopped |
model [<model>] |
a model name; none goes back to the session's own | its next requests use it |
effort [<level>] |
low, medium, high, xhigh or max; none goes back to its own |
its next requests use it |
command <name> [<args>] |
a slash command's name, with or without the slash, and what follows it | the command ran |
- It waits for the session to confirm: up to 10 seconds, or 60 for a slash command, which runs only once the session is idle. A command the session took but has not confirmed by then prints its id and exits 2, and still goes through: a prompt sent to a busy session starts its turn when the current one ends. A command the session never took in that time (its mod stopped polling) is withdrawn and exits 1: nothing ran, so it is safe to send again. A session that ends first exits 3. A session that says the command failed (an abort with no turn running, a slash command it does not have) exits 1 with its reason, as does one that is not running.
modelandeffortchange that session only, from its next request on. The slash commands/modeland/effortare refused: run this way, Claude Code saves what they set as your default for every new session.- A slash command that opens a dialog (
/cost) holds the session's later slash commands until someone presses Esc in its terminal.abort,modelandeffortstill apply. - Claude Code 2.1.280 or later, with lich-plugin 0.15.0 or later, in a trusted
folder. Any other session is refused: another CLI has nothing inside it lich
could hand a command to, and a Claude Code session without the mod polling is
refused as one that cannot take commands, naming what to fix. A mod older than
0.15.0 runs everything but
command, which it refuses with "update lich-plugin to 0.15.0 or later". - A session cannot control itself. An abort would end the turn asking for it, and a prompt or a slash command would only run once that turn is over.
--json prints
{"id","project","label","action","value","command_id","state"}, value absent
when the action carried none; state is done, delivered or ended.
Asks a running Claude Code session a side question and prints its answer. The
session answers from its own conversation while its turn goes on, through the
lich-plugin mod (docs/hooks/mod-control.md); neither the question nor the
answer enters that conversation. Experimental: it may change outside semver
(stability.md).
$ lich ask auth-fix what are you working on
Fixing the flaky login test: the fixture user expires before the assertion.
- It waits up to 90 seconds for the answer. A short one takes seconds. An answer that comes later is dropped, and the command exits 1; so does every other way it ends without one: a session with no conversation yet (new, or just cleared), an API error, a session that ended.
- The answer knows the conversation as of the session's last finished model response: it cannot say what the session is doing this very second (a prompt it is still writing its first reply to is not in it either), and it cannot run tools to find out more.
- It costs tokens on the asked session's account and never shows in its cost. The prompt cache serves the conversation while it is warm.
- Claude Code 2.1.280 or later, with lich-plugin 0.15.0 or later, in a trusted
folder, as for
lich control, and never the session running the command.
--json prints {"id","project","label","answer"}.
Lists a project's git worktrees — what each is called, whether it holds uncommitted work, and which sessions are open in it:
worktree state sessions
auth-fix uncommitted auth-fix
idle clean -
The sessions column is what makes it actionable: a checkout with one session is one whose fate that session's close decides, and a checkout with none is one nobody is working in. The project's own directory is not listed — it is the checkout every project has and the one that cannot be removed.
Lists a project's sidebar folders and the sessions filed under each, in the order the sidebar draws the folders:
folder sessions
Auth auth-fix, login-tests
Docs changelog
A folder is a name on a session and nothing else, so a folder with no session
in it does not exist and is never listed. --json prints
[{"name":"Auth","sessions":["auth-fix","login-tests"]}].
Moves a session into a sidebar folder: the window's "Move to folder", from outside the window.
$ lich file auth-fix Auth
Filed "auth-fix" under "Auth".
- One argument is the folder for the session the command runs in; two are
the target and the folder, as with
rename. - A name no session carries yet starts a folder with this session in it.
Names are matched exactly, case and spaces included, as the window matches
them:
authbesideAuthis a second folder. Runlich foldersfirst to reuse one. - An empty folder (
'') takes the session out of the one it is in. A session is in at most one folder, so filing it anywhere else moves it. --jsonprints{"id","project","label","folder","previous"},previousbeing the folder it left (""for none).
Renames a folder across every session filed under it: the window's "Rename
folder". An empty new name ('') takes the folder apart, the window's
"Ungroup", and every session in it goes back under its checkout.
$ lich rename-folder Auth Login
Moved "auth-fix", "login-tests" from folder "Auth" to "Login".
- The old name has to be one a session carries. The store would match no row and report success, so a name nothing carries is refused with the project's folders listed.
- Renaming onto a name the project already has merges the two, as in the window. The output names every session that moved, which is how you see it.
- Parked sessions follow the rename, so a resumed one comes back into the folder under its new name.
Paints every session filed under a folder: the window's folder "Color". The
color is one of red, orange, amber, green, teal, blue, violet or
pink; an empty one ('') hands the cards back to the theme.
$ lich color-folder Auth teal
Painted "auth-fix", "login-tests" in folder "Auth" teal.
- A folder has no color of its own: the color is written on each card in it, as the window does, so a card filed there later keeps its own.
- The folder has to be one a session carries, for
rename-folder's reason, and a color outside the palette is refused with the palette listed. --jsonprints{"project","folder","color","sessions"}.
What the sessions lich still remembers have cost, at API prices, one row per project and a total under them:
project sessions unpriced source cost
lich 12 2 mixed $4.31
revu 3 0 priced $0.88
total 15 2 mixed $5.19
9 priced by lich, 4 reported by their provider.
Lower bound: 2 unpriced of 15 sessions — their spend is not in this total.
The last line is the contract. A session lich never priced holds no ledger
row and contributes nothing, so a sum over a unit containing one is a lower
bound — and nothing else on screen would say so. The count travels with the
money on every surface: the unpriced column, the line under the table, the
unpriced field in --json, the unpriced column in --csv. With none, the
line reads Complete: every session in this total is priced. instead.
Why a session has no price is not reported here. That is the live scan's
costMiss (internal/terminal/usage_cost.go), decided per turn and never
persisted, so the ledger can only say a session carries no price — an
unreadable transcript, an unpriced model, a provider lich has no reader for
(Antigravity and Cursor CLI), or a session that ran while the readout was off
all land in the same count.
The source column is the other half of that contract. The money comes
from two kinds of accounting and reads identically either way: priced is
lich's own arithmetic over the token counts Claude Code and Codex write down,
reported is the figure oh-my-pi, opencode or Crush computed themselves and
handed over, with the omission their own accounting has. A Claude Code session
whose mod reported the cost Claude Code measured (docs/hooks/mod-usage.md) is
reported too: that figure is Claude Code's arithmetic, not lich's. A row that ran both
reads mixed, and a total that mixes them adds the line above the last one,
splitting the counted sessions between the two. A row with no money to
attribute — every session in it unpriced, or its spend earned by a provider CLI
started by hand inside a shell session — reads — and claims no rung.
docs/ceilings.md has what each rung's own figure leaves out.
With the cost readout switched off, no transcript is ever summed and every
session is unpriced, so the total can only be $0.00. That case adds a second
line naming the setting, because a zero on a machine that was never asked to
count reads exactly like a machine that spent nothing.
The filters:
--project <name>— one project, matched case-insensitively the way every other command matches one. A name no session sits under is refused, not answered with a zero: about money, a typo that reads as "that project cost nothing" is worse than no answer.--provider <provider>— one session kind (claude,codex,crush, …). Combined with the total line this is what a provider cost across the machine. An unknown provider is not refused; it matches nothing and the report is empty.--since <window>—7d,24h,90m. Sessions active in the window, counted whole: a session's activity is its last counted turn, and a session that ran through the boundary brings all of its cost with it. The ledger is a running total with no per-turn history behind it, so this is a filter over sessions, never a slice of the money by day. A session with nothing counted is dated by when it was parked, or by now while it is still open.
--csv writes the per-project rows and no total — the totals are a sum of the
columns, unpriced included, so the bound survives the export:
project,sessions,unpriced,source,cost_usd
lich,12,2,mixed,4.312500
revu,3,0,priced,0.880000
A row with no rung leaves the source cell empty rather than carrying the
table's dash: to a script a blank is the absence, and the dash is a thing to
print.
--json is the whole report on one line — projects, the summed sessions,
unpriced and costUsd, the total's own source with the priced and
reported session counts behind it, and readout for whether anything is
being counted at all.
It is not registered as an MCP tool. Every tool definition is in the prompt of every session lich spawns whether or not it is used (see the ceiling below), and a total nobody asked for is not worth that on every turn — an agent that wants it runs the command.
There is no --model breakdown. The ledger records what a (session, transcript) pair cost and never which model each priced turn ran under, and
the model on a session row is the one selected now — so a per-model total would
have to be a second accounting path, not a filter on this one.
Serves the session commands above as MCP tools over stdio — cost is not one
of them: one JSON-RPC 2.0 message per
line, initialize / tools/list / tools/call / ping. stdout carries
protocol and nothing else.
You rarely run this by hand — lich registers it for the providers that can be told at spawn (see below). Run it yourself only to point some other MCP client at lich.
| Tool | What it does |
|---|---|
list_sessions |
The live sessions that can be given work, as JSON — each with the state it last reported, waiting among them. |
send_to_session |
session, prompt, optional project, timeout_seconds and private (lich send --private). |
wait_for_answer |
optional ticket, timeout_seconds and no_wait — with a ticket, lich wait <ticket>; without one, the collect: everything ready at once. no_wait is lich wait --no-wait and takes no ticket. |
reply_to_session |
answer, optional ticket — what a relayed message asks for; without a ticket, the one request open against the calling session, and a refusal naming each open ticket when there are two. |
open_session |
optional project (a name, or an absolute directory path, which is opened as a project first), kind, worktree, base, model, effort, ultracode, folder — lich open — plus optional prompt and private — lich open --prompt [--private], the same hand-off in the same call. |
close_session |
session, optional project, worktree (keep/remove), force. |
rename_session |
label, optional session (omitted renames the caller's own) and project — lich rename. |
control_session |
Experimental. session, action (prompt, abort, model, effort, command), optional value, args (command only) and project. lich control. Delivered is a result, not an error; a command the session never took is withdrawn and fails. |
ask_session |
Experimental. session, question, optional project. lich ask: the answer is the result, and every way it ends without one is an error. |
list_worktrees |
optional project — the checkouts, as JSON. |
list_folders |
optional project: the folders and the sessions in each, as JSON. lich folders. |
file_session |
folder ("" takes the session out), optional session (omitted files the caller's own) and project. lich file. |
rename_folder |
folder, to ("" takes the folder apart), optional project. lich rename-folder. |
color_folder |
folder, color ("" clears it), optional project. lich color-folder. |
A tool that fails answers with isError and the reason as text, not a JSON-RPC
error: the agent should read what went wrong and act on it, not lose the turn.
initialize also carries instructions — the server's own briefing, which
clients inject into the agent's system prompt. It is the one place the whole
journey (fan out into worktree sessions, carry on, collect) is told as one;
the tool descriptions each only explain their own door. list_sessions,
list_worktrees and list_folders are annotated readOnlyHint, so a client
may auto-allow them.
file_session, rename_folder and color_folder refuse a call that leaves out
folder, to or color, rather than reading the missing field as empty: an
empty name takes a session out of its folder, or a folder apart, an empty color
clears a folder's, and a model that forgot the field did not ask for that.
Opens the native window, experimental (docs/native-window.md), on the running
lich. With no lich running it starts one in the background first, as
lich --no-window: the backend and its tray, no web window. The web window is
closed before the native one opens, through the same call as the page's "keep
running" answer, so lich and its sessions go on; the two windows cannot share
the backend's event and terminal streams, and the newest one takes them.
Opening the native window on lich (port 47900).
The native window is lich-native, looked up beside the lich binary, or at
the path LICH_NATIVE names. Neither there is an error before anything starts
or closes. It runs detached, with the backend's port and token as LICH_PORT
and LICH_TOKEN, so the command returns at once and closing the native window
ends nothing. There is no --json and no MCP tool: it opens a window for the
person at the screen.
list prints the installed themes, system first, with each one's scheme and
the one in use marked; --json adds each theme's pair, its variant for the
other scheme.
theme scheme name
system - System, the bundled theme the OS asks for
light light Light
dark dark Dark
everforest-dark dark Everforest Dark (in use)
everforest-light light Everforest Light
switch makes a theme, by its id, or system, the one in use, and the native
window repaints at once. It is for the script that changes a desktop's theme:
the one that recolors the compositor, the terminal and the editor can bring lich
along with one more line. An id no installed theme has is refused, naming it.
Switched to "everforest-light".
A desktop that only goes from light to dark and back does not need it: a theme
with a pair follows the OS to it while Auto light/dark is on
(docs/themes.md).
Ends the running lich: its window closes, every session's process ends with it, and the process exits. Closing the window does not do this: the backend keeps serving its sessions with no window on screen, and launching lich again opens a window on them. This is the way out of a lich whose window is closed.
lich has quit.
It returns once nothing listens on lich's port any more, so a script can
launch lich again straight after; it gives up after 15 seconds and exits 1. A
lich that exits before its reply leaves still counts as quit. Run inside a
session, it ends that session too, with all the others. There is no --json
and no MCP tool: an agent ending every session at once, its own included, is
not something to hand it as a tool.
The one command here that talks to no session — and to no lich. It collects a
bug report from this machine into ./lich-rage-<timestamp>.tar.gz (or
--output's path) and prints where it went:
Wrote lich-rage-20260810-150405.tar.gz
lich v0.25.0 on linux/amd64, running (pid 4242, port 47821).
Read it before you attach it: it carries absolute paths, project and branch names.
It exists for the failure lich cannot report on itself: a window that never opened has no Settings › Help to ask through. So it needs no running instance — whether one is running, and whether it still answers, is one of the facts it reports. It answers for the opposite failure too — a window still on screen that stopped updating. Nothing crashed there, so the log holds nothing; the stacks it collects say where the process is stuck.
| Entry | What it holds |
|---|---|
report.md |
Version and build, platform, instance state, browser, whether a confined session would actually be confined here, providers on PATH, plugin state per provider, and the config directory one level deep. |
env.txt |
Every LICH_* variable plus a fixed allowlist (SHELL, PATH, EDITOR, the desktop ones). Anything named like a token, key, secret or password is <SET> / <UNSET>, never a value. |
logs/ |
lich.log and the native window's lich-native.log, each with its rotated *.old, each carrying at most its last 4 MiB. |
goroutines.txt |
Every stack in the running instance, blocked ones included — only when one is running. A lich that holds its port but will not answer within 5s leaves that sentence here instead, which is the finding. |
Nothing is uploaded and no issue is filed: the archive sits on disk until the
user attaches it to one they wrote. The loopback token is masked wherever it
appeared — by value for the running instance, and by the token= query shape
for every earlier one. The Chromium profile, the workspace database and its
directories are named but never read.
Walks the boot a launch walks and says whether lich would start here:
lich v0.25.0 — linux/amd64
ok home 1ms /home/u/.config/lich is writable
ok log <1ms /home/u/.config/lich/lich.log
ok listener <1ms port 47821 is held by the running lich (pid 4242)
skip store <1ms held by the running lich (pid 4242)
ok browser 2ms /usr/lib/lich/shell/lich-shell
ok providers 3ms 4 of 8 on PATH: claude, codex, opencode, crush
ok sandbox 11ms bubblewrap confines a session here
ok git <1ms /usr/bin/git
ok gh <1ms /usr/bin/gh
total 17ms
lich starts here — nothing is in the way.
The checks are in boot order, and each carries its own verdict:
| Check | Fails when | Warns when |
|---|---|---|
home |
<config-dir>/lich cannot be created or written to. |
It is writable but the probe file could not be removed. |
log |
— | The log file will not open; lich would run on stderr only. |
listener |
The pinned port (LICH_LISTEN_PORT, else 47821) cannot be bound and no live lich holds it. |
— |
store |
The workspace database will not open or migrate. Skipped while an instance is running — a second process migrating the store is not a price a diagnosis may charge. | It will not close cleanly. |
browser |
No window resolves — neither one beside the binary nor a LICH_SHELL pin. lich would run and show nothing. |
— |
providers |
— | None on PATH: the window opens, but no session can spawn. |
sandbox |
— | The backend will not start (an AppArmor policy denying user namespaces, say), so a session opened with the sandbox on will not start either; or it starts and confines nothing, so a session marked confined runs on the machine. Skipped where the platform has no backend at all. |
git |
Never. | Not on PATH: branches, diffs and worktrees stay empty. |
gh |
Never. | Not on PATH: pull requests, checks and PR checkouts are unavailable. |
A fail exits 1 and a clean run exits 0, which is the automation surface here —
there is no --json. It needs no TTY, no running instance and no network.
Two things it does on purpose, because a launch does them too: it creates
<config-dir>/lich and an empty log file if they are missing, and — only when
no instance is running — it opens the workspace database, creating it on a
first run.
The sandbox check is the one that cannot be answered by a lookup, so it opens
a confined child and reads two files through it: one in the checkout, which
must come back, and one in the private home the backend replaces, which must
not. Both halves are needed. A backend whose ruleset silently applied nothing
would start perfectly and confine nothing, and on Ubuntu and Debian an AppArmor
policy denies unprivileged user namespaces, so bubblewrap is installed, resolves
on PATH, and every confined session dies on its error. The spawn is bounded at
two seconds and killed, so a kernel that blocks the request cannot hang the
report. It never turns a session confined or unconfined: internal/sandbox
answers that at spawn time, and this only says what the answer would be worth.
An MCP tool is in the agent's own tool list from the first turn. That is the point of it — a command line only helps an agent that has been told the command exists, and the person who would have to be told is the one who does not read release notes.
So lich registers the server itself, per session, at spawn
(internal/terminal, mcpArgs), for the providers that can be told on their
own command line (providers.AcceptsMCPServer):
| Provider | How | Registered |
|---|---|---|
| Claude Code | --mcp-config with a JSON string, no file on disk |
at spawn |
| Codex | -c mcp_servers.lich.command=… and …args=["mcp"] |
at spawn |
| Antigravity | an agy mcp add the plugin install runs — its own supported interface, so lich never formats that document |
with the plugin |
| Crush | an mcp add line in the block the plugin install writes into crushrc |
with the plugin |
| opencode | its plugin defines all fourteen as tools of its own (lich-plugin 0.18.4 or later; earlier releases define eight, without the folder tools, control_session and ask_session), since a plugin there cannot register an MCP server |
with the plugin |
| oh-my-pi | a lich entry merged into mcp.json beside the extension the plugin install writes |
with the plugin |
| Cursor CLI | a lich entry merged into ~/.cursor/mcp.json by the install — its mcp subcommand only lists, enables and disables what is already there |
with the plugin |
| Kiro CLI | a kiro-cli mcp add --agent lich the plugin install runs, so the entry lands in the agent lich owns rather than in the user's global mcp.json |
with the plugin |
Only the first two can be told on their own command line, which is what makes their registration per-session and secret-free. The rest arrive with the plugin instead (Settings › lich plugin), because that install already writes into those harnesses' own directories — so the server is registered where the hooks are, removed by the same uninstall, and absent for anyone who never installed it.
Two rules the registration must keep:
- Never
--strict-mcp-config. It would make Claude Code ignore every other MCP configuration — the user's own servers dropped for the sake of lich's. - Order is not free.
--mcp-configis variadic and reads whatever follows it as another config path, so for Claude Code it goes last. Codex spells resume as a subcommand and its global options must precede it, so for Codex it goes first.providerArgsowns both.
Why stdio and not HTTP. lich already serves HTTP on the loopback listener,
and registering a URL would have been less code. But the registration lands in
the provider's argv, and /proc/<pid>/cmdline is readable by any user on
the machine while /proc/<pid>/environ is not. A URL registration means
?token= in argv. The stdio one names a binary and a subcommand and carries no
secret at all — the server inherits the coordinates from the PTY's environment,
where they already were.
A relayed task can take minutes; a tool call cannot sit still that long (Claude Code detaches one that runs past 120 seconds). So waiting is optional here.
When a result lands and nobody is still holding the line, it goes to the sender's inbox, and what is typed at the sender's prompt is one short nudge — never the result itself:
[lich] The task you sent "docs" has its result ready. To collect everything at
once, call the lich tool `wait_for_answer` with no ticket, or run:
"$LICH_BIN" wait
The nudge is the whole delivery on purpose. Typing the results themselves was the first shape this had, and it is what made orchestrating expensive: every arrival is a prompt submission, every submission restarts the sender's turn, and the full text stays in its context window — an orchestrator fanning out to N workers paid all of that N times. The nudge costs one line, arrives once per batch (results landing within a couple of seconds share it, and a sender mid-turn hears nothing until its turn ends), and the text comes back through the collect call, inside a turn the sender chose. A result nobody collects expires with its ticket's TTL, one hour — except a subagent's, which waits as long as its worker runs.
A caller that is still waiting carries the result out itself and nothing is typed — delivering both would deliver twice.
A subagent's report is the exception (lich open --subagent). Where the
sender's Claude Code mod takes the note, the note carries the report whole,
under a line naming the worker, its branch and the ticket, with the nudge for
anything else waiting beside it — that is what a native subagent's result looks
like to the model, and the mod hands it over as its own prompt without touching
what the user is typing. The report then leaves the inbox: wait_for_answer
with no ticket does not return it again, while lich wait <ticket> still does.
A sender whose mod does not take it (no mod polls, or none collected the note)
is typed the short nudge instead and collects the report as usual: typing a
long report into a TUI is what the mod route exists to avoid. A report collected
before the note goes out is not delivered again.
A sender that is not a session — the lich command from a script — is never
nudged: its result waits in the inbox for lich wait <ticket>, or expires.
Everything above is keyed by the sending session, and a subagent or a
workflow step running inside a session reaches lich as that session: they share
its lich mcp and its LICH_SESSION_ID, and nothing in an MCP call says which
of them made it (Claude Code sends only claudecode/toolUseId and
progressToken, measured on 2.1.288). So a step that sends an ordinary task
has its result drained by any no-ticket collect from the session — a sibling
step's, or the session agent's own — and announced at the session's prompt,
waking an agent that did not send it.
private (--private on the command line) is how such a caller keeps the
errand to itself. Its result is held for the ticket alone: no note, no count on
the card, never returned by a no-ticket collect, which also stops listing it as
open. wait_for_answer with the ticket collects it, and holding the line on
your own ticket is not polling. It expires with the ticket's TTL, silently.
A workflow that fans work out to lich sessions therefore:
- Tells each step to open its worker with
open_session(worktree,folderset to the run's name so its cards stay together,prompt,private), or tosend_to_sessionwithprivate. - Has the step wait on its own ticket, in calls of up to 90 seconds, and return the worker's report.
- Returns the tickets and the folder in its final output, so the session can
still collect a result with
wait_for_answer {ticket}and close the workers withclose_sessionif the workflow dies mid-run.
[lich] Message from session "<sender label>", not from your own prompt.
<prompt>
When you have an answer, send it back by running:
"$LICH_BIN" reply <ticket> "<your answer>"
That ticket is the only way back: whoever asked is blocked on it and is
reading nothing else. Do not answer by messaging a peer session — an answer
sent any other way is lost. Keep the answer a concise report — what was done,
where, and what remains — never a transcript: the sender pays to read every
byte, and the detail is in your commits and files anyway.
A caller with no session of its own — the CLI run from a script or a plain
shell — opens with Message relayed by the lich command line instead. The
distinction is the point: the receiving agent must not read either as its user
speaking, and the two are not the same kind of "not your user".
A target that has lich's tools is offered one first — Claude Code and Codex
always, Antigravity, opencode, oh-my-pi, Crush and Kiro once the installed plugin is
new enough to carry them
(agentplugin.HasTools) — Cursor among them, whose tools come from the document
its install writes rather than from the plugin it borrows from Claude Code. A session pointed at a tool it does not have loses the
turn to an error, where the command works everywhere:
When you have an answer, send it back with the lich tool `reply_to_session`
(ticket <ticket>), or by running:
"$LICH_BIN" reply <ticket> "<your answer>"
The command is named either way. A session whose shell is locked down would otherwise have no way to answer at all, and the reply path exists for the receiving agent only because this text describes it.
- Spawn —
internal/spawn: opens, lists and closes for a caller that is not the window. It is the one place that does all four things a session is at once — the worktree (internal/project), the row (internal/store), the PTY (internal/terminal) and the card (thesession-openedevent) — because no existing package owns more than one of them. The window does the same four infrontend/src/providers/projects.tsx, in its own order. - The card —
session-openedcarries the whole session ({id, projectId, project, label, name, kind, path, nextSeq, originSessionId, originLabel, confined, folder, run}) rather than an id to look up: the row is already written and the PTY is already running, so the window has nothing to fetch and nothing to spawn.adoptSession(frontend/src/lib/session/sessions.ts) appends it without focusing it — an agent opening three workers must not drag the view along three times. - Relay —
internal/relay: resolves a label to a live session, composes the message above, hands it to the target's Claude Code mod when one is polling and types it through the terminal service otherwise, and holds the ticket the answer comes back on. Tickets live in memory: one exists for as long as its errand does, and a lich that restarted has no PTY left to answer into. - UI push — the relay emits the global app event
session-relay({id, peer, direction, ticket}) for both ends when a message lands in a PTY, and again with an empty direction and ticket for both when the errand closes. The ticket rides along because the window is the only other place it can be read: the number is otherwise in the message typed at the target's prompt and nowhere else, so a card's tooltip is what a person falls back to when an agent no longer has it. It is raised after the write, never before: a mark that outlived a delivery which never happened would be a card claiming something untrue. A caller with no session of its own gets no mark — there is no card to put one on — and the target'speeris then empty, which the card words as the command line. - Store and card —
frontend/src/lib/session/session-relay-store.ts, keyed by session id, read bySessionCard. The row outranks the tool line while a request is open: it explains the whole turn, and the tool comes back when the errand closes. The store also clears onidle(SessionEnd), which the relay's own clear cannot reach — a session that ended can answer nothing, and its ticket would otherwise hold the mark for an hour. - Delegating from the window —
delegate-targets.tslists every other session,delegate-prompt.tsdecides what is typed: a plain request where the sender has lich's tools, the spelled-out command where it has none. That split mirrorsproviders.AcceptsMCPServerand has to keep mirroring it. - Delivery —
internal/terminal,Service.Write: the same PTY write the window's keyboard input takes. The message is wrapped in bracketed paste (\x1b[200~…\x1b[201~) and followed by a carriage return, so a multi-line message arrives as one prompt instead of one submission per line. - Liveness —
internal/terminal,Service.Live: whether a card has a PTY behind it right now. - CLI —
internal/cli: argument parsing and the RPC client. It answers beforemainopens the database or takes the log file, so a command never races the lich it is talking to.dispatchis the seam its tests use: a client built byRunresolves the runtime file of the lich actually running, and a test that reached it would deliver into a real session. - Finding a lich from outside —
internal/singleton,Read: the runtime file the CLI falls back to when the environment carries no coordinates. - MCP server —
internal/cli/mcp.go: the stdio transport, the tool table and the handshake. Adding a tool is one entry inmcpTools, which is what registering once buys — every tool lich grows later reaches every provider that took the registration, with no further per-provider work. - Registration —
internal/terminal,providerArgs/mcpArgs, besidenameArgsandresumeArgs; which providers accept one isproviders.AcceptsMCPServer. The names it registers under (relay.MCPServerName,relay.MCPSubcommand,relay.ToolReply) live in the relay because both this and the composed message need them, and only that direction closes no import cycle. - Version:
lich versionaskssystem.Diagnostics, the endpoint the window's bug report already reads, rather than growing one of its own. - Env —
internal/terminal,Service.sessionEnv: exportsLICH_BINalongside the hook coordinates.
A Claude Code session can reach another one twice over: through its own peer messaging, and through lich. That is not a conflict to be settled by a setting — it became one only because the two named the same sessions differently, so an agent handed a name could not tell which door it was standing at. On the first real run one hedged and used both at once: it delivered over its own channel and then waited on a lich ticket nobody would ever close.
Three things keep that from repeating, and none of them asks the user to choose:
- One address space. The relay answers to the card label and to the peer
roster name — the one the session's own record carries, read back per lookup
(
internal/terminal.AgentName) and falling back to lich's derivation when there is nothing to read (internal/relay/rostername.go). A mention that slips into lich's tool works instead of erroring, and goes on working after a/rename. - Both names travel.
list_sessionsand every miss report the label and the roster name together, so an agent sees one session with two names. - One route home. The relayed message says the ticket is the only way back
and that an answer sent any other way is lost;
reply_to_sessionsays the same.
When all three fail anyway, the fourth catches it: the target's own turn ending closes the wait and points the user at that session. That path is the exception, not a supported mode — an answer that goes there is one lich cannot hand to whoever asked.
costonly sees the sessions lich still holds a row for. A session deleted for good took its ledger with it (ON DELETE CASCADE), so its spend is in no total and in no unpriced count either — it is not excluded, it is invisible. A parked session is still counted; a removed one never happened.- Closing has no undo. The window offers one for ten seconds after a close;
nothing here does. A removed checkout is gone from disk, and a deleted session
row with it.
--forceon a dirty checkout is the only step that asks for anything extra, and it asks the agent, not the user. - Nothing says whose session it is. Any session can close any other in the workspace, its own excepted. That does not widen lich's trust boundary — every PTY already holds the token — but an agent told to "clean up the worktrees" can close cards a person was working in.
- A relayed prompt carries the sender's reach, not the user's. It is typed
at the target's prompt and submitted, so the receiving agent acts on it under
its own permissions — in a session running without permission prompts, that is
every tool it has. This does not widen lich's trust boundary (
LICH_TOKENis already in every PTY, and any process in one can already write to any session), but it is the first feature that uses it, and there is no switch. - The tools cost context in every session, used or not. Fourteen tool
definitions are in the prompt of every Claude Code and Codex session lich
spawns, whether or not that session ever talks to another one. The command
line costs nothing until it is called; the tools are what buy discovery, and
this is the price. Anything added to
mcpToolsraises it for everyone. - Registration is silent and there is no switch. A session lich spawns is
handed the server without being asked. This does not widen the trust boundary
—
LICH_TOKENwas already in every PTY — but it does mean a user who never wanted this has no way to say so short of the provider's own MCP settings. - The card shows what is in flight, never what happened. Marks live with their ticket and the ticket lives in memory, so a finished request leaves no trace and a reload starts blank — the same ceiling the status readout has.
- Only live sessions are addressable. A parked or never-opened card is
invisible to
sessionsand unreachable bysend— the window spawns a session's terminal on its first view and nothing here changes that.openis the exception, and only for the session it creates: it starts that PTY itself, which is why the card it leaves behind is one you can send to unseen. - A session is addressable before its agent can read.
openreturns when the PTY exists, not when the provider inside it has finished starting. The one stretch lich can see is the project's worktree setup script, which owns that PTY before the provider and can run for minutes:sendqueues the task for it rather than typing into it (see below), because a message handed to a runningpnpm installis read and discarded, and the sender then waits out a ticket nobody was ever asked to answer. What is left is the second or two the provider's own TUI takes to start reading. lich waits that out as quiet — a PTY that has stopped drawing has taken the terminal — which is a heuristic and not a signal: no provider offers one. openstarts the PTY at the size the window last reported, because there is no terminal at this end to measure (terminal.sizeFor); 80×24 only when no window has ever measured one. It is a copy, not a subscription: a window resized between the spawn and the first view of the card leaves that session drawn for the old grid, and the first view resizes it — at which point the TUI repaints and the conversation it had already written is gone from the screen, though not from the provider. The same seam a hidden session's replay has.- An opened session takes the project's active slot. The row is written the
way the window writes one, so the project's
active_session_idmoves to it — the card is not focused now, but a reload lands on it. Two of them and the last one wins. - A provider may open on a question of its own. Claude Code asks whether a
directory is trusted the first time it runs in one, and a session sitting on
that prompt looks exactly like one waiting at its own: quiet, live, ready. The
first task sent to it answers the trust prompt instead of being read. lich
cannot see that without reading the screen, which is the one thing this
feature does not do — what it does instead is notice that nothing was picked
up and say so (the
unreadresult above), so the wait ends in half a minute with somewhere to look rather than at the ticket's expiry an hour later. Measured: the prompt is answered once per project, not per worktree — the second checkout of a repository never asks — and the session's ownsession-starthook fires while it sits there, so the report that a session exists is no evidence that anything in it is listening. - Delivery is proven, receipt is not.
sendknows the bytes reached the PTY. Whether the target's TUI queued them, and whether its agent ever runs the reply command, is what the wait and the ticket are for. A provider that does not honour bracketed paste would read the message's newlines as submissions. - A busy target is written to anyway. Every provider lich spawns queues text typed mid-turn and submits it when the turn ends, so the message waits its turn rather than interrupting one.
- A bug report knows nothing about the workspace.
lich ragenever opens the database — a second process migrating it is not a price a bug report may charge — so no project, session or cost is in the bundle, only that the file exists and how big it is. What a session was doing has to be asked for. doctorproves the port for an instant, not for the launch. It binds the pinned port to prove it is free and releases it immediately, exactly as the worktree port reservation does: anything on the machine can take it between the check and the launch that follows.- A green
doctoris not a green window. It goes as far as resolving the browser binary; whether that browser opens under this compositor, this display server or this sandbox is not something a check can answer without becoming the launch. - The answer is the agent's summary, not the transcript. Nothing reads the
target's output, so what comes back is exactly what that agent chose to type
into
lich reply— and an agent that never runs it is indistinguishable from one still working.