Skip to content

Latest commit

 

History

History
1634 lines (1622 loc) · 162 KB

ceilings.md

File metadata and controls

1634 lines (1622 loc) · 162 KB

Known Ceilings

Deliberate limits and shortcuts, and the traps they set. A bullet earns its place by naming something that breaks work when nobody knows it and that the call site never shows. The mechanism and the history stay in the code and CHANGELOG.md; this file is the trap alone. Provider-specific behaviour is reference, not a trap, and lives in providers/.

  • A project's gh account governs gh, not git: vcs.account (internal/project/ghaccount.go) puts one account's token in GH_TOKEN for every gh call lich makes for that project. A push still rides the remote's ssh key and signs with the global user.email, so a PR can be read by one account and its commits land under another. lich never writes user.email, and the Version Control settings still print both identities without comparing them — noreply forms, vanity domains and org aliases make a string comparison a false-positive farm. The Commits tab of the Pulls screen is where the two are finally reconciled, and only there (frontend/src/lib/pulls/commit-authors.ts): GitHub has already resolved each commit's author email against the verified addresses of every account, so its answer is set membership, not a guess. The window is after the push and before the merge. Nothing reads before a push — the resolution does not exist until GitHub has the commit — so a branch can still be built entirely under the wrong identity and only say so once its pull request is open. Two things keep it there: gh api user/emails would answer for an unpushed commit, but it needs the user scope that gh's own login does not request (measured: repo, read:org, gist) and its refusal is an HTTP 404 indistinguishable from any other not-found, on a path where gh's stderr never reaches the UI; and a commit's signature is a second identity this compares nothing about, so a commit signed by a key the account does not list reads as clean. The reading is also about the pull request, not about you: it compares each commit against whoever opened the PR, the one identity the payload already carries. So a branch somebody else built collaboratively — a teammate's commit, a Copilot-authored branch — reads as commits under another account on a PR you are only reviewing. The statement is true and the tone is factual rather than an alarm, but the line does speak on pull requests where nothing is wrong. Narrowing it to your pull requests means resolving which account lich runs gh as, which vcs.account only answers when the project has named one.

  • A closed card outlives its session by 180ms (frontend/src/lib/session/use-closing-sessions.ts): the card animates out, and nothing can animate an element React has already unmounted, so the closed session is held and drawn for the length of that exit. For those frames the sidebar renders a card for a session the state no longer holds — anything counting or measuring cards sees one card too many, and the card is already pointer-events-none and out of the drag order rather than a live one. The hold and the CSS duration are the same constant, and the timer starts on the frame the browser starts the transition on; two clocks that can drift are exactly how the card ends up popping out of its own last frames. The card's box also stops clipping while a card in the block is dragged (SessionGroup.tsx), because the drag carries the card out of that box: a drag that begins while another card is still opening will see that one overflow.

  • The prompt language reaches a running session only through lich itself (internal/prompt): text lich composes in its own process (relay messages, nudges) follows the setting at once, but anything composed inside the session (the lich CLI, its MCP results, the plugin's hooks) reads LICH_PROMPT_LANG, which is fixed when the session spawns. Change the language and an open session can get relay messages in the new language and CLI output in the old one until it is restarted. The spawn briefing is likewise fixed at spawn, and only Claude Code and oh-my-pi receive one at all: they are the two whose command line can append to the system prompt (briefingFlags), so the other six read lich's words only in relayed messages. MCP tool descriptions, --help and error returns stay English in every language. A usage-limit continuation parked before the setting changed keeps the language it was parked in: the row stores the text, so isResumePrompt matches it in every locale.

  • Some interface text stays English in every language: the exit marker written into a terminal's scrollback (exitMarker, frontend/src/lib/terminal/session-exit.ts), because it is data in the replay and not a label; the Session N label a session gets when nothing names it (frontend/src/lib/session/sessions.ts), because it is persisted and would otherwise change language on the next launch only for the rows made afterwards; every Error message and the output of errorText(), which show backend and library text as it came; the CHANGELOG group labels in the What's new dialog (PatchNotesDialog), which render a file that is English; and MCP tool descriptions and the lich CLI's --help, which agents read and which do not follow the prompt language either.

  • "Unfold long pastes" copies three providers' own rules (frontend/src/lib/terminal/paste-unfold.ts, internal/terminal/paste_unfold.go), and is off until turned on. For Claude Code lich pastes twice wherever it predicts the fold, because a repeat of the same text unfolds the [Pasted text #N +M lines] chip. The prediction is Claude Code's rule copied from its 2.1.296 bundle (more than 800 characters, or more than min(rows - 10, 2) newlines, up to 100 000), and nothing tells lich when that rule moves: a paste lich thinks folds and Claude Code does not lands twice, and one it thinks does not stays a chip. It also assumes the paste goes to the prompt; a paste into one of Claude Code's own dialogs that does not fold gets the text twice. For Kiro CLI lich presses Tab behind a paste of more than 10 lines or 500 characters (2.21.0), which expands the newest N lines ▸ chip; a paste lich thinks folds and Kiro does not gets a Tab, which in the / menu picks an entry. For opencode the spawn sets experimental.disable_paste_summary through OPENCODE_CONFIG_CONTENT, extending one the user exports rather than replacing it; opencode's own "Disable paste summary" command is stored in its state and wins over any config, so once used there the switch changes nothing. Codex, Antigravity, Crush and Cursor CLI fold with a constant, no setting and no key that unfolds, so they have no switch; oh-my-pi was not measured.

  • LICH_WORKTREE_PORT is reserved, never held (internal/terminal/worktreeport.go): the number is a name the checkout owns, nothing binds it, and anything on the machine can take the port before the dev server starts.

  • A folder is a name on a session, so it has no life of its own (internal/store/mutations.go, frontend/src/lib/session/sidebar-groups.ts): the folder a card is filed under is a column on the session row, and the name is the whole identity — there is no folder record to point at. What follows is deliberate: a folder exists only while a session carries its name, so the last card leaving ends it and an empty folder cannot be kept on screen waiting for a card; a rename is a write across every session in it, parked rows included (RenameFolder); two names differing only by case or a space are two folders, because nothing folds them and the user can see and rename both; and a folder belongs to one project, since the name is scoped by project_id. A session is in at most one folder, and where it could be in two blocks the order is wall, then pin, then folder, then its checkout — a pinned card that is also filed is drawn at the top, not in its folder.

  • A card is filed by dropping it on a block's header, never between another block's cards (frontend/src/lib/session/file-drag-store.ts): each block still owns an isolated DndContext, which is what keeps a reorder inside a worktree from rewriting the list around it, so no droppable of another block exists for the card to land between. The drag finds the headers by hit-testing the pointer instead, and a card dropped on one lands where the stored order puts it, exactly as filing it from its menu does. A folder's header takes any card not already in it; a checkout's header takes back only a filed card of its own checkout, since filing never moves a session between checkouts. So a checkout whose every session is filed has no header left to drag a card back to, and the card's menu is the way out. A keyboard drag moves no pointer and never reaches a header either: the menu is its path too. A card on a wall or in the pinned block starts no filing drag, because both outrank the folder and the drop would land with nothing moving on screen; for the same reason a card drawn on a wall is offered no folder in its menu. A filtered block offers no "Move group to folder", because the cards it drew are the ones that survived the filter and filing "the group" would file part of it.

  • A folder's + opens only where its cards already live (frontend/src/components/sidebar/FolderLaunchMenuItems.tsx): a folder has no directory of its own, so its

    • lists the checkouts of the cards it holds and opens the session in the one picked. A worktree none of its cards is in is not offered: open the session there and drag it in. Under a filter the list is read off the cards the filter (query or state chips) left, so it can name fewer checkouts than the folder holds.
  • Renaming a folder remounts its block (frontend/src/lib/session/group-prefs.ts): the block is keyed by the folder's name, so a rename is a new key to React and to the fold preference — moveGroupCollapsed carries the fold across and drops the old entry, and anything else keyed off that block starts again. A wall avoids this by keeping one id across its renames; a folder cannot, because the name is the folder. Renaming onto a name the project already holds merges the two folders into one set of sessions; the field says so under it before the rename lands, as the New folder dialog does, and the merge has no undo.

  • An agent files by the name as written, like the window (internal/spawn/folders.go): lich file, lich rename-folder, lich color-folder and their MCP tools match a folder exactly, so an agent that types auth beside Auth starts a second folder; the tool descriptions send it to list_folders first instead of folding case behind the user's back. A rename from there refuses a name no session carries, because the store would report success over nothing, and answers with every session that moved, which is how a merge shows.

  • The header of a folded block reports at most one thing (collapsedMark): a waiting session, else an unread finished turn, else nothing. It is a dot rather than a count, so a folder hiding four unread turns and one hiding one read the same, and it speaks only for the cards in that block — a folder whose members are all busy says nothing, exactly like a block whose cards have never reported.

  • The sidebar's state chips read the ring, nothing more (frontend/src/lib/session/session-filter.ts): Waiting, Running and Unread are the card's amber, spinning and unread green ring, and Idle is everything else, a read turn included. A provider that cannot raise a state cannot be filtered into it: Crush reports no turn at all and always sits under Idle; Antigravity, oh-my-pi, Kiro CLI and Cursor CLI raise no permission event, so they never reach Waiting (a Kiro or Cursor session blocked on a confirmation counts as Running). There is no error chip: no provider reports one, and a process that exited is known only to its terminal, not to a store the sidebar reads. The chips are not persisted and drop on a project switch, like the query.

  • Next waiting session reads the same ring (nextWaitingSessionId, frontend/src/lib/session/sidebar-groups.ts): it jumps only to a card whose last report is Waiting, so Antigravity, oh-my-pi, Crush, Cursor CLI and Kiro CLI sessions are never landed on, for the reasons the chips above give. It walks the order next session walks, the cards the sidebar shows (sidebarCards): a card in a folded block or left out by the search and state filter is skipped rather than its block unfolded, so a waiting session folded away is reached only through its block's mark. The active card never counts: when it is the only one waiting, the chord does nothing and goes on to the terminal.

  • A session is ready for work only once its agent reports from its own prompt, where it can (internal/terminal/startgate.go): in a fresh worktree Claude Code, Codex, Cursor CLI and Antigravity ask whether the folder is trusted and Crush asks whether to initialize the project, and a quiet screen is all terminal.Ready used to read. Anything typed then (a relayed task, lich open --prompt, a scheduled prompt, an issue or pull request handed over) went into the question, and the Enter behind it answered it: Claude Code and Cursor CLI exit, Codex and Antigravity lose the text to a second screen, Crush runs an init turn (measured 2026-10-08). Claude Code, Cursor CLI, oh-my-pi and Kiro CLI now stay unready until their session-start report, which their hooks send only from the agent's prompt. That hold needs lich-plugin's hooks in the provider: without them nothing would ever release it, so nothing is held and the old trap is back. Codex (0.161.0), Antigravity, Crush and opencode report nothing before their first turn, Codex not even at its prompt in a trusted folder, so a quiet screen is still all lich has for them, a trust, hooks or init question included; opencode asks nothing, but under load it goes quiet mid-boot and text typed then lands before its TUI does.

  • The cost readout bills per (session, transcript) (internal/pricing, internal/terminal/usage_cost.go): a conversation forked inside the PTY bills its copied history twice — lich's own resume continues the same transcript and is unaffected — and each sub-agent's own transcript is counted in, so one unreadable or unpriceable sub-agent withholds the whole session's number. A Claude Code session whose mod reports a cost is not scanned at all: the report replaces the transcript, sub-agents included. A withheld number is marked $— on the footer only when the reason is standing (costMiss.spoken in internal/terminal/usage_cost.go): a transcript that merely could not be read this turn says nothing and keeps the last figure, so a reader cannot tell that absence from a session still on its first turn.

  • The session readout sits on three rungs, and only two providers reach the top one (internal/terminal/usage.go, usageSourceFor). Which rung a provider is on is decided by what it writes down, not by what lich chose to read:

    Provider Rung Why
    Claude Code full readout per-turn token counts, and the window its mod reports, else one named from the model (windowForModel)
    Codex full readout the effective model_context_window, and a running total_token_usage
    oh-my-pi cost only a USD total on every assistant turn; no line carries a context window
    opencode cost only session.cost per conversation; no window recorded with it
    Crush cost only sessions.cost per conversation; no window recorded with it
    Kiro CLI context only a window and a per-request percentage; spend is metered in credits, not dollars
    Antigravity nothing conversation filed as SQLite lich has no reader for
    Cursor CLI nothing chat filed as SQLite lich has no reader for, search and recap included

    Kiro CLI is the mirror image of the cost-only rung, and the only provider on its own one. It records context_usage_percentage against a context_window_tokens (internal/terminal/usage_kiro.go), so the ring is real — but it files every token count as zero even on a turn that spent them, so the tooltip's "n / window tokens" is derived from the percentage, not read. It is Kiro's own percentage against Kiro's own window, and it is what keeps the tooltip agreeing with the ring; it is not a token count anybody measured. Its spend never appears at all: metering_usage is denominated in credits, whose dollar value depends on the account's plan, and lich's readout is dollars — so a Kiro session shows a context ring and no cost, and the CLI's own footer is the only place that credit figure can be read.

    The cost-only rung's footer shows the figure and nothing else: its usage event carries a zero window, and a zero window is what tells FooterSession to drop the ring and SessionModel to render nothing rather than a provider glyph beside a model nobody reported. Those three figures are the providers' own arithmetic, never re-priced here — they bill models internal/pricing has never heard of, so a second opinion would only be a second, disagreeing number — and each therefore inherits what its own accounting leaves out. oh-my-pi's total is the sum of its assistant turns, the same walk its own status line makes, so a task sub-agent's spend is missing from lich's figure exactly as it is from omp's. opencode files each sub-agent as a session of its own and does not roll it into the parent, so the read walks the parent_id chain; Crush does roll it in (updateParentSessionCost), so summing its children would bill them twice. Both of those are measured behaviour of another tool's schema, not a promise it made — the day either changes its mind about the rollup, the footer is silently wrong in one direction or the other, with nothing on screen saying so. A zero from any of the three is an answer and not a gap: it is what they record for a free model and for a subscription — while an oh-my-pi turn written with no total at all withholds the session's whole number and borrows the unpriced-model marker, whose tooltip then blames a network that would not have helped. That branch has never been seen in the wild and gets no reason of its own until it is. The two bottom-rung providers get no bullet beyond their row — a reader for either means a reader for its own SQLite schema, and neither schema is a contract anybody promised to keep. The Codex window in that table is 95% of the rollout's default or configured model_context_window, and Codex is the one rung lich prices itself. internal/pricing/prices.json bakes a fixed slice of OpenAI rates beside the Claude ones, read from the same LiteLLM table the refresh reads, so an offline Codex session shows a cost at the rate that shipped — stale by however long it has been since the release, until the refresh (internal/pricing/pricing.go) lands and overrides it. task pricing:refresh reprices the slice from that table, and a monthly job (.github/workflows/pricing.yml) runs it and opens a pull request when a rate moved — deliberately not a gate, since an upstream price change is nobody's pull request to fix. A hand-copied cacheWrite1h sat 12x over Anthropic's rate until the first run of that task found it. What the job cannot notice is a model the floor never carried: that list is hand-picked, so what is left with no price at all is a model newer than the build, or one LiteLLM never priced — and with no network the refresh that would settle it never runs. A Codex conversation that ran /model has no cost from that turn on (codexCostScan.mixed): the one running total spans both models and the rollout never splits it, so no rate prices it — absent, the way an unpriced line is absent, rather than a number billed at whichever model happened to go last. Those two absences are spoken; the staleness is not. A session lich cannot price draws $— with a tooltip naming which of the two it is (usageEvent.CostMiss, COST_MISS_REASON in frontend/src/lib/session/session-cost.ts), while a cost billed at a rate the release froze reads as an ordinary figure with nothing beside it. The marker also names the reason and never the model or the turn, so a session whose sub-agent alone is unpriced looks like one whose every turn is.

  • Footer choices cannot add readings a provider never reports (frontend/src/components/FooterSession.tsx): Appearance's global toggles preserve the gaps above — Claude Code and Codex full usage, oh-my-pi/opencode/Crush cost only, Kiro context only, and Antigravity/Cursor no transcript usage. Selecting a missing reading renders nothing, never a zero. Cost still uses the backend setting because it controls whether transcripts are priced: when another profile disables it, an old layout cannot enable pricing just by moving an unrelated item. The editor waits for that setting before migrating old visibility choices. Hiding a reading hides its warning too. An item without data can occupy an editor slot while drawing nothing in the live footer — a PR on a branch without one, or an unsupported provider reading.

  • Hands-on time is read off three signals, and one of them is not universal (internal/terminal/handson.go, noteOutput): the figure beside the cost counts the gap between consecutive signs of life in a session — any hook report naming it, a keystroke at its PTY, or its own output while a turn is open — and drops any gap longer than handsOnIdleGap. The output signal is the one that carries an unattended turn, and it is gated on the provider having reported busy, because a tail -f, a dev server or a TUI repainting would otherwise bill hours nobody worked. Which turns get counted therefore depends on what a provider's hooks report at all, and there are two rungs. On the top one — Claude Code, Codex, Antigravity, opencode, oh-my-pi, Cursor CLI, Kiro CLI — the turn opens, so a turn nobody touches is counted from its own output. On the lower one — Crush — no turn ever opens (docs/hooks/session-state.md), and the turn is counted through the reports its tool calls fire: Crush's /session-start, which its only hook event (PreToolUse) fires once per tool call — measured 2026-09-03 against Crush 0.88.0, three tool calls in one turn, three POSTs. What that rung cannot count is a turn that calls no tool at all: a long answer written straight out, with no keystroke and no report between the prompt and the reply, is worth only the gap the prompt itself closed. A plain shell session is keystrokes-only by design and not a gap: there is no agent in it whose work could be missed. The trap is reading the number as exactly comparable across cards — a toolless turn is time on a Claude Code card and nothing on a Crush one. The tooltip under the figure says which rung the session is on (handsOnDetail, frontend/src/lib/session/hands-on.ts), in the same two sentences on both, by naming what the clock listened to: a turn, or a tool call. What it does not say is how much the rung cost this figure — nothing can know that without inventing the turns it never heard.

  • A split stage puts every pane on the visible cadence (internal/terminal/coalescer.go, frontend/src/components/TerminalHost.tsx): the coalescer batches a visible session's output every 8ms and a hidden one's every 250ms, and until the stage could divide, exactly one session per window was ever visible — Service.SetVisible had one true at a time. A wall of eight makes eight, deliberately: a pane nobody demoted is the entire point of opening it. So the window's hot path — the event bridge, its base64 and the WASM parse behind it — carries as many streams as there are panes, and anything reasoning about that cadence from the constants alone will read a number that was true for one terminal. The budget suite pins that adding a pane mounts one terminal and remounts none (frontend/src/components/render-budget.test.tsx); it cannot measure the cadence, because jsdom has no canvas to paint.

  • A wall reaches into other projects, but only its own project's cards are whole cards there (frontend/src/lib/session/sidebar-groups.ts, frontend/src/components/sidebar/WallGuestCard.tsx): walls belong to the window (one lich.panes pref), not to a project. A member from another project is drawn in this project's block as a guest that can only be opened or taken off the wall, because every action on a session card speaks for the sidebar's project. Guests sit under the block's own cards rather than in pane order, stay out of the block's drag, the next/previous session walk and its pull request cards, and the collapsed rail does not draw them. Focusing a guest's pane switches the route to its project. A closed project's sessions drop out of every wall, and a wall left with one live member is not drawn. "Show beside its N delegates" still reads the routed project only (delegatesOf), so a delegate opened in another project is left out of the wall it builds.

  • The native window's layouts are not the web's walls (native/cmd/lich-native/layouts.go, model_layouts.go, stage_layout.go): a layout is a free split tree stored under lich.native.layouts, and neither window reads the other's. A session sits in one pane of one layout, because its PTY has one size; clicking its card anywhere shows it in that layout, while its project's tab shows it alone. A split is refused, with a toast, when a pane would drop under 480dp (51 columns at the default 13sp, measured with Claude Code and Codex): a larger terminal font fits fewer columns in the same floor. Only the focused pane draws its exit band and takes a dropped file; a pane in the background whose process ended keeps its last frame until focused, and a file dropped on it goes nowhere. A pinned card cannot be dragged onto a pane: the pinned block shares no carry with the panes, so its card never leaves the sidebar. The panes' drop zones are not SortCarry targets: Gio's transfer delivered Enter to targets laid out mid-drag only some of the time, so the window reads the pointer from a handler over the whole window and works the zone out from where the panes were drawn (layouts_drag.go). A new drop target over the stage belongs there, not in the carry. Resting a drag on a strip tab switches to it, which can take the drag's own list off screen; Gio drops a handler nobody asked about in a frame and then dereferences the forgotten source on the next move, so the window calls SortCarry.Hold every frame to keep the source asked about, and lets a drag whose list never heard the release go (endStrandedDrag). The layout hotkeys the web window has no action for (split below, zoom, take out of the layout, rotate) live in the shared hotkeys.bindings value, which the web window rewrites with its own actions only: rebinding anything there drops a custom chord set for those four in the native window. Show beside lays out only the delegates that fit the 180dp pane floor, counted from where the panes were drawn the frame before (columnRoom); the rest stay out and the pane header offers them. Its footer item is not an item of the Footer layout, so Settings can neither move nor hide it: it shows only while the session, in no layout, has delegates.

  • A theme switch reaches only the apps that asked, and only since the page loaded (frontend/src/lib/terminal/theme-notify.ts): lich answers mode 2031 and sends the light or dark report on a switch, but the "asked" flag lives on the page's terminal entry. A full page reload forgets it, so a session that turned the mode on at boot stays on its old look until it turns it on again, and a terminal reset (ESC c) does not clear it. Of the providers, Claude Code and opencode ask (driven in a PTY), oh-my-pi and Cursor CLI ask in their shipped code (not driven); Codex, Antigravity and Kiro CLI read the background once at boot and never hear of a switch, and Crush reads it not at all.

  • Only zsh, bash and fish get their prompt cleared on a resize (internal/shellintegration, native/gioterm/prompt.go): a shell session's shell is started with scripts that mark its prompt with OSC 133 and ask the terminal to clear it on a resize (redraw=1, redraw=last for bash, which redraws only the last line). sh, PowerShell and a shell started by hand inside a session are unmarked, and a prompt line as wide as the terminal (a right prompt) leaves its wrapped head on screen after a narrowing, the way any terminal without shell integration does. Agent sessions are untouched: they run no shell rc and draw their own screen. Only the native window clears; the web window's xterm.js ignores the marks. The clearing works around a libghostty-vt resize that clears a wrapped prompt only from its last wrapped row: it erases the rows above once the terminal has cleared that one, which is also how it tells a shell that asked from one that did not. The scripts are rewritten under lich's config dir at every start, so two lich builds running at once share the newer one's. bash marks where a command's output starts from PS0, which it has since 4.4: the bash 3.2 macOS ships marks its prompt but not that, which the resize clearing does not need.

  • An interrupted turn is read off the keystrokes, not from the provider (internal/terminal/draft.go, hookstate.go, Service.noteInterrupt): Claude Code, Codex and oh-my-pi all skip the hook that ends a turn when the user stops one, so lich publishes interrupted itself when a lone Ctrl+C or Escape reaches a session it knows is mid-turn. It is also raised with no keystroke, when a Claude Code mod acks an abort sent from lich and when a usage limit ends a Codex turn (internal/terminal/limit.go). It is a guess made from bytes, and it has three edges. A provider session running a tool that owns the terminal — an editor opened through a shell command — takes Escape as the interrupt and clears the ring while the turn is still running; the next report from the provider puts it back. opencode does report its own abort, and reports it as the turn finishing (session.status idle), so an interrupted opencode card wears the same solid ring a completed turn does — nothing in the event says which happened, and the provider's own word outranks the keystroke. Crush reports no state at all, so it has no turn to end and the fallback never fires there. And an errand the relay delivered survives an interrupt on purpose: stopping a turn is not answering the request, so the sender keeps waiting for the target's next turn rather than being told the work is over.

  • A diff's context expander reads GitHub, one round-trip per gap (internal/project/filelines.go): FileLines tries the local object first and asks the contents API when the clone does not have it, which on the Pulls screen is the normal case — the branch under review is usually not one this clone ever fetched. The call site is a promise from an RPC, so nothing there says a click costs a network round-trip and a rate-limit unit against the project's gh token. Anything that multiplies the clicks multiplies that: prefetching a file's gaps on mount, incremental ±20 stepping, or an "expand everything" control would each turn one reader's file into dozens of calls. The per-request line cap is the only bound, and a gap wider than it is several calls already. Two things follow from the same shape. The expander needs the revision the diff's new side stands at, so a source that cannot name one has no expander at all rather than a wrong one — a last-turn record from before LastTurn.After existed, or a pull request whose detail carries no commits. And there is no expanding past the last hunk: a unified diff carries no file length, so nothing here knows whether anything follows it, and an affordance drawn there would be a no-op on every file whose change reaches the end.

  • A side-by-side diff never wraps, and its left column only mirrors (frontend/src/lib/git/split-doc.ts, SplitDiffBody.tsx): the two sides are two editors kept level by having the same number of rows, every row exactly one line tall, so a long line scrolls sideways in its own column instead of wrapping. A change block pairs deletions with additions by position, so the rows beside each other in a block that rewrites one thing and inserts another are unrelated lines. Threads, comment boxes and the gap expanders live in the right column only; the left one holds an empty gap resized by a ResizeObserver to match, and anything that grows a right-hand gap without a resize the observer sees would push the columns out of step.

  • An image or PDF preview reads only what this clone holds (internal/project/blob.go): /blob serves the working tree, HEAD or a snapshot tree, and has no GitHub fallback the way FileLines does, so the Pulls screen still prints "Binary file". Adding it there takes two reads this endpoint does not make: the head blob from the contents API, and the old side at the merge base, which gh does not report (baseRefOid is the base branch's tip, and an image the base also changed would show the wrong "before"). SVG is refused on purpose: git diffs it as text, and opened straight from the endpoint a document with a script would run on lich's own origin, so rendering it needs a Content-Security-Policy: sandbox response and a test of its own. One side is capped at maxPreviewBytes (20 MiB) and held whole in memory; the formats are what Chromium decodes in an <img>, so HEIC, TIFF and PSD fall back to the "can't be previewed" line, and a Git LFS file shows the pointer's text diff, never the image. Previews are not deferred the way a text diff's editor is (LazyDiffBody): every image or PDF in an open Review panel is fetched as the panel opens, and a working tree side is fetched again whenever its blob id in the diff changes. The native window reads the same endpoint and decodes in Go (native/cmd/lich-native/blob_preview.go): PNG, JPEG, GIF (its first frame only), WebP and BMP, so AVIF and ICO, which the web draws, show the no-preview state there. It refuses an image whose header claims more than 40 MP before decoding it, holds it scaled to 2048 px on its longest side (no zoom), and never draws a PDF: Open in default app (system.OpenFile) reaches only a file in the working tree, so the old side of a change, a turn's snapshot and a pull request's file have no way out but GitHub's page.

  • A line revert rebuilds its patch, and some of it cannot be done (internal/project/revertlines.go): the panel sends line numbers and the text it drew, never a patch, so a file that moved since the draw is refused instead of reverting whatever those numbers point at now. The index is reverted with the working tree when it holds the change; when it holds a third version of the same lines (staged, then edited again) the revert is refused whole and Discard is the way out. A renamed file offers no line revert, since its old side lives under a path the one-file diff cannot see. The Undo is the reversed patch the frontend keeps for as long as the toast is on screen (REVERT_UNDO_MS); nothing on disk records it after that. And a revert does exactly the lines it names: reverting part of a block can leave code that no longer compiles, which is the reader's call to make.

  • The Review panel's "Last turn" is a window of wall-clock time (internal/terminal/turnsnap.go, internal/project/turnsnap.go): the panel brackets a turn with two git write-tree snapshots taken against an index of lich's own, so what it shows is everything that touched the checkout between the busy and the done — a formatter, an editor open beside lich, the user's own hands. Nothing in it can attribute a line, which is why the copy names the window and never the agent. Four traps follow. add -A obeys .gitignore (deliberately, so this and DiffText never disagree about which files exist), so a turn that only touched ignored files reports itself as having changed nothing. Every snapshot in the app runs on one FIFO worker, because git refuses a second add against an index another holds — so one session's first snapshot of a large checkout delays the next session's, and a queue past snapQueueDepth drops a job, costing that turn its record — the panel reads "lost" for it, which is a turn that ran and cannot be shown, never the "no last turn recorded" of a card that has not had one. It says so for that turn alone: the next one to close files a record like any other, and nothing carries the gap into the workspace database, so a lich relaunched between the two answers "unrecorded" for a turn it lost before the restart. A checkout whose first snapshot fails is dropped outright and never asked again — the ordinary reason is a session opened outside a repository, but a transient failure at spawn reads the same and leaves that card with no last turn until it respawns. A pair read back at launch names loose objects no ref reaches, so a git gc --prune in that checkout between one run and the next leaves the panel reporting a failure rather than an absent turn. And the boundary is the session-state contract, so Crush has no last turn at all: it reports no state (docs/hooks/session-state.md), so nothing ever opens or closes a window there, and the recap band beside it is never drawn either. Whether the switch is offered is read off the session's own reports and corrects itself the day it starts reporting (turnSwitchable), but the sentence under the dead switch naming the provider is a hand-written list (turnUnavailableReason, frontend/src/lib/git/last-turn.ts), because the frontend cannot tell a provider that never reports from one that has not reported yet. The panel draws that sentence only while the switch is off, so a listed provider that starts reporting is named only until its first report, and the list stays wrong in source until it is moved with the contract.

  • A finished turn is unread until its own card is watched (frontend/src/lib/session/session-status-store.ts, frontend/src/providers/project-events.tsx): the solid emerald ring means "back from the agent, not read yet", and it fades only for the session whose terminal is on screen while the window has focus. A card left focused in a background window keeps its ring solid until the window is touched again, which is the point, but it also means a browser that reports focus oddly never fades one.

  • A session close is a hang-up on Unix and a Ctrl+C on Windows (internal/terminal/pty_unix.go, pty_windows.go): closing a card signals the agent and gives it closeGrace to leave, so its exit path runs — hooks, transcripts, whatever it writes on the way out. A ConPTY has no signal to deliver, so Windows sends the terminal's own Ctrl+C instead, and that is a weaker ask: an agent whose TUI reads one Ctrl+C as "interrupt the turn" and wants a second one to quit — Claude Code does — is still killed when the grace runs out, and nothing on screen says so. That the byte arrives at all depends on heedCtrlC: a lich started by a service passes an inherited "ignore Ctrl+C" to every agent it spawns, and clearing it before the first spawn is what the Windows close rests on.

  • The shell-env pty read ends on a printed end marker, not on the child's exit, and Windows never gets one (internal/terminal/shellenv_unix.go, runShellDump): resolving PATH and friends runs the login shell on a pty rather than a pipe so an rc guarded on [ -t 0 ]/tty -s (nvm's and fnm's own init, among others) loads — but neither closing that pty from another goroutine nor SetReadDeadline interrupts a read already blocked in it (both measured directly against a blocked read: Close returns without error and the read stays parked in the kernel regardless, and the deadline is never enforced on this fd). So the read is driven from a goroutine free to outlive the call, and the dump is echo <start>; env; echo <end>: the read returns the moment the end marker arrives, which is what lets an rc that backgrounds a job (an ssh-agent/gpg-agent eval, a prompt tool) still hand back the dump instead of paying the full shellEnvTimeout. It replaced a 300ms silence window that cut the dump whenever env itself was slow to start: on macOS the PATH search for it blocks on a privacy-protected folder like ~/Documents until the user answers the prompt, so a Dock launch resolved nothing. Two edges follow. A shell that never prints the end marker (a hung rc, a prompt waiting on input, an unanswered privacy prompt) is bounded only by the 5s ceiling, and a dump cut there is discarded whole rather than merged half-read. A shell still alive after printing it (a slow .zlogout, an rc that execs into something holding the pty) also waits out the 5s, dump kept: the read ends on the marker and the shell's exit, because the marker alone called a shell milliseconds from exiting parked and refused the next resolution. And the reader goroutine outlives the call for whatever still holds the pty: the fd, the goroutine and the zombie child persist until that holder exits on its own or lich itself does, one leak per resolution that hits this edge, not a recurring one. And Windows gets none of this: SHELL is normally unset there, so ResolveShellEnv returns before shellenv_windows.go's pipe-based runShellDump ever runs — but on a machine where the user sets it anyway (Git Bash, a POSIX-ish shell reached through PATH), that path still runs over a pipe, so an rc guarded the same way is skipped there exactly as it was everywhere before this fix, with no ConPTY wired in to close the gap.

  • A session's cwd readout names the host it cannot see into, and only Unix has one to name (internal/terminal/cwd.go, cwd_unix.go, cwd_windows.go): the directory is polled off the terminal's foreground process group, and a foreground job that is itself hosting the shell somewhere else (tmux, ssh, a container) has a directory of its own that is readable and wrong. The unix readers match that job's comm against shellHosts and publish the host with no path, so the card, its tooltip and the footer draw cwd unknown · inside tmux rather than a real local directory nobody is standing in. Windows has no foreground process group to read a comm from and so has no host to report: a session hosted elsewhere there goes on naming a local path that is not where the user is typing, with nothing on screen saying so. There is no Windows hardware here to build the replacement against.

  • The worktree setup script answers to the main checkout, never the new branch (internal/project/setup.go): improve .lich/setup-worktree.sh on a feature branch and fresh worktrees keep running the old one until the change reaches the checkout the project points at.

  • A worktree off a local branch waits on a fetch first (internal/spawn/spawn.go newerOf, internal/project/freshness.go): lich open --worktree, a subagent's worktree and open_session fetch the base's upstream to start from the newer of the two, so against a remote that does not answer the session appears only once the fetch gives up (baseFetchBudget, 60s). A failed fetch starts from the local branch, which may be behind without anything saying so. The create reuses that fetch for baseRefReuse (15s, internal/project/worktree.go) instead of fetching again, so a commit pushed in between is not in the worktree.

  • A working-tree row carries what git can name, and nothing else (internal/project/carry.go): the copy is git diff HEAD --binary applied to the new checkout plus the untracked files --exclude-standard lists. What .gitignore covers is not carried from the forked session at all — and the fork is not without it either, which is the trap: seedWorktree copies the ignored files the project names (.env*, or .worktreeinclude) out of the main checkout into every new worktree, so an ignored file the forked session had edited arrives as the main checkout's version of it, silently disagreeing with the tracked work that came from the fork. node_modules and the rest of the build output are the setup script's, as always. Everything arrives unstaged: which changes the source had staged is a fact about the commit it is preparing. The row is offered only when the base is the forked session's own branch, because the patch is read against that checkout's HEAD; picking any other base branches off it at its last commit, the way every fork did before. And it rides on the fork entry itself, so the five providers whose CLI only resumes (README's provider table) cannot reach it either. The + dialog offers the same row for the project's own checkout, never preselected. The ignored-file trap does not apply there, because seedWorktree reads the same checkout the copy does, and no provider is out, because the worktree exists before any session opens on it. What it adds is a snapshot that is not atomic: a session still writing in that checkout while the diff is read and the untracked files are copied can leave the new worktree with a mix of two moments. The source is only ever read, never cleaned, so nothing is lost there.

  • git status is polled — one shared poller per repository path (frontend/src/lib/git/git-status-store.ts); the lich plugin's session-touched hook nudges an immediate refresh.

  • The status badge has a single source (internal/project/status.go): the branch, the HEAD commit and the dirty count all come out of one git status --porcelain=v2 --branch parse. A git release that changes those records breaks all three together rather than one at a time, and there is no second call left to disagree with the first — Branch still asks symbolic-ref, but nothing on the polled path calls it.

  • A project opened from outside the window is matched by the spelling of its path (internal/project/project.go, Identify; internal/spawn/projects.go): lich open --project <dir> normalizes what it is handed — ~ expanded, cleaned, refused unless absolute — and then matches that string against the open projects and the workspace's history. It stops there. A symlink to a directory, or a bind mount of it, is a different string and opens a second project on the same real directory: two rows, two ids, two tabs, and every path-addressed lookup lich makes (which project a checkout belongs to, which gh account its calls run as) answering with whichever the query reached first. The window's picker has the same hole — it takes the path zenity hands back — so resolving symlinks here would make the two surfaces disagree about which project a directory is, which is worse than the duplicate. The trap is a workspace where /home/you/work and /home/you/src/work are the same checkout: nothing on screen says the two tabs are one directory. It also moves a boundary that used to be the window's: until this, an agent could reach only the projects somebody had opened in front of it. Any directory on the machine is now one open_session away from a tab, with no confirmation on the way. It is not a new privilege — the agent already runs as you, in a shell that can read the same disk — but it is new visibility, and a card it opens there is a card with a PTY in it.

  • lich's own window refuses what a page did not ask its host for (shell/src/main.rs, kurogane's hooks): the window answers a web permission (reading the clipboard, a camera, notifications) with no and no prompt, opens a window.open or a target=_blank to another site in the system browser instead of a window of its own, keeps a navigation to another origin out of the window, asks where to save a download, and draws a right-click menu of its own: editing items in a text field, Copy on a selection, nothing elsewhere, and no Inspect (Ctrl+Shift+I still opens DevTools). The page cancels it everywhere but a text field and a terminal whose app does not read the mouse (isAppContextMenu in frontend/src/lib/browser-defaults.ts), and leaves it alone while text is selected, so Copy is there too. It refuses Chromium's Back, Forward, Find and Print commands, and shows no status bubble over a hovered link; a mouse's back and forward buttons never reach that command hook (measured), so the page cancels those itself (browser-defaults.ts, on mouseup). The frontend uses none of the refused ones today. The trap is the next feature that does: navigator.clipboard.readText(), a blob download or a popup works in a browser tab (a Mac whose window is missing or died at startup, openTab in main.go) and fails silently in the bundled window until shell/src/main.rs answers the matching hook (on_permission, on_download, on_new_window, on_navigation). A second one: kurogane reports through tracing and the window installs no subscriber, so a window CEF could not create after startup says nothing on the stderr lich reads; a failure to start still does.

  • lich's own window offers the page every primary-modifier chord before Chromium runs it (shell/src/main.rs): kurogane's key hook answers page-first for each Ctrl chord (Cmd on macOS), which is the only way a page can claim one of Chromium's reserved accelerators. Ctrl+T, Ctrl+W, Ctrl+Shift+T and the tab selectors otherwise run in the browser before the renderer is given the key, and no command handler sees them either: that is how Ctrl+Shift+T came to reopen a closed tab in a window with no tabs. What the page consumes is now gone from the browser, and a focused session consumes a lot, since xterm.js claims every Ctrl+letter: while you type in a session, Ctrl+W no longer closes the window and Ctrl+T no longer opens a tab. It also holds in the bundled window alone. Opened as a tab (a Mac whose window died), the browser keeps its accelerators and a chord it reserves never reaches lich at all.

  • Hidden sessions are serialized and destroyed: 2MB replay rings on both sides (frontend/src/lib/terminal/replay-buffer.ts page-side, internal/terminal/replay.go backend-side — the latter survives a full page reload). Scrollback past the ring is gone, not paged. The snapshot carries only the modes xterm's SerializeAddon reads off term.modes; the ones an app relies on and it does not record are restored by hand in frontend/src/lib/terminal/term-modes.ts.

  • One socket carries every session's output (internal/terminal/writequeue.go): the per-session outbox decouples the producers, never the wire. A window that stops reading stalls the connection's single writer, so after wsWriteTimeout (5s) every session's output switches to the /events bridge at once. That is a second socket, and the page reads the two independently: a frame that fell back can land ahead of one still sitting in the stalled connection's buffer, so output is never dropped but its order across that switch is not guaranteed. Until then the queue holds writeQueueDepth frames for the whole app, and two things still wait on it across sessions: a push that finds it full, and the flush a session runs before its exit banner so the banner cannot overtake its own last bytes.

  • Single instance via the pinned port: the bind is the lock (internal/singleton); a duplicate launch hands its URL to the running window through Chromium's profile lock (chromium.Focus) and exits 0. With the bundled window the hand-off is a second lich-shell on the same profile: CEF forwards the command line to the running one, which raises the window it has (the kurogane fork's relaunch hook; without it CEF opened a second browser that kept lich alive after the window closed, #470), reports the forward as a failed initialise, the duplicate exits 1, and focusRunning logs one Warn per duplicate launch. Raising is best-effort: a Wayland compositor may only mark the window urgent. On an install with no window of its own, a duplicate launch cannot focus the running lich's tab; it opens one more tab pointed at it (focusRunning, system.OpenURL).

  • A prompt in use is recognised from the bytes going in, never from the line itself (internal/terminal/draft.go; typed deliveries only, a Claude Code session with its mod is handed the message, see below): a relayed message pastes at the prompt and sends an Enter behind it, so lich holds the delivery back while the user has unsent input there. What it counts is printable input since the last Enter, escape sequences skipped — it cannot see the line, so an edit that leaves it empty by another route (Ctrl+W, a click into the middle of it) reads as a draft that is still there, and a delivery waits out draftIdle for nothing. The stale-draft release is what keeps that a delay instead of a wedged relay, and the wait says so where it is happening: the pull request and issue handoffs (frontend/src/lib/terminal/write-at-prompt.ts) wait on the same answer with no Enter of their own to justify it, so while one is held its target's card carries a rung saying something is waiting for that prompt — hand a conflict to a session you left half a sentence in and the card says where it went, rather than the click reading as having done nothing. The mark lives in the page that is waiting, so it is only ever on the window that started the handoff, and the relay's own hold is not drawn at all: a delivery held there answers to its sender, through the ticket. What stays open is the provider that takes keystrokes through anything other than this PTY, which is invisible to every part of this.

  • lich insert pastes and leaves it, so what the person sees is the provider's own paste handling (internal/relay/insert.go, internal/terminal AtPrompt): it types a bracketed paste at the PTY of all eight providers alike, a Claude Code session with the mod included, and presses nothing. The same gaps as a typed delivery apply and are not closed here. On Windows (ConPTY) the paste markers are dropped, so a multi-line selection reaches the TUI as typed characters and each provider's burst heuristic decides whether a newline inside it submits the line — measured on none of them, since there is no Windows hardware here. A provider that collapses a long paste (Claude Code's [Pasted text #1 +40 lines]) shows the placeholder, not the text, and the person cannot edit what is inside it. And a prompt lich cannot see — a trust dialog, a question of the provider's own — takes the text as an answer: AtPrompt only knows the agent has gone quiet, which waiting (reported by some providers) is what refuses; the rest are on the same footing as send. It ignores the user's unsent draft on purpose, unlike send, because it adds to the line instead of sending it; the draft heuristic's blind spots (above) do not apply to it for that reason.

  • lich focus raises the window the way a second launch does, with the same gaps (internal/relay/focus.go, focusRunning in main.go): the card is opened by an event to the window, and the window is brought up by handing its URL to Chromium against the shared profile. A task dev window is never raised (its own profile, skipped as for a second launch), so it only switches the card. Where lich is running as a browser tab (no window of its own, chromium.ErrNoShell), each call opens one more tab pointed at lich, and every tab open on lich switches to the card. It reaches only live sessions other than the caller's own, as every command resolving a target does.

  • An interrupted wait lets go only when the caller says so, and oh-my-pi never says so (internal/cli/mcp.go, runMCP; internal/relay/inbox.go, collect): a wait whose caller is gone must not take a result, because nothing reads it and the nudge was skipped for its sake. lich wait killed with Ctrl-C closes its connection and the backend sees the request's context end. An MCP tool call interrupted with Esc does not close anything: the lich mcp process lives on, so the backend only lets go when the client sends notifications/cancelled. The MCP clients of Claude Code, Codex, Antigravity, opencode, Crush, Cursor CLI and Kiro CLI carry that message (read in their shipped binaries, not measured Esc by Esc); oh-my-pi's does not, so an interrupted wait_for_answer or send_to_session there keeps holding the line until its own timeout, at most 90 seconds (mcpMaxWait), and a result landing in that window is taken by a wait nobody reads. And even where the cancellation arrives, delivery is not acknowledged: a result drained in the instant before the hang-up reaches the backend, or already written back when the client withdraws the call, is lost with the reply. The window is the round-trip of one message, not the length of the wait.

  • A private errand is opt-in, because lich cannot tell a subagent from its session (internal/relay, SendPrivate): a subagent or workflow step reaches lich through the session's own lich mcp and LICH_SESSION_ID, and an MCP call carries nothing that names its caller (Claude Code 2.1.288 sends only claudecode/toolUseId and progressToken). One that sends without private is the session to lich: a sibling's no-ticket collect can take its result and the note lands at the session's prompt, both measured on 2026-10-03. A private result is announced nowhere, so only its ticket recovers it, and when the ticket's hour runs out only a wait on that ticket hears it expired: one that comes back after another hour, or never, is told nothing. It covers tickets, not ownership: the sessions and worktrees a step opens still come from the session, their marks and stalled toasts land on its card, and closing them is the workflow's own job. opencode's send_to_session takes private from lich-plugin 0.13.2; its open_session takes no task at all, so a private hand-off at open is lich open --prompt --private there.

  • A relayed Enter is timed against silence, not against the target (internal/relay, awaitSettled; typed deliveries only, a Claude Code session with its mod is handed the message, see below): lich presses Enter once the target's PTY has been quiet for defaultSubmitDelay, because nothing here can read a TUI's screen to know it has taken the paste in. The window that opens on the target's own keyboard is closed rather than lived with: from the paste to the Enter its keystrokes are held and written at the prompt the Enter leaves behind (terminal.HoldInput), so what a person types there is late by the length of the window instead of submitted inside somebody else's message. Held, not dropped — but held is still not typed: an interrupt reached for in that window lands after the delivery, on the turn the delivery started. On Windows that quiet is the whole instrument — ConPTY hands a child key events rather than bytes, the bracketed paste markers do not survive, and every provider TUI then guesses at where a paste ends from timing alone. A target that repaints on a timer of its own never goes quiet and gets its Enter at defaultSettleLimit regardless, which is the case this cannot tell from a paste still arriving.

  • A relayed message reaches a Claude Code session through its mod, and every other session by typing (internal/relay, deliver and handOff; terminal.SubmitPrompt): the other seven providers have no mod system, so a task, a result note and a scheduled prompt are typed at them with every trap the two bullets above describe. A Claude Code session is typed at too while no mod polls from it: lich-plugin older than 0.14.0, mods off, a folder not trusted yet, or a session so new its mod has not polled. A session whose mod polls is handed the message while its user is mid-sentence, since nothing is typed into that line. Delivery through the mod is at most once: a prompt no poll collected is typed instead, one a poll collected is never typed again, so a collected prompt lost on the way (the mod reloading as the response lands) is reported unread, not sent twice. A hook that drops the prompt is reported undelivered with its reason, except at a session that was mid-turn, whose ack lich stops waiting for after the receipt window.

  • A task handed to a Crush session waits out its ticket when the agent ends its turn without answering (internal/relay/observe.go, turnCandidates): the relay closes an errand as unanswered on the target's done, and Crush raises no hook at a turn's end, its only event being PreToolUse (0.98.1). Its database does mark the end (finish.reason other than tool_use in .crush/crush.db), but the conversation id reaches lich only on the first tool call, so a turn that calls no tool has nothing to read. The asker hears nothing until the ticket's deadline. The day Crush ships Stop, the existing report-state.sh done closes it.

  • A pull request a Crush session opens reaches its card only when the window regains focus (native/cmd/lich-native/gitstate.go recheckMissingPR, frontend/src/lib/pulls/use-pull-request.ts useRecheckAfterTurn): gh pr create moves neither branch nor HEAD, so a card showing no pull request asks gh again when its session's turn ends, and Crush reports no done (docs/hooks/session-state.md). The day Crush ships Stop, the existing report-state.sh done closes it.

  • Only a merge lich makes is announced, and only to a card running at that moment (internal/relay, AnnounceMerge; called from the Pulls screen's merge): a pull request merged on github.com, with gh in a terminal or by an agent leaves every session in its checkout believing it is still open, and so does one merged while the card was parked or never opened. The notice also lands as a delivery like any other, so a cleanup clicked in the second before it starts a turn finds no session mid-turn, asks nothing, and removes the checkout with the notice still on its way.

  • A CLI run as a tool inside a session is told apart by conversation id, and Kiro has none to tell by (internal/terminal/nested.go, docs/hooks/session-state.md "Nested agent CLIs"): a claude -p the agent runs inherits the card's LICH_* env and reports as it. A state report naming another conversation is dropped, and a session-start naming one is dropped while the card's turn is open. A scripted Kiro CLI run (--no-interactive, how an agent runs one) carries no id in any hook payload (measured on 2.21.0), so a nested kiro-cli still reports as its host. The start guard reads lich's own turn, so a host lich never sees open cannot use it: a Crush session reports no turn at all, so a nested CLI's start can still move what its card resumes. A plugin older than 0.20 sends no id on state reports, which are then taken as before.

  • Only a Claude Code subagent becomes a lich card (lich-plugin's hooks/agent-cards.js, its docs/agent-cards.md): the plugin's mod takes the model's general-purpose Agent call and runs it as a lich session with lich open --subagent --prompt, in the background. The card opens in the asking session's checkout (spawn.OpenSubagent), unless the call asked for worktree isolation, which opens a worktree of its own; it is filed with the asker (in the asker's folder, or one named after it that the asker is filed under too), its errand is kept past ticketTTL for as long as the worker runs, the asker hears once per block when the worker waits on a permission, and the report comes back whole in a [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 (4 of 84 workers measured on 2026-10-09), or runs a lich-plugin older than 0.17.0, is handed the ticket instead. A final turn that ends blank ends the errand unanswered (relay.WorkerUnanswered), once the plugin posts it (docs/hooks/mod-answer.md, Unanswered turns). A turn ended by an API error or a refusal fires StopFailure, which carries no background list, and no Stop (measured on Claude Code 2.1.296), so the plugin cannot tell it from a turn whose background work will resume the worker and posts nothing: that errand, like one whose turn was aborted with Esc, stays open with nothing reported while the worker runs, kept past ticketTTL rather than ending at a deadline, until a later turn answers or the worker is closed. A worker's report, and a turn end that would call a ticket-carrying errand unanswered (relay.turnCandidates), wait 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 is filed in the worker's inbox as expired (relay.sweep, run on the scheduled-prompt tick, so it lands up to 30 seconds late), and that notice starts the turn whose report is the answer. Closing a worker ends its errand (relay.SessionClosed, wired before the PTY is killed so the SessionEnd its CLI reports finds nothing to call unanswered) without a word to an asker that closed it itself, as TaskStop does; closed from its card or by another session, the asker is told it stopped. Who closed it is the session lich close runs in (terminal.CloseBy), so a TaskStop run from anywhere but the asker's own process reads as someone else's. 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 (docs/hooks/mod-control.md, A prompt as a notification), through a render path Claude Code does not document: a Claude Code that drops it shows the raw XML as a prompt, and a lich-plugin older than 0.16.0 shows the note as 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) 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. 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 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.

  • An install started from go run registers the lich on PATH, not itself (internal/agentplugin/crush.go, resolveLichBinary): Crush's, oh-my-pi's, Cursor's, Antigravity's and Kiro's registrations name the absolute path of the lich that wrote them, and under go run — task dev — that path is the binary the toolchain built into its cache and deletes when the run ends, so writing it gives a registration that works for the rest of that session and then fails silently forever. lich writes lich from PATH instead, recognising the cache by shape (go-build*/b*/exe/*) since the toolchain exports no marker. The trap is that a dev install then points at whatever version is installed on the machine — harmless, because the registration is only the transport and a session reaches the lich its PTY's coordinates name, but not what the file appears to say. With no lich on PATH at all, a dev install registers nothing: Crush, oh-my-pi, Antigravity and Kiro still get their hooks, and Cursor's install refuses outright.

  • A plugin install is pinned, so the harness never updates it on its own (internal/agentplugin/compat.go, claude.go claudePinMarketplace, codex.go codexPinMarketplace): lich installs the newest plugin release inside the range it speaks and declares Claude Code's and Codex's marketplace at that release's tag, so a plugin release this lich would not parse never arrives behind its back. The price is that a newer compatible release only lands through lich's own update prompt, and a marketplace the user pointed somewhere else is re-pointed on the next install. Neither CLI can change a declared ref, so an install removes the marketplace and adds it back; on Claude Code the remove also uninstalls the plugin, and an add that fails there (offline mid-install) leaves it uninstalled until the next attempt, which the install prompt then offers. An install that was already outside the range, from before the pin or by hand, is caught at startup from its installed version, and again by the X-Lich-Plugin header its hooks send (docs/hooks/README.md) once a session reports. A report whose payload lich refuses never reaches that check, so a plugin old enough to send one only shows up in the startup prompt and the log.

  • Only a Claude Code session can be driven from lich (lich control, control_session, spawn.Control, internal/terminal/modcontrol.go): the control channel (docs/hooks/mod-control.md) has a mod as its client, and mods are a Claude Code feature, from 2.1.280 on and only with the lich-plugin mod loaded, with lich-plugin 0.15.0 or newer. The other seven providers have nothing that runs inside them and polls, so they cannot be prompted, stopped, switched to another model or handed a slash command this way, and spawn.Control refuses their sessions by provider before anything is queued. The mod-control contract's Known ceilings has the rest; three are traps a caller walks into:

    • /model and /effort are refused as slash commands: measured on Claude Code 2.1.288, run through a mod they write ~/.claude/settings.json (model, modelSettings.<model>.effortLevel), the default for every new session. The model and effort actions are the per-session route.
    • A slash command that opens a dialog (/cost) holds that session's later slash commands until someone presses Esc in its terminal; lich cannot tell such a command apart before running it.
    • A session cannot control itself, for any action: an abort would end the asking turn, and a prompt or a slash command would only run after it.
  • Only a Claude Code session can be asked a side question (lich ask, ask_session, spawn.Ask): the answer is a $.model.fork the same mod makes, so the seven other providers are refused by provider, as for control. Owner's choice: no other CLI exposes a fork of its own conversation to anything running inside it. The traps, all measured on Claude Code 2.1.289 (the contract's Known ceilings has the detail):

    • The answer knows the conversation only as of the session's last finished model response: a reply being written, a tool call running, or a prompt whose first reply is still being written is not in it.
    • An ask cannot be cancelled. Past the 90 seconds lich waits, the fork runs on and is billed until it ends, and its answer is dropped. A mod reload mid-answer loses it, and the caller waits out the 90 seconds.
    • Its tokens never reach the session's cost: the fork is not in the transcript lich reads cost from. It is cheap only while the prompt cache still holds the session's transcript.
    • The answer is cut at 16,000 characters.
    • A session cannot ask itself: the question would ride the request it is waiting on, which the fork never sees.
  • Only a Claude Code session reports its own readout (internal/terminal/modusage.go, docs/hooks/mod-usage.md): its mod, with lich-plugin 0.15.0, posts the context window, rate limits and cost Claude Code measured, and those win over what lich derives for the conversation they name. Every other provider, and a Claude Code session without the mod, keeps the derived figures, so two sessions on the same model can disagree by the requests no transcript records (measured 2.7% and 3.1% of a session's cost on 2.1.289). The report fires at the end of a turn and never on a compaction, so the tokens stay the transcript's and only the window and the money come from the report; and its cost replaces the conversation's ledger row and drops its sub-agent rows, so a lich restarted before the session's first report re-scans that conversation from the transcript until the report lands again. Only a long-lived token login's plan gauge uses the reported rate limits: a credentials login keeps its usage route, which names the plan, the account and the model-scoped caps a report does not carry.

  • Only a Claude Code session's status line shows its errands (internal/terminal/modstatus.go, docs/hooks/mod-status.md): the requests it owes, the ones it handed out and the answers waiting in its inbox are read by its mod, and mods are a Claude Code feature. On the seven other providers, and on a Claude Code session without the mod, the card's tooltip and inbox mark are the only place they show. The read is polled by the mod, so the status line lags an errand by up to its read interval, and an errand sent privately (a subagent or workflow step inside the session) never shows there, since the session's own collect never reaches it.

  • Only a Claude Code card says it is compacting (compacting, docs/hooks/session-state.md): the start of a compaction is reported by the mod's session.compact, and no other harness has that event wired. On the seven other providers, and on a Claude Code session without the mod, a /compact keeps the finished turn's check and an auto-compaction is a spinner with no tool line, which reads as a stuck turn. A compaction the mod never closes (the mod killed mid-call) holds "Compacting…" until the session's next report.

  • The plan gauge answers to undocumented routes, and three providers have none (internal/quota): Claude Code's and Codex's usage routes are what their own CLIs poll, Cursor CLI's is the cursor.com dashboard's own (/api/usage-summary, sent the CLI's token as the dashboard's session cookie with the dashboard's Origin and Referer, which its CSRF check demands), opencode's is the OpenCode Go console's, and Antigravity's is a slash command of its CLI. None is published API. A field renamed upstream drops the window it fed rather than raising anything, so a new kind of limit is invisible instead of wrong. oh-my-pi, Crush and Kiro CLI report no plan lich can read, and opencode reports one only for an OpenCode Go subscription: its key is read from the opencode-go entry of opencode's auth.json, so a key given only through OPENCODE_API_KEY or OPENCODE_AUTH_CONTENT is not read (the first is shared with Zen keys, which have no plan). The 200 shape of that route was ported from the opencode console as the Orca project parses it, never measured here: only its 401 and 403 were. A 403 there means the key has no Go subscription, and reads as signed out. Cursor reads only the CLI's own login (auth.json where the CLI writes it, providers.CursorAuthDir, and on macOS the Keychain item cursor-agent 2026.06+ writes), never the Cursor editor's session, which lives in another app's SQLite database. Cursor accounts still on request-quota billing (the dashboard's older /api/usage route) show their plan with no windows. Antigravity keeps its Google login in the OS keyring and mints its own token, so lich runs agy -p /usage --output-format json (measured on agy 1.2.5: no turn, no tokens, no conversation) with the session's own environment and the agy on lich's PATH, never a configured binary that may be a wrapper. An agy older than 1.1.11 answers that as a prompt and spends a turn, so it is never asked; a newer one that answers with a turn anyway stops being asked until lich restarts. A machine with no agy shows no Antigravity plan at all. That run takes two to three seconds per reading. lich reads those logins and never writes them: it does not refresh a token, so an expired one reads as signed out until the provider's own CLI rotates it. A reading is cached for five minutes because the endpoints rate-limit hard; the number on screen is up to that old, and nothing on it says so.

  • A turn a usage limit stopped is picked back up on two providers only (internal/terminal/limit.go, internal/relay/resume.go): Claude Code and Codex are the two that write the limit down with its reset. On Antigravity, Crush, Cursor CLI and Kiro CLI nothing marks a limit at all, so the card keeps spinning until the next prompt; opencode and oh-my-pi retry by themselves and lich adds nothing on top. Claude Code's end arrives only as StopFailure, which an older lich-plugin does not report. Codex raises no hook for it, so an open Codex turn is read off its rollout every 30 seconds, and the limit is inferred: a turn completing with nothing said right after a window shows 100% spent (measured on codex exec 0.144.5, never on the TUI); a change to that shape stops the read without a word. The continuation is a scheduled prompt told apart from a person's by its exact text, so someone parking those same words has them dropped on the session's next turn. Nothing is parked for a reset already past or more than 24 hours out (Claude Code's own auto-continue gives up at the same horizon), nor over a prompt already parked there. Any turn starting before it is due drops it, which is also what keeps it from landing behind Claude Code's own autoContinueAtUsageLimit, a setting the server enables per account, which is why the prompt is due two minutes after the reset rather than at it. The card's limit rung lives in the window alone: a reload drops it, while the parked prompt stays on the card as a plain scheduled one.

  • The pace marker is borrowed calibration, and it is silent far more often than it is wrong (internal/quota/pace.go): the two numbers that decide when a weekly window is marked as spending ahead — fifteen percentage points past the elapsed share, and no marking at all in the first twenty-four hours after a reset — are the claude-swap project's measurements (src/claude_swap/pace.py, its issue #125), not lich's own. Nothing here has been tuned against a lich user's accounts. The consequence to know before debugging a marker that "never appears": a window ninety percent spent on the first day of its cycle is deliberately unmarked, because just after a reset the elapsed share is near zero and almost any use at all would read as far ahead. Only the weekly window is paced, so the five-hour one and Codex's monthly free-tier window carry no marker however they are spent. The verdict is derived from the window's own reset time, which is the next reset and never the start — the start is that time rolled back whole windows — so a provider that stops reporting a reset time silently drops the marker rather than the gauge.

  • Two more fields of Claude's usage payload are read no further than measuring them (internal/quota/claude.go, limits[].severity and the top-level extra_usage/spend blocks): every severity observed on a live account reads "normal", so its scale — what a non-normal value looks like, whether it maps to a colour — is unknown, and swapping the local usage-based colour ramp (usageColor) for it would be a guess dressed as a reading of the source. extra_usage and spend are the credits that cover spend past a full window; both exist in the payload but come back entirely null/disabled on every account measured, so their filled shape — what a partially-spent credit balance actually contains — cannot be built against here. A gauge built on an unobserved shape is the same failure is_active/locked_reason fixed around, repeated. The payload's top level is otherwise a graveyard of null codenames — nimbus_quill, tangelo, iguana_necktie, omelette_promotional, cinder_cove, amber_ladder, juniper_tide, seven_day_omelette, seven_day_cowork — every one of them unpopulated on every account measured, which is the standing evidence that reading limits[] and silently skipping a kind lich has no name for is the right default, not a gap to close. Codex's wham usage route carries no equivalent to any of this — no per-window active flag, no lock reason, no credit block — so this ceiling is Claude-only by the shape of the payload, not a choice.

  • Measuring a token-only login costs a request against the very plan it measures (internal/quota/claude.go): a long-lived OAuth token (claude setup-token) carries user:inference alone, so the usage route answers it 403 and the account is read the way Claude Code reads it for itself — one max_tokens: 1 message, for the rate-limit headers on the response. Reading the gauge therefore spends quota (negligibly) and appears in the account's own usage, once per cache window. That request carries Claude Code's system prompt verbatim because the API rejects an OAuth token without it — the same coupling as the user agent, and it fails closed, as a failed reading. Headers carry the two account-wide windows and no plan name, so such a session shows no model-scoped weekly cap and no "Max 5x" badge. A session on that login whose mod reports (docs/hooks/mod-usage.md) stands in for the request until one of its windows resets, so the request is only spent for an account no such session has spoken for since.

  • The sandbox confines a working agent, not hostile code (internal/sandbox): namespaces and mounts on Linux, a path policy on macOS, and nothing else — no seccomp filter, no Landlock ruleset. The network is never cut (the agent needs its API and the plugin's hooks report over loopback), so anything readable inside is exfiltrable, and ~/.config is readable: a token stored there is in reach. gh's is not one of them — it lives in the system keyring, which is why reaching it takes the flag below. Every session also carries LICH_TOKEN, so a confined agent can call lich's own RPC over loopback: a method that reads a host path it was handed would copy anything into reach of the sandbox, which is why the attach flow opens the picker inside the backend (drop.Attach) instead of taking a path. The private home is writable and vanishes with the session, so a dotfile an agent writes is gone next spawn with nothing saying so. On Ubuntu and Debian the kernel may refuse the user namespace outright (an AppArmor policy), which surfaces as bubblewrap's own error in the card and no session: Available is exec.LookPath and stays that way, because only a spawn can ask the kernel, so nothing before the card can refuse the rung. What answers ahead of time is lich doctor, whose sandbox check opens one confined child and reads a file in the checkout and a file in the home the backend replaces: it is a diagnosis, not a gate, and a machine where the probe fails still lets the user turn the sandbox on and watch it fail. ~/.ssh is not mounted at all: a push over ssh from inside a confined session fails unless the project hands over the ssh agent (below), and lich's own PR flows run outside the sandbox and are unaffected either way. The distribution's /etc/ssh/ssh_config.d drop-ins are replaced by an empty directory, because inside the namespace they belong to nobody and ssh refuses to read a config file it does not own — so a host whose ssh depends on one of them (a corporate ProxyCommand, say) does not have it inside the sandbox. The display server's socket is mounted, because the agent's own copy and its clipboard image paste shell out to wl-copy and xclip and both fail without it: a confined session can therefore read whatever you copy, a password manager's paste included. A host without Wayland gets the X11 socket and its cookie instead, the wider of the two — X clients are not isolated from one another — and macOS gets neither, its pasteboard being a mach service rather than a socket, so a confined session there has no clipboard at all. macOS has no hardware here — its profile is unit-tested and has never run.

  • The two sandbox grants are all-or-nothing (internal/store/settings.go, internal/sandbox, internal/terminal/sandbox.go): a confined session reaches the network as the user only where the project turned one of them on, and each is all-or-nothing. The ssh agent is handed over as a socket, so nothing private enters the sandbox — but the session signs with every identity loaded in that agent, against any host it can reach, for as long as it runs. OpenSSH can pin a key to one destination, and only when the key is added on the host (ssh-add -h); lich is given a socket that is already populated and can only pass it on whole. The socket does not travel alone: ~/.ssh/known_hosts is mounted read-only beside it, because without it ssh cannot verify github.com, has no tty to ask on, and fails with "Host key verification failed" before the key is ever offered — the grant would hand over the credential and not the push. That file is public host keys, never a secret; what a confined session learns from it is the list of machines the user connects to. Read-only, so a host the user has never connected to outside the sandbox still fails inside it and cannot be learned there — blind trust-on-first-use is not a thing to grant an unattended agent. And a known_hosts symlinked out of a dotfiles repository is dropped like every other link in the home (internal/sandbox, mounts.keep; the card names it from SkippedLinks), which takes the whole grant down with it. That is why Settings lists what is in the agent, and re-reads the list every time the window regains focus: ssh-add is run in a terminal outside lich, so the tab back is the frame where a key loaded a moment ago has to already be named by the switch that hands it over. The GitHub token is one account's, the project's own (vcs.account), and it rides in the session's environment: the agent can read it back out of its own environment and spend it on anything that account's scopes allow, this repository or not. Both switches say that much in the sentence under them — a grant is decided in a settings pane beside a sandbox whose whole promise is that the user's credentials are not in there, and a switch read as "let it push with my GitHub key" is read as a smaller promise than it makes. Neither grant is keyed by provider — a grant describes what is inside the sandbox, not who runs in it — so turning one on turns it on for every provider confined in that project. It is resolved once per spawn, so a token gh rotates mid-session goes stale with nothing saying so, and a gh auth token that fails leaves the session with no token rather than failing the spawn. Both are off by default, and both are Linux in practice: the macOS profile denies reads inside the home while a launchd agent socket and gh's keyring live outside it, so a confined macOS session never lost either and the switches change nothing there — they are inert rather than hidden, and there is no macOS hardware here to prove it further. Windows has no sandbox backend, so neither switch exists.

  • The macOS floor is the toolchain's, not lich's (build/darwin/Info.plist.tpl, build/darwin/homebrew/lich.rb.tpl): nothing in lich needs macOS 13, but Go 1.27 dropped every release before Ventura, so a binary built from this module cannot run on Big Sur or Monterey. The cask's depends_on and the bundle's LSMinimumSystemVersion say 13.0 because the compiler does — both move with the next Go bump, and a machine below the floor is refused by Homebrew rather than by a crash.

  • A workspace database only moves forward (internal/store/migrate.go): PRAGMA user_version counts the migrations applied, and a lich that finds a number above the ones it knows refuses to open the database, in a dialog naming both, rather than write its defaults over columns it has never heard of. There is no migration down: going back to an older lich means installing the newer one again or starting a fresh workspace. The refusal only exists from the release that stamped version 1; a lich before it reads no version and opens anything, which costs nothing until the first migration after version 1 adds a column. The trap for a contributor: schema and legacyAlters are frozen at the version-1 shape and every change after it is an appended entry in migrations, never an edit to the CREATE TABLE and never a line in legacyAlters: the version-1 step is the one step every database that already exists has long since run, so a column added there reaches none of them and every read of that table fails. 0.53.0 shipped a column that way and loaded no projects at all.

  • The workspace database is three files while lich runs (internal/store/store.go, open): WAL mode appends commits to lich.db-wal beside lich.db, with lich.db-shm as its index, and folds them back into lich.db at checkpoints and on a clean close. Copying lich.db alone off a running lich, as a backup or a move, leaves the commits since the last checkpoint behind; copy all three, or close lich first. With synchronous(NORMAL) a power loss can drop the last commits, never corrupt the file. The rollback journal it replaced created, synced and deleted a file on every commit, tens of milliseconds each on Windows, which was most of the backend suite's time there.

  • The history's branch is read live, so a row whose checkout is gone has none (internal/project.BranchesOf): the branch a row shows is not the one it stores — a worktree keeps the name it was created with while an agent moves the branch inside it, so the stored snapshot dates the close and only git can say what the checkout is on now, which is what the row draws. The batch runs once per settled search, which also means a branch that moved while the palette is up is stale until the query changes or the palette is reopened. A checkout removed behind lich's back has no branch to read and no session to resume: that row says checkout gone and offers to forget itself, which is the only way such a row is ever collected — PurgeWorktreeSessions never ran for it, because the removal never went through the app.

  • The history's external conversations are told from lich's own by a stone, and stones only start at schema version 8 (internal/store/external.go, internal/terminal/conversations.go): a provider keeps its transcript after lich deletes the session row, so the listing leaves out ids a row holds and ids a deleted row held (provider_session_tombstones, kept by triggers). A row deleted before that table existed left no stone. Claude Code covers the gap with the name lich spawns it under (relay.IsRosterName on the first custom-title): measured on a real workspace, it marked 490 of the 539 Claude conversations lich held no row for, and the 49 left had no title at all, which is a plain claude run. The other seven providers write no such mark, so their conversations from before the stones list once, as if started outside lich. Kiro CLI writes session_created_reason: "subagent" on every conversation (2.21.0, measured on 15), so it is no sub-agent filter and is not read.

  • An external conversation matches a project only in a checkout's own directory (store.ExternalSessions): a conversation started in a subdirectory of a project is not offered, because a session lich opens runs in the project's directory or a worktree and nowhere below it. Checkouts come from git worktree list, run for every project with a .git on each listing (about 300 ms over 30 projects); a project that is not a repository owns only its own directory. Search reaches an external conversation by its title, project and path; what was said in it is indexed only after it is adopted and closed, the same as any session. Crush's project index is read at $XDG_DATA_HOME/crush/projects.json (else ~/.local/share), measured on Linux only; where Crush keeps it on Windows and macOS is not measured, and a miss there lists no Crush conversation.

  • A filed backend answer outlives the screen that asked, under a key its caller writes by hand (frontend/src/lib/remote-cache.ts): a useRemoteResource caller that passes cache has its answers kept in module memory until the page reloads, under exactly the string it composed. Two callers that compose the same string serve each other's answers, and a string that leaves out something identifying paints one repository's answer onto another's screen — instantly, and then corrected one round-trip later, which reads as a flicker rather than as a bug. It is deliberately not key: key carries what dates an answer (the checkout's HEAD), which a fresh mount does not have until its git poll lands, so a cache keyed by it misses on the one frame the cache exists for. The cap is 32 answers with no byte budget, so a review that walks through more pull requests than that pays a skeleton on the way back to the first.

  • Seeding that filed answer runs during render, so its bookkeeping may never live in a ref (useMovedAnswer, frontend/src/lib/use-remote-resource.ts): React can discard a render that updates state during it and replay it, and a ref written by the discarded pass makes the replay skip the very update it guarded. The symptom is silent and looks nothing like the cause — the answer is seeded, the screen paints, and the next frame is blank again with no setter anywhere having run. use-remote-resource.test.tsx pins it, but only under two conditions that are easy to drop: the probe must change the request on a live component (a remount initialises the marker and never exercises the replay), and it must record frames from a layout effect rather than from the render body (the body sees passes that were never committed, which reads a correct hook as an oscillation). A probe missing either one calls the ref version green.

  • The dock's remembered browse is module memory, keyed by a path and never swept (frontend/src/lib/file-browse.ts): every checkout the Code tab has ever browsed keeps its filter or text search, folds, preview, landing line and marked row until the page reloads — a few strings per checkout, deliberately not worth a sweep, and deliberately not persisted: these are positions in a tree that is re-read on each mount, and outliving a reload would mean pointing at files that have since moved. The key is the checkout path with no project or session in it, so two projects sharing a path share a browse, which is the same thing as saying they share a checkout. The tree and each previewed text file now also file their answers in remote-cache, whose 32-entry cap they share with the pull request screen: a browse that opens more files than that evicts the oldest answers, and the panel pays a "Loading…" on the way back to them. An image or PDF preview is not cached and loads again on the way back.

  • The Review tab's remembered source is a wish, not what is on screen (ReviewPanel, frontend/src/lib/dock-prefs.ts): the pref is global and holds what the user picked, while what the panel shows is that choice put through turnSwitchable — a session whose provider never reports and holds no last-turn record has no turn to bracket, so it is shown the working tree, with no switch or, on Crush, a disabled one naming why. Nothing writes the guard's answer back, and that is the whole design: a session with neither is unswitchable after a reload until it next reports, so a panel that reset the pref instead of overriding it would erase the choice before the switch had a chance to appear. The two halves of that guard read different sources — the record rides the session's hydration, the diff behind it is seeded when the PTY is spawned — so a panel that reaches a restored card before its spawn has been tracked is offered the switch and told nothing is recorded, until the next read.

  • The pull request screen's remembered state is read once, at mount (frontend/src/lib/pulls/pulls-prefs.ts): the filter box, the quick filter and the selected pull request are keyed per project but seeded from useState, which holds because every route into the screen carries its own project and leaving one unmounts it. A future route that reuses Pulls across two projects would keep the first one's box and its selection, and nothing in the component would say so.

  • The settings screen remembers nothing per project, on purpose (frontend/src/lib/settings-prefs.ts): the pane that was open, the search box and the provider picked are stored under one key each, so opening Settings in project B lands on the pane project A was reading. That is the rule pulls-prefs states, landing on the other side — the nav is the same list of panes in every project, so neither is about a repository — and it is a decision rather than an oversight: the values those panes read and write are project-scoped already, in the workspace database under the project's own id. The trap is for whoever adds a pane that is genuinely about one repository's content. Its remembered state belongs on the per-project side, which means a new key with the project id in it, not another global one beside these three.

  • The settings search reads names, and its index is written by hand (frontend/src/lib/settings-index.ts): every control is listed there with the section and group it lives in, because the panes are React components whose blocks exist only once rendered and the suite runs in node. Two things follow. A block added without an entry is a control the search cannot find, which is why settings-index.test.ts reads the SettingBlock and SettingRow literals back out of the source and fails on one nobody indexed; that guard is the only thing standing between this file and silent rot, so deleting it costs more than it looks. And the search matches titles plus a few hand-picked keywords, never a description, a stored value or a theme's name: a user hunting for emerald or a port number finds nothing, and the empty state says as much rather than pretending the setting is absent. Shortcuts and the panes themselves are indexed off HOTKEY_ACTIONS and SETTING_SECTIONS, so those two never fall behind.

  • The footer editor's sides wrap, and stop lining up when they do (FooterLayoutEditor.tsx): each side is a tray of chips laid out with flex-wrap, so a user who turns on most of the twelve items gets two rows in one column and one in the other, and the two stop reading as the two ends of the footer. Chosen with the drag: the alternatives that never wrap are lists, one item per line, and moving an item then stops being a drag between the sides. Both sides stretch to the taller of the two so the block still reads as one thing. Whoever adds a thirteenth footer item is making that worse, and the answer is not a narrower chip: it is deciding the drag is worth less than the alignment. The chip also carries nothing but its own reading: a menu and a remove button that appeared on hover were tried and taken back out, because a control that grows under the pointer shifts the chips beside it at the exact moment a drag is being aimed. Moving an item without a pointer is the dnd-kit keyboard sensor (focus the chip, Space, arrows), which is why removing the menu costs no keyboard path.

  • A release's highlight is read from the binary, so fixing it after the tag fixes nothing a user sees (internal/patchnotes): the What's new dialog parses the CHANGELOG.md embedded at build time — the alert blocks under the version heading included. Editing that release on GitHub, or the changelog on main, changes the release page and no dialog anywhere; the users who already updated have recorded the version as seen, and the ones who have not will read the binary they install. A wrong headline is a patch release. The update toast reads nothing from the release but its tag, on purpose: the toast says a release exists, and the dialog is where it speaks.

  • The Files changed tab remembers which file, never where in it (frontend/src/lib/pulls/use-active-file.ts): the changed-files tree's mark comes back with the tab, but the diff pane reopens at the top. Nothing in this codebase restores a scroll offset, and the one that would have to be restored here is not measurable at the time it is needed: LazyDiffBody builds each file's editor only as its card nears the viewport (FileDiff.tsx), so on the way back every card is a placeholder sized from a line count and the page's real height arrives over the following frames. Re-jumping to the marked file would land on that estimate and drift as the editors mount. The mark already meant "the file last selected" rather than "the file on screen" — a click followed by a hand scroll leaves it behind on a live tab too — so restoring it alone says nothing untrue. That relanding is also why returning to the tab costs the lazy mount again: the editors are destroyed with the tab and rebuilt on the way back, which is the work LazyDiffBody exists to spread out rather than avoid.

  • This screen's per-pull-request state is re-read during render, not at mount alone (useActiveFile): the bullet above about pulls-prefs.ts does not extend to it. The Files tab is not remounted when the list column moves to another pull request, so a useState seed would mark the previous pull request's file on the next one's tree. The re-read is held in state and never in a ref, for the replay reason use-remote-resource.ts documents, and use-active-file.test.tsx pins it by moving the pull request on a live component — a probe that remounted instead would call the ref version green.

  • The CEF cache only grows (build/cef.sh): every worktree's task build:shell shares one unpacked distribution per CEF version under the user's cache dir (build/cef.sh --dir prints it), and nothing ever deletes one. Each CEF bump leaves the previous ~1.4 GB behind, as does the per-worktree .cef/ of a checkout from before the cache. Clearing lich-cef/ is safe; the next build fetches again. The fetch tool itself is still built per worktree (shell/target/tetsu-tools), so a fresh worktree compiles it once even on a warm cache.

  • A profile belongs to one window build, so pinning another opens lich at its defaults (internal/chromium/profiledir.go): every lich.* UI setting lives in the localStorage of the profile keyed by the path of the window that opened it, and nothing copies between profiles. A LICH_SHELL pin, or the macOS tab a dead window falls back to, comes up factory-fresh; the settings are still under the other key. A window pinned at a path carrying its own version (an AppImage) is a new profile on every update.

  • Every package ships lich's own window, and only macOS falls back to a system browser (internal/chromium/shell.go, shell/): the Linux packages, the Windows installer and both Lich.app bundles ship an embedded Chromium (CEF through kurogane) beside the binary, and nothing but a LICH_SHELL pin outranks it. The trap: the four launch paths are one, so a window-side change (a flag in Args, a prefs write, the restart signal) lands on lich's own Chromium everywhere but on a Mac whose window died, where lich serves a plain tab and the change reaches nothing. Windows and macOS have one more: both were built and smoke-tested on a CI runner only (release.yml opens the window and reads a page over CDP), never on a desk, so the taskbar icon and AppUserModelID grouping on Windows, the Dock tile, Cmd-Tab and menu bar name on macOS, and the graceful close on restart on both are designed, not seen; what the macOS runners did measure is that the subprocesses hold no Dock tile of their own and the page renders (release.yml, the mac job, once per architecture). The Intel bundle is the least seen of all: it is built and run on GitHub's macos-15-intel runner, nobody here has an Intel Mac, and that runner is the last of its kind GitHub hosts, so the day it goes the Intel window goes back to being a cross-build nobody can run. A page read over CDP is not a pixel either: a window that renders every frame and presents none reads as green, and that is exactly what an Intel UHD driver did on Windows until shell/src/main.rs turned DirectComposition off there. No runner here can look at its own screen, so presentation is only ever proven on a desk.

  • A Linux or Windows install whose window is missing or dies is a lich that shows nothing but a dialog (internal/chromium.Run): go run with no LICH_SHELL pin, a binary copied out of the package it shipped in, a package missing lib/lich/shell, a window that exits on a missing system library or a glibc older than 2.34 (Debian 11, RHEL 8, which lich-shell will not load on) — each ends in the error dialog with the log path, never in a browser on the machine; a missing window's dialog names the release page, a crashed one's quotes its FATAL lines. Only macOS keeps a fallback, and only to a plain tab: a window that exits with an error inside startupGrace (30 s, because a segfault is reported only after its core dump is written) hands the URL to the default browser instead. lich doctor names the window a launch would open.

  • Every release asset is a complete package, and an update is run by the release being left (internal/appupdate, TestUpgradeMatrix): the matrix pins the route today's lich takes from each package, not what an older one does on its way out, which is frozen in its binary. 0.48 to 0.50 also shipped bare binaries, and an install built from one sees this: a macOS bare binary's update button fails on a 404 (the release has no such asset now) until the cask replaces it; a Linux bare binary is offered install.sh or yay, which install a package, while /restart relaunches the bare binary, still at the old version; a Windows exe without shell\ never opened in the first place. A 0.48 Scoop install ran the installer into Scoop's directory, and scoop update puts it back. Nothing below 0.48 is supported. A Linux tarball updates by hand: the prompt links the release page, because a package installed over it leaves the tarball's binary relaunched. On disk, the unkeyed Chromium profile of a release before the window moves under the window's key (migrateProfile); a profile a newer system Chrome wrote, opened by an older CEF, is the failure ProfileDir names, and it has not been measured.

  • Only a window that exits with an error leaves its stderr in the log (internal/chromium/stderr.go): the window's stderr still reaches lich's own, and a failed exit, of the window or of the launch that focuses it, carries its last 20 lines into lich.log, with the first three FATAL or "Check failed" lines kept ahead of them once they scroll out; the dialog shows only those lines, or the last three. Nothing is logged as it comes, because a healthy start already writes Fontconfig, GPU and D-Bus warnings, so a window that misbehaves and keeps running leaves nothing behind. The stream is a pipe the window's subprocesses inherit, and a clean close waits up to stderrDrain (2 s) for them to let go of it (10 ms, measured on Linux). Windows takes the same path unmeasured: whether Chromium writes its FATAL lines to stderr there, rather than only to its own debug log, has not been seen, so the tail may hold no more than lich-shell prints itself; lich -- --enable-logging=stderr asks Chromium for them.

  • The window's own sandbox needs an install a package manager made (shell/src/main.rs, the kurogane fork's no_sandbox): Chromium confines the window's subprocesses in a user namespace, or through the setuid helper in cef/, beside libcef.so (not beside lich-shell: a helper there that is not root-owned 4755 aborts the browser process whatever sits elsewhere). The packages nfpm builds (deb, rpm, archlinux) set that mode from their install script, since nfpm cannot mark one file of the window's tree setuid, so rpm -V and pacman -Qkk report a mode mismatch on it; the AUR package carries the mode itself. Where it has neither, the browser process would abort at its zygote, so the shell asks first, the way Chromium does (a fork that enters a user namespace, denies setgroups, maps its own uid and gid, drops its capabilities and enters another; the helper checked for root and 4755; never as root; the CHROME_DEVEL_SANDBOX helper Chromium also accepts for a binary the user owns is not asked) and opens with --no-sandbox. Every step of the fork counts: Ubuntu's AppArmor policy, over every unconfined binary, lets the first unshare succeed and denies the maps, so a probe that stops at CLONE_NEWUSER promises a sandbox Chromium then refuses. Only a package can own that helper root: a tarball unpacked as the user cannot, and an AppImage's squashfs mounts nosuid. On such a desktop either install therefore runs unsandboxed and carries Chrome's "stability and security will suffer" bar, which is the truth about it. Windows and macOS run with no_sandbox and the same bar everywhere: the Windows sandbox needs cef_sandbox linked into the executable and the macOS one a helper app initialising it, and neither is wired.

  • Under Wayland the window remembers its size, not its position (shell/src/geometry.rs): a Wayland client can neither read nor set where its window sits, so the compositor places it at every launch. X11, Windows and macOS reopen it where it was. The geometry lives in lich-window in the Chromium profile directory, so task dev keeps its own, and a window killed outright (SIGKILL, a crash) keeps the one it had before.

  • Closing the window does not quit lich (main.go serve, internal/restart.Window): with a session running, the window asks whether lich keeps running in the background or quits, and the answer can be kept (Settings › Appearance › When the window closes); with none running, closing it quits. Kept running, the backend holds every session's process with nothing on screen, and with them the pinned port (47821), the keep-awake assertion while a session works, the providers' spend and the desktop notifications. The tray icon (below) is all that says it runs. Launching lich again opens a window on it; lich quit (or SIGINT / SIGTERM) ends it. task dev behaves the same: kept running, the dev backend stays on 47822 until Ctrl+C or LICH_DEV=1 go run . quit.

  • A window closed before it was ever touched does not ask (frontend/src/lib/close-request.ts): the page holds the close by cancelling beforeunload, and Chromium ignores that on a page the user never clicked or typed into, so a window closed straight after it opened keeps lich running without asking (or quits, with no session running). Only lich's own window asks: the macOS tab fallback never holds its close. The hold rests on App::on_before_unload, a hook on the lich-close-prompt branch of the kurogane fork that is not upstream yet; lich-shell answers a reload and a close lich itself asked for (quitting, restarting, through the window's stdin) with Leave, and everything else with Stay.

  • The tray icon is Linux and Windows only, and late (internal/tray): macOS's menu-bar item needs cgo and lich is built without it, so a windowless lich on a Mac shows nothing. On Linux it is a StatusNotifierItem: GNOME draws none without the AppIndicator extension, and a desktop with no tray host shows nothing either. Its words are the page's interface language, so it comes up once the page has loaded and handed them over; a lich whose window never opened has no tray. task dev puts up a second icon beside the installed lich's, with the same name.

  • Restart and update end every running session, now with nobody watching (internal/restart.Coordinator): /restart and an update stop this process and every PTY with it, as they always did. Before, the window closing explained that loss; a windowless lich restarted by install.sh from outside loses its sessions with no window to show it. The successor opens a window only when its predecessor had one up (Coordinator.SetWindowOpen), so a native window on that backend reconnects to it instead of losing it to a web window.

  • A window that dies after the first one is up only gets a dialog (chromium.EndingOf): only the launch's first window failing within the 30-second startup grace exits lich. A crash later, or a reopened window that will not start, leaves the backend serving and shows "lich could not open its window"; the next launch tries again.

  • Logout and session end, per OS (unverified on every OS; nothing here is measured): on Linux a lich launched from the desktop lives in the graphical session's scope, so logind or the user manager stopping it sends SIGTERM and lich quits cleanly; with KillUserProcesses=no and lingering it can outlive the logout, windowless with no display, and the next login's launch opens a window on it. A lich started from a terminal dies on that terminal's SIGHUP without its clean exit, so the next launch reports an unclean exit. On Windows a GUI-subsystem process with no window receives no console close event: logoff ends it without the clean exit, which the next launch reports as unclean. On macOS logout quits user processes; whether the backend gets SIGTERM first is not known. Whether the window holding its close for the question (above) can stall a logout is not measured on any OS; Chromium is expected to skip beforeunload when the session ends.

  • Reopening from the Dock or Finder is unverified on macOS, and Windows reopen is unmeasured: a second launch reaches the running lich over system.ShowWindow on every OS (singleton.Show), which the macOS e2e exercises by running the binary in Lich.app directly. LaunchServices may instead activate the running Lich.app without starting a second process, and lich answers no reopen Apple Event, so a Dock click on a windowless lich may do nothing. On Windows the Start-menu launch is a new process and takes the same path, and quitting closes the window with WM_CLOSE as restart did, but neither has run on Windows hardware with the window closed first.

  • The gate run inside lich kills lich's window (measured 2026-10-10): go vet, go test ./..., the cross-compile loop and vitest + vite build started together from a session ran the machine out of memory, and every Chromium on it, the lich window and an unrelated browser alike, died with SIGBUS; the window took the session that ran the gate down with it. /tmp is tmpfs on many distributions, so the Go build's temporaries count as RAM too. Nothing in lich bounds what a session spends. The way around it is in CLAUDE.md › Local Gate: one step at a time, in a systemd-run --user --scope with MemoryMax, with GOTMPDIR/TMPDIR on a short path on disk (/var/tmp/lich-gate; under ~/.cache the sandbox suite's Unix sockets outgrew the 108-byte limit); there a step over the limit is the one killed. macOS and Windows have no equivalent here.

  • A rig needs its own HOME, not just its own config dir (internal/agentplugin.RepairRegistrations): every launch that is not task dev (LICH_DEV) or a go run binary repoints the provider MCP registrations it finds to its own executable, and those live under HOME, not under the config dir: ~/.cursor/mcp.json, ~/.omp/agent/mcp.json, ~/.gemini/config/mcp_config.json and Kiro's agent. A built binary launched with only XDG_CONFIG_HOME (or APPDATA) moved rewrites the developer's real agents to point at a scratch binary that is deleted later. Give a rig its own HOME and XDG_* both, and check those files afterwards. Two side effects of that HOME: a node reached through a mise shim stops trusting the developer's mise config and exits, so call the real binary (mise which node); and a rig given its own D-Bus (dbus-run-session, so its tray stays off the developer's bar) activates portals and a notification daemon that outlive it, which are found by their DBUS_SESSION_BUS_ADDRESS and stopped by PID.

  • Opened as a tab lich cannot close it or tell it closed (main.go, openTab, macOS only): lich serves a plain tab it did not spawn, so lich quit leaves the tab open on a dead page, and a second launch opens another tab rather than raising the first.

  • The tab fallback cannot tell "opened" from "nothing happened" (internal/system.OpenURL): open is started and never waited on — waiting would block for the life of the browser they hand off to. A desktop with a URL handler installed but no browser behind it therefore looks like success: lich stays up with a notification and nothing on screen. Only a machine missing the opener itself reaches the dialog that carries the URL.

  • Keep-awake follows the session-state report, and only that (internal/awake, turnLog.onOpen): the machine is held out of idle sleep while a hook says a turn is open, so what a provider reports decides whether the machine stays up at all (docs/providers/). Linux holds it through systemd-inhibit --what=idle: a distro without systemd logs one warning per burst of work and sleeps, and a desktop that ignores logind idle inhibitors sleeps silently. The hold was measured on Linux only; on Windows and macOS CI proves the request is registered (powercfg /requests, pmset -g assertions), not that the machine stays up.

  • A lich upgraded under a running lich is refused by a call the backend does not have (internal/rpc/rpc.go): a package manager replaces the binary without restarting the backend it is serving, so the new CLI, and the lich mcp of every session opened after the upgrade, speaks to an older backend until lich is restarted. Every call they make takes one options object (spawn.OpenOptions, relay.SendOptions, store.CostTotalsOptions and their siblings), so an option the backend does not know yet is dropped and one an older client does not send is its zero value; the version probe system.Diagnostics takes nothing at all, which has nothing to misalign. What is left: a method newer than the backend is refused as unknown, and the release that moves a call to the object (a parameterless one included, the day it takes one) is refused by a lich from before it, once, with "want 1 arguments, got N" (or the other way round). The RPC itself still matches arguments by position and count, so a call lich makes must not go back to positional arguments: TestEveryCallTheCLIPostsTakesOneOptionsObject (internal/cli/rpcshape_test.go) reads every method name the CLI's source posts and fails on one whose parameters are anything but one struct or none, and internal/cli/wire_test.go posts each one as a client of another release. The window's own calls stay positional, since the page ships in the backend's binary and cannot be of another release.

  • A reasoning effort reaches five providers, and Cursor only through its model name (internal/terminal/command.go, effortFlags): lich open --effort and open_session's effort are refused for opencode, whose --variant lives on run only (1.18.31), and for Crush, which has no such option (0.88.0), because the TUI lich spawns would drop the level and hand back a session on its default. Cursor is refused too: the effort is part of its model id (claude-opus-4-8-high), and the model[effort=high] override its own --help advertises spawns into "Cannot use this model" (2026.08.11), so a caller who wants it passes that id as --model. The level is never validated, so a misspelled one is each provider's to handle: Claude Code warns and runs at its default, and Codex, Antigravity, oh-my-pi and Kiro start without a word (measured on the versions above).

  • Ultracode reaches Claude Code only (internal/terminal/command.go, ultracodeArgs): it is a Claude Code mode, standing multi-agent workflow orchestration, and no other provider has one, so lich open --ultracode and open_session's ultracode are refused for every other kind and Settings offers the switch on Claude Code alone. lich passes it as --settings '{"ultracode":true}' rather than --effort ultracode, which pins the effort to xhigh. Claude Code drops it on --resume (measured on 2.1.288), so unlike the model and effort it is passed on every spawn, and a fork inherits it from the session whose conversation it branches. The cost is that an ultracode turned off inside the session comes back on the next restart, and nothing turns it off for one session while the provider-wide switch is on.

  • The two-cards-one-conversation warning reaches only the providers that report an in-app switch (internal/terminal/terminal.go reportDuplicate, native/cmd/lich-native/duplicate.go): lich learns which conversation a card runs from its session-start report, so a resume typed inside the agent's own app is warned about only once that report names the new id. Measured on 2026-10-17: Claude Code (/resume), Kiro CLI 2.21.0 (/chat, agentSpawn fires per switch) and oh-my-pi 18.8.9 (/resume, whose session_switch the plugin reports from 0.20.5 on) report at the switch; Codex 0.162.1 reports it with its next prompt and Crush v0.98.1 with its next tool call, so a turn may already have run in both cards; Antigravity 1.4.0 carries the id on every PreInvocation, read in the binary and not run. Cursor CLI skips sessionStart on any resume (2026.10.16); opencode 1.18.35 raises nothing on /sessions. Those two report nothing at all, so their row keeps the old id and a restart resumes the conversation the card opened with. Reopening from lich itself is not this gap: every door through store.reopen focuses the card that holds the conversation.

  • A model or effort is a birth value, and two providers forget it on resume (internal/terminal/command.go, modelArgs, effortArgs): lich passes neither when it resumes or forks a conversation, because a /model or /effort typed inside the session is the user's decision and re-sending the birth value would undo it. What a resume then runs on is the provider's call, measured in a real PTY after an in-session change. The model comes back from the conversation on Claude Code 2.1.274, Codex 0.154.0, oh-my-pi 18.0.10, Kiro 2.21.0, Cursor 2026.09.15 and opencode 1.18.31, and the effort on Codex and oh-my-pi. Claude Code's effort and all of Antigravity 1.1.25 do not: both resume on the saved default, which their own /effort and /model write (settings.json), so a change made inside the session survives while a level or model lich set at birth falls back to that default. Kiro's effort is unmeasured: no model on the measuring account accepts one. Cursor also writes whatever model a spawn or resume runs on into cli-config.json as the default for every later cursor-agent, so lich open --kind cursor --model changes the user's Cursor default too.

  • The native window and the web window take turns (lich native, docs/native-window.md): /events and /ws hold one client, and a new one replaces the old with no close frame, so the two windows cannot share a backend. Whatever opens a window closes the other first: lich native, and the window chosen in Settings › Window that a launch, the tray, lich focus and a second launch open (system.ShowWindow). A native window someone else started, from a terminal with LICH_NATIVE say, is closed the same way, through the window-close event; one too old to know the event says another window took over. A focus that has to start the native window again loses the card it named: the window comes up on the session it last showed. On macOS the native window is built and run only on GitHub's runners (native.yml draws one frame against a real backend, .github/scripts/mac-e2e.sh opens it from the bundle): its keys, Retina scaling, IME and menu bar have never been looked at on a Mac. Option types what it composes there (Option+c is ç), as Ghostty's and Terminal.app's default does, so an agent's Alt/Meta shortcut on a letter (Option+P) never reaches it; Option with Backspace or an arrow still does (native/gioterm/keys_option_darwin.go). A restart keeps it: the native window tells a backend that went away from one that kept running, and moves onto the successor its runtime file names. A quit (lich quit, the tray, SIGINT or SIGTERM) closes it with the same window-close event; a backend killed outright or crashed sends nothing, so the window shows "Reconnecting to lich…" for the 15 seconds it waits and then lich as lost. /events replays nothing: the native window reads each session's status, usage and working directory from terminal.TurnStates on connect, the web window does not, so after a reload it shows a terminal's moved working directory only from the next cd. system.CloseWindow never quits lich, even with no session running, where a window the user closes does (quitIfIdle). The native window's text comes from its own catalogs (native/i18n/locales), a copy of the web window's, so a copy fix made in one window until the cutover lands in both (docs/native-window.md).

  • Only a native window the backend starts tells lich how it ended (restart.StartNative, restart.WatchNative): the backend waits on the native window it starts again (the tray's Show lich after the window closed) and logs its exit code or the signal that killed it, with its last VmRSS/VmHWM (Linux only, sampled every 10s, so up to 10s old). The window lich native starts from a terminal is not the backend's child: the command exits right after, nobody waits on that window, and its death leaves no line in lich.log. Every window started either way prints into lich-native.log beside lich.log, where an event loop stuck on one event for 3s or more is reported with every goroutine's stack (native/cmd/lich-native/stall.go); a stall inside w.Event() itself, in Gio's own dispatch, looks like an idle window and is not reported.

  • The native window finds fonts through fontconfig on Linux and by scanning the font folders on Windows and macOS (native/fonts/locate_fontconfig.go, native/fonts/locate_fontscan.go): neither ships fontconfig, and a window that shelled out to fc-match there closed at launch. The scan (go-text's fontscan) marks a family monospace only when every glyph shares one advance, so the terminal and editor font pickers on Windows and macOS leave out the dual-width Nerd Font families (the ones not named "Mono", whose icons take two cells) that fontconfig's spacing=dual lists on Linux; the "Mono" variant of each is offered. Its family names come from parsing one file per family, a few hundred on a stock Windows, the first time a font picker opens.

  • The Chinese font the native window downloads reaches its interface, not its terminal on Linux (native/fonts/download.go, native/glyphs/raster.go): the terminal finds a glyph its family lacks through fontconfig (fonts.Covering), which never sees <user cache dir>/lich/fonts, so with no Chinese font installed a branch name or agent output in Chinese still draws as tofu in the terminal while the cards and Settings around it read fine. Installing a Chinese font system-wide covers both. Windows and macOS find the terminal's fallback by scanning the system font folders, which leave lich's cache out the same way.

  • Profiles are Claude Code's, Codex's, oh-my-pi's and opencode's everywhere, Cursor CLI's on Linux and Windows, and Kiro CLI's on Linux only (internal/claudeprofile, internal/codexprofile, internal/ompprofile, internal/xdgprofile, on one store, internal/profilestore): one account per profile. The first three are each its own CLAUDE_CONFIG_DIR, CODEX_HOME or OMP_PROFILE with a real claude auth login, codex login or omp login. The other three have no variable of their own that moves their login, which is what makes them link homes (the next bullet). What they were built on, measured on the installed CLIs (2026-10-12, throwaway homes, the bundle read for what a run could not reach):

    • opencode (1.18.35) reads its data from $XDG_DATA_HOME/opencode and nothing else moves it: OPENCODE_CONFIG_DIR and OPENCODE_TEST_HOME leave it where it was. opencode.db holds the logins (its credential and account tables, beside auth.json) together with every conversation, so the folder cannot be split and a conversation cannot move between profiles without moving rows between two databases, which lich does not do. Its config, ~/.config/opencode (plugins, agents, MCP servers, lich's plugin), is not under the data home and is every profile's.
    • Cursor CLI (2026.10.01) keeps its state in three places that three different variables move. The config dir (cli-config.json, which names the account, statsig-cache.json, chats/<md5 of the cwd>/<id>/store.db) is $CURSOR_CONFIG_DIR, else $XDG_CONFIG_HOME/cursor, else ~/.cursor. The login, auth.json, is not in it: on Linux it is $XDG_CONFIG_HOME/cursor/auth.json else ~/.config/cursor/auth.json (macOS ~/.cursor/auth.json, Windows %APPDATA%\Cursor\auth.json), so CURSOR_CONFIG_DIR splits the chats from the account without splitting the account (issue #765). projects/ (transcripts) answers to CURSOR_DATA_DIR else ~/.cursor, and mcp.json, hooks.json, skills/, skills-cursor/, agent-cli-state.json, policy.json and sandbox-policies/ hang off $HOME/.cursor with no variable, so they are every profile's. A logged-out status --format json is {"status":"unauthenticated","isAuthenticated":false,…} with exit 0; the logged-in shape (userInfo.email) was read in the bundle, not run. A resume finds a chat only under the active config dir. The install directory (~/.local/share/cursor-agent/versions/<v>/) and the logs under $TMPDIR never move.
    • Kiro CLI (2.21.0) keeps its login in $XDG_DATA_HOME/kiro-cli/data.sqlite3 (auth_kv, beside its history and state tables; issue #766), and the TUI it runs (bun, node, tui.js, a few hundred MB) in the same folder. KIRO_HOME moves ~/.kiro (settings, agents, steering, skills and the conversations, sessions/cli/<uuid>.json with the .jsonl beside it), which no profile moves, so a conversation is every profile's. A logged-out whoami --format json is {"account":null} with exit 1; the logged-in shape was not measured, so a profile reads as logged in whenever account is not null and its address is account.email when there is one. Not measured: the logged-in TUI, an enterprise (IdC) login that may read ~/.aws/sso/cache, which no profile moves, and a TUI run from a linked bundle.
    • Crush is unmeasured. Antigravity cannot: its ~/.gemini is fixed by the CLI.

    Off Linux, read in the shipped artifacts (2026-10-17) and never run on a Mac or a Windows machine:

    • opencode (1.18.35 bundle) resolves its data as $XDG_DATA_HOME, else <home>/.local/share, with opencode under it, in JavaScript with no branch per platform, and keeps no secret in the Keychain or the Credential Manager. Its profiles are the Linux ones on macOS and Windows too.
    • Cursor CLI (2026.10.01 bundle, cli-credentials) keeps its login on macOS in the Keychain by default: services cursor-access-token, cursor-refresh-token and cursor-api-key under the account cursor-user, names no variable changes, in the one login keychain every profile's session would read. So it has no profiles on macOS. AGENT_CLI_CREDENTIAL_STORE=file moves the login to $HOME/.cursor/auth.json, which only HOME moves; lich does not set either. On Windows the login is %APPDATA%\Cursor\auth.json (APPDATA, else <home>\AppData\Roaming) and the config dir is ~/.cursor, so a profile is a link home of %APPDATA% whose Cursor folder is its own, with CURSOR_CONFIG_DIR pinned to that same folder so the chats and cli-config.json (which names the account) go with the login (internal/xdgprofile/cursor_windows.go).
    • Kiro CLI (2.32.0 macOS .dmg and Windows .msi): on macOS the kiro-cli launcher keeps its tokens in the Keychain through /usr/bin/security (kirocli:odic:token, kirocli:odic:device-registration, kirocli:external-idp:token, fixed names), so it has no profiles on macOS. On Windows the login is in data.sqlite3 under the local AppData folder, found through SHGetKnownFolderPath, which no variable moves (the binary reads neither LOCALAPPDATA nor APPDATA), so it has no profiles on Windows either. KIRO_DATA_DIR and KIRO_HOME leave data.sqlite3 where it was (run on the 2.32.0 Linux build in a throwaway home); KIRO_TEST_DB_PATH moves it but is a test hook, which lich does not lean on. Kiro's docs announce sign-ins under KIRO_HOME "in V3", which would give it profiles on every platform with a variable of its own.

    internal/xdgprofile lists the providers each platform has (supported_linux.go, supported_windows.go, supported_other.go), and the native window shows profile rows for the same ones (profiles_xdg_*.go). A project's profile setting, the per-session profile and Continue in therefore exist for Claude Code, Codex, oh-my-pi and opencode sessions everywhere, Cursor CLI sessions on Linux and Windows and Kiro CLI sessions on Linux. The profile slug of a session row belongs to that row's provider: a Codex profile named "work" and a Claude Code one named "work" are two profiles.

  • An opencode, Cursor CLI or Kiro CLI profile is a base directory of links (internal/xdgprofile, internal/profilestore/linkhome.go): the profile is <lich config dir>/profiles/<provider>/<slug>/, and its sessions run with XDG_DATA_HOME (opencode, Kiro CLI), XDG_CONFIG_HOME (Cursor CLI) or, on Windows, APPDATA (Cursor CLI, with CURSOR_CONFIG_DIR at the profile's Cursor folder) pointing at it, because moving that variable to an empty directory breaks gh, git, mise and lich's own worktrees in the same session. It mirrors your own base directory entry by entry, each a link, except what is the profile's: opencode/ whole, cursor/ whole, and in kiro-cli/ only data.sqlite3 (with its -wal, -shm and -journal) and .refresh.lock, the rest of that folder linked so the TUI bundle is not downloaded again. A tool writes through a link into your own directory, as it would without a profile; lich itself never writes there. Before every spawn into a profile the mirror is brought up to date: an entry you gained is linked, and a link to an entry you lost is removed so a tool can make it again. An entry a tool creates inside the profile is the profile's own from then on, whether you have one or not: a tool that first writes its config during a profile's session keeps it in that profile, your own directory never sees it, and an entry you make later with the same name is not linked there. So a Kiro update that replaces tui.js by a rename inside a profile leaves that profile its own copy, which no later update in yours reaches. XDG_STATE_HOME and XDG_CACHE_HOME are not moved and are every profile's (opencode's locks, Cursor's compile cache). The entry of your base directory that holds the profiles themselves is never linked: for Cursor CLI the mirror is of ~/.config, so ~/.config/lich (lich's config dir, profiles included) is absent from its profiles, and a tool in a Cursor profile session that reads $XDG_CONFIG_HOME/lich finds nothing; on Windows the same holds for %APPDATA%\lich. On Windows a folder is linked by a junction and a file by a symlink, which Windows refuses without Developer Mode: such a file is then left out of the profile rather than copied, and linked by the first spawn after Developer Mode is on. A Windows Cursor profile owns %APPDATA%\Cursor whole, which is also where the Cursor editor keeps its user data, so an editor started from inside a Cursor profile session sees none of yours. Dropped from a profile's spawn, because each would point the CLI at another account or another profile's state: OPENCODE_DB, OPENCODE_AUTH_CONTENT, OPENCODE_CONSOLE_TOKEN and OPENCODE_API_KEY; CURSOR_CONFIG_DIR, CURSOR_API_KEY and CURSOR_AUTH_TOKEN; KIRO_API_KEY. The provider API-key variables (ANTHROPIC_API_KEY and the rest) are not dropped, as for oh-my-pi. A sandboxed session gets the profile bound writable, and its links reach only what the sandbox already exposes at their targets.

  • opencode, Cursor CLI and Kiro CLI log in from a terminal card, and Continue in differs for each (xdgprofile.Service, xdgprofile.MoveConversation): every login is a picker or a browser flow, so the dialog hands opencode auth login, cursor-agent login or kiro-cli login to a shell card and ends, and nothing in lich sees it finish; the list is read again when Settings opens and when the window regains focus after a minute. "Logged in" is a non-zero count at the end of opencode auth list (which names no account), isAuthenticated from cursor-agent status --format json, and a non-null account from kiro-cli whoami --format json. There is no plan per profile in Settings and no usage on the Continue in rows; the footer of a session reads the plan of the login its own process sees (Cursor CLI, opencode Go). Continue in moves a Cursor CLI chat's chats/<md5 of the cwd>/<id>/ directory between config dirs, both ways (Personal's is ~/.cursor when XDG_CONFIG_HOME is unset, a profile's is its own cursor/); for Kiro CLI nothing moves and only the session's profile changes, so a Kiro conversation resumes in whichever profile its session is set to; for opencode it is refused before the session is stopped, and the card menu does not offer it. A conversation adopted from History runs in the profile whose directory holds it (opencode, Cursor CLI) or in the project's (Kiro CLI).

  • An oh-my-pi profile is omp's own, linked against Personal's agent directory, and logged in from a terminal card (internal/ompprofile): OMP_PROFILE=<slug> puts everything omp keeps for that profile under ~/.omp/profiles/<slug>/ (agent/ with agent.db, sessions/, blobs/ and its settings; beside it logs/, run/ and plugins/), which is omp's location and not lich's: lich's own part is a name in a sidecar under its config dir. A profile made with omp --profile x outside lich is not listed, but its slug is taken, and default is reserved because omp reads OMP_PROFILE=default as the default profile. A profile links Personal's config.yml, models.yml, mcp.json, ssh.json, AGENTS.md, SYSTEM.md, RULES.md, PERSONALITY.md and the agents, commands, extensions, hooks, modules, prompts, rules, skills, themes and tools directories (measured on 18.8.9: a linked skills/ resolves in the profile and an unlinked profile sees none, and omp writes config.yml through the link and leaves it in place). An entry the profile already holds, link or directory, is never replaced, as for the others, so a profile with an extensions/ of its own does not get lich's extension through it and its sessions report nothing. Not linked: agent.db (the logins), sessions/, blobs/, omp's databases (models.db is about 7 MB and is built again by each profile) and the plugins omp plugin install puts beside agent/, so an omp plugin is per profile, as Codex's are. ~/.omp/install-id, the global daemons and the collab registry are shared by every profile whatever the variable says (read in the source). Personal is read as omp reads it with lich's environment: the profile OMP_PROFILE names there, else PI_CODING_AGENT_DIR, else ~/.omp/agent; a profile ignores PI_CODING_AGENT_DIR. PI_CONFIG_DIR and the XDG_*_HOME/omp layout omp config init-xdg makes are not read, here or by the provider's plugin install and transcript reader, which share the rule. On Windows the file links are copies without Developer Mode, as for Claude Code.

  • oh-my-pi has no login lich can run, and its account is whatever its CLI lists (ompprofile.LoginCommand, ompprofile.Service): omp login is an interactive picker of providers (omp login <provider> takes an id, and each provider's own OAuth flow follows), so the dialog hands the command to a shell card and ends: nothing in lich sees that login finish, so nothing is emitted for it, and the profile list is read again when Settings opens and when the window regains focus after a minute. A profile is "logged in" when omp usage accounts --json lists an OAuth account in it (measured on 18.8.9: local, exit 0 with an empty list when there is none, no network); a login that is only an API key, stored or in the environment, reads as logged out. One profile holds several accounts, one per provider or more, so the row names every address (the email: part of each identity key) and each provider rather than one plan: there is no plan gauge for oh-my-pi, so the Continue in rows carry no usage. OMP_AUTH_BROKER_URL and OMP_AUTH_BROKER_TOKEN are dropped from a profile's spawn, because a broker named there is read before the profile's own agent.db; a broker in the shared config.yml (auth.broker.url) or in ~/.omp/auth-broker.token is every profile's too, and so is a key a shared models.yml sets for a provider, which omp reads before any OAuth login. The provider API-key variables in lich's environment (ANTHROPIC_API_KEY, OPENAI_API_KEY and the dozens more omp lists) are not dropped, because omp's own order puts a stored OAuth login first and the environment key only after it (KeyCascade.peek, read in the source): a profile that has no login for a provider spends the key the environment has, as Personal does.

  • Continue in moves an oh-my-pi session file and its artifacts, and copies its blobs (ompprofile.MoveConversation): resume looks only in the active profile's own sessions/, so a conversation outside it is Session "<id>" not found (measured), and moving the file is what makes it resumable in the other profile, both ways. What moves is sessions/<cwd>/<timestamp>_<id>.jsonl and the directory beside it named after the file (the sub-agents' sessions and tool output), and every blob:sha256: image the files refer to is copied into the target's blobs/, copied and not moved because other sessions of the source may refer to it. A blob the source no longer has is skipped. The prompt history (history.db) stays in the profile it left. Like the others it refuses a turn in progress, which is read off the session-state hooks, so an omp without the lich extension reports no turn and is moved mid-turn.

  • A profile shares Personal's own content through links, and a link shares writes too (internal/claudeprofile/profile.go, internal/profilestore/layout.go): settings.json, CLAUDE.md, agents/, commands/, skills/, hooks/ and the marketplace clones (plugins/marketplaces) point at Personal's, and Claude Code writes through a link to its target (measured on 2.1.296). So claude plugin install run in a profile writes enabledPlugins into the one shared settings.json, and a plugin a work account installed reads as enabled in Personal, where it is not installed; /config changes in any profile change them all. The plugin cache is the next bullet. The plugin set itself (installed_plugins.json, known_marketplaces.json) is per profile, because an organization account may forbid plugins Personal runs. An entry Personal gains after a profile was made is linked at that profile's next spawn; an entry the profile already holds, link or file, is never replaced: Claude Code may have written a settings.json of its own there first, and that one is the profile's from then on. On Windows the directories are junctions, but a file link needs Developer Mode: without it settings.json and CLAUDE.md are copies, named on the profile (copied), and they drift from Personal's on the first edit to either. A hard link was measured and drifts just the same, Claude Code replacing the file on write. The global .claude.json is a copy everywhere, minus the account and its caches, since Claude Code replaces it through a rename that would turn a link into a file anyway.

  • A provider's "trust this folder?" answer is carried only where lich can find the person's own answer, and a confined session is told to skip the question (internal/claudeprofile/trust.go, internal/terminal/cursor_trust.go, antigravity_trust.go, crush_init.go, confinedEnv and confinedTrustArgs in internal/terminal/sandbox.go). Claude Code: a profile's copied .claude.json freezes the trust answers at its birth, so before each spawn into a profile lich writes hasTrustDialogAccepted for the checkout's top when Personal trusts it or the main checkout of the worktree it is; Personal itself needs nothing, since Claude Code finds the answer for a worktree in its main checkout and for a folder in its parents (measured on 2.1.302). A folder outside any git checkout is not carried into a profile: lich asks git, not the parents. The write is a read, an edit and a rename beside sessions that rewrite the same file; Claude Code re-reads it before each write of its own (a key written mid-session survived its later writes), so the window left is what either side writes between the other's read and rename. Codex keys trust by the main checkout's top, so its worktrees already count, and its profiles merge Personal's trust (codexprofile/config.go). Cursor CLI finds a marker (~/.cursor/projects/<slug>/.workspace-trusted) for the folder or a parent below the home, and a worktree sits beside its repository rather than under it, so before each spawn in a worktree lich writes the marker when the main checkout is trusted; its profiles need nothing, the marker following the home (measured on 2026.10.16, Windows not). It never trusts a folder nobody trusted. A confined session skips the question: Claude Code with CLAUDE_CODE_SANDBOXED=1, Codex with -c projects={"<main checkout>"={trust_level="trusted"}} (the dotted spelling still asks; measured on 0.144.5 and 0.162.1) and Cursor CLI with --trust, which also writes its marker, so that folder stays trusted outside the sandbox too. The question guards against a folder's own settings running on the machine, which is what the sandbox confines. Whether Codex's trust and Cursor CLI's --trust loosen anything beyond the question was not measured. A confined oh-my-pi is spawned with OMP_SKIP_SETUP=1: a profile's config.yml links into Personal, which the sandbox does not mount, and without it the session opened on the setup wizard. Antigravity trusts only the exact folders in ~/.gemini/antigravity-cli/settings.json trustedWorkspaces, so worktrees and subfolders of a trusted repository asked again (measured on 1.3.5): before each spawn lich adds the folder when its checkout's top or main checkout is listed, and every confined folder whatever the list says, so a folder once opened confined stays trusted outside the sandbox. Crush asks once per checkout whether to initialize the project and keeps the answer as an empty .crush/init at the checkout's top (measured on v0.98.1); lich writes it, with Crush's own * .gitignore beside it, before every Crush spawn, so the offer to write AGENTS.md never comes up in a lich session. opencode, oh-my-pi and Kiro CLI ask nothing.

  • A Claude Code profile's plugin cache is its own, and each version in it links into one store lich never empties (internal/claudeprofile/plugincache.go): before every spawn, and in the startup pass of the lich plugin, each version a profile's installed_plugins.json names under its plugins/cache is moved into plugin_cache/ beside the profiles (or gives way to the copy already there) and replaced by a link to it. The link is what keeps a version alive: Claude Code's orphan sweep, run at every session start, marks each cached version its own record does not name (.orphaned_at) and deletes it once the mark is 14 days old, but it passes over a version that is a link, marking and deleting nothing through it, and plugin update, uninstall and a reinstall over a link leave the store's copy alone (all measured on 2.1.299, a plugin running from a linked version included). When the cache was one link to Personal's, a plugin only one profile had, Personal included, was deleted after 14 days without a session of that profile. A version Claude Code downloads stays a plain directory of the profile, which its own record protects, until the next spawn links it. Versions no profile names any more stay in the store, since a session started before an update may still run one; deleting plugin_cache/ with every Claude Code closed only costs the downloads. Personal's cache is Personal's alone and is left as it is, so a version both use is on disk twice. A profile made when the whole cache was a link to Personal's is moved at its next spawn or lich start: each version it names is copied from Personal's into the store, and one Personal's sweep already deleted stays missing. .in_use markers of every profile running a version land in the store's copy, which a confined session reaches because the store is bound read-write beside the profile (internal/terminal/sandbox.go). On Windows each version is a junction, which Claude Code's sweep reads as a link too (read in the source, not run on Windows), and a version Windows will not move while a file in it is open stays the profile's own until a later spawn. CLAUDE_CODE_PLUGIN_CACHE_DIR moves Claude Code's whole plugins/ directory, installed set, marketplaces and cache, not only the cache (measured on 2.1.300), so a profile's spawn, login and plugin install drop it and a profile's plugins stay inside its own directory; Personal keeps it, and lich reads Personal's installed set where it points (claudeprofile.PluginsDir). A new profile seeds its marketplaces from, and links its clones to, Personal's plugins directory where the variable points. A profile made before the variable was set keeps the plugins/marketplaces link it was made with, since an entry a profile holds is never replaced, and clones each marketplace again into a directory Personal no longer reads; removing that link has the next spawn link it where the variable points.

  • A profile's directory never moves, and lich's config dir decides where it is: on macOS Claude Code keeps a login in the Keychain under a service named after a hash of the config directory string (internal/quota/claude_storage.go), so a renamed profile keeps its slug and directory and changes only its name, and a moved one would lose its login. Deleting a profile deletes that Keychain item with security delete-generic-password (quota.ForgetClaudeLogin), which has not run on a Mac yet. The directory is built from os.UserConfigDir() on every call, so an XDG_CONFIG_HOME (or APPDATA) that changes between launches points lich at another, empty set of profiles. A task dev rig has its own set (profiles-dev), so its profiles need their own login.

  • Continue in moves what Claude Code keys by the conversation id, and nothing else (claudeprofile.MoveConversation, terminal.ContinueIn): the transcript, its subagent directory, any file a hook filed beside it under the same name, and file-history/, session-env/ and tasks/ under that id, the set a throwaway conversation left on 2.1.296, which resumed with its history and checkpoints both ways. The prompt history (history.jsonl) and the project's lastSessionId stay in the profile it left, so claude --continue there points at a conversation it no longer holds and the arrow-up history does not follow. It refuses while a turn is open, which is read off the session-state hooks: a Claude Code without the lich plugin reports no turn and is moved mid-turn. A move across filesystems copies, Windows volumes included. A card that has not had its first prompt has no transcript yet, so it is not moved but started afresh in the target profile. A session born in a profile deleted since is refused at spawn with "claude profile no longer exists", including its resume: the conversation was deleted with the profile.

  • The version Settings shows is the one lich's own environment gets (internal/providers/version.go): --version runs in lich's working directory with lich's environment, not in a session's checkout. A version-manager shim that picks its tool per directory (mise, asdf) can answer one version there and start another in a project that pins its own, and the "Also on PATH" list compares those same per-lich answers. All eight providers print a version (measured on Claude Code 2.1.299, Codex 0.162.1, Antigravity 1.3.5, opencode 1.18.35, oh-my-pi 18.8.9, Crush 0.98.1, Cursor CLI 2026.10.01, Kiro CLI 2.21.0); a CLI whose --version hangs past 10 s or prints no version shows its path alone.

  • The binary setting and lich's own plugin checks still answer for Personal: a profile's session runs the configured Claude Code binary, so a wrapper that exports its own CLAUDE_CONFIG_DIR or a token still wins over the profile, and the same binary answers the profile's auth status and installs its plugins. The start gate reads the profile's own plugin state, but the nested-subagent-cards check and the MCP servers a card lists read Personal's, so a profile on an older lich plugin than Personal's can be offered cards its plugin cannot open: one whose follow-up install failed, or one still waiting for it.

  • A profile's lich plugin follows Personal's, after it and in the background (internal/agentplugin/profiles.go): an install or update of the lich plugin for Claude Code or Codex, from Settings or anywhere else, runs in Personal and returns; then the same pinned install runs as each other profile of that provider whose version differs, one at a time, logged in or not (claude plugin install and codex plugin add both install into a logged-out home, measured on 2.1.296 and 0.162.1). A backend start makes the same pass, which is what reaches a profile logged in before profiles got the plugin at login; it is the start and not the next spawn because a pass clones the marketplace, and a spawn would wait on it. A start asks for the release list only when a provider has a profile besides Personal, and a Codex profile's version costs one codex plugin list each. While Personal itself is behind the newest release this lich speaks, a profile that has the plugin keeps its version and one that has none gets the newest, so the two can differ until Personal is updated. A profile whose install failed is named in an error toast of the native window, in the CLI's own words, and is tried again at the next start or update; the web window, which has no profiles, shows nothing. A Claude Code profile's plugin marketplace remove deletes the marketplace clone it links to Personal's (the plugin cache stays, measured on 2.1.296), so a Personal session starting during a pass can find the clone missing for the seconds the pass takes to clone it back. The pass lays each profile out as a spawn would before asking its version (EnsureShared): a Codex profile made when config.toml was shared still links Personal's, read as holding Personal's plugin, and its next spawn rebuilt it with no plugin and no marketplace. The same layout moves a Claude Code profile's plugin cache off Personal's (the bullet on it above) at a start too. oh-my-pi needs no pass: lich's extension is in the extensions/ every profile links to Personal's.

  • A theme's pair and lich theme switch reach the native window only: the web window (in maintenance, docs/native-window.md) ignores a theme's pair and has no Auto light/dark switch, and it reads the selection once at load, so a lich theme switch while it is open shows at its next reload. The native window hears the backend's theme-selection event. A theme chosen in the web window is written straight to the setting and emits nothing, which no native window can be open to miss: the two windows take turns. Following the OS reads the freedesktop appearance portal on Linux and AppsUseLightTheme in the registry on Windows, both watched. macOS polls defaults read -g AppleInterfaceStyle every 3 seconds, so a switch reaches the window up to that late: observing AppleInterfaceThemeChangedNotification needs an Objective-C shim no one could compile or run without a Mac. The BSDs read no scheme and draw dark.

  • Profiles are managed in the native window only, and the window asks every profile at start: the web window has no profile screen, no card tag and no Continue in (it is in maintenance, docs/native-window.md), though what it opens or resumes runs in its profile, the backend deciding. The native window reads claudeprofile.Profiles when it opens and when Settings does, which runs one claude auth status per profile, and keeps each logged-in profile's plan reading fresh only while there are two to move between. A login the dialog starts is claude auth login --claudeai, which opens the browser itself and takes the callback on a localhost port of its own (measured on 2.1.296); the address it prints, which redirects to a page that shows a code, and the field that takes the code are for a browser that cannot reach this machine.

  • A Codex profile has its own plugins and a config.toml rebuilt from Personal's at every spawn, so a setting changed inside a profile does not last (internal/codexprofile/config.go): Codex keeps the plugin set ([plugins."<key>"], [marketplaces.<name>]) in the same config.toml as every other setting, and reads no second user file it could be split into: a managed_config.toml in CODEX_HOME is ignored, the other layers are /etc/codex and a project's .codex/config.toml, and -c would hide the plugin set from Codex's own writes (measured on 0.162.1). So a profile's config.toml is a file of its own, and before every spawn lich writes it again as Personal's tables minus Personal's plugin set, then the profile's plugin set, then the project trust ([projects.*]), hook trust ([hooks.state.*]) and dismissed notices ([notice]) the profile was given and Personal was not. Everything else is Personal's: a /model, features enable or MCP server added inside a profile is gone at its next session, and a setting Personal removes leaves every profile then, while a trust answer only Personal withdrew stays in the profiles that gave it. The cut is by table header and copies tables as they are written; plugin or marketplace keys Personal writes as dotted keys above its first table (Codex never does) are every profile's. The plugin and marketplace downloads (plugins/cache, .tmp/marketplaces) are the profile's own too, which costs a clone per profile and is what lets plugin remove and the lich plugin's pinned reinstall (plugin marketplace remove then add) run there without deleting Personal's copy; a profile made when they were links has them removed at its next spawn. The lich plugin follows Personal's (the bullet on it below). Plugins are ported with codex plugin add run as the profile, after Personal's declaration of the marketplace, ref included, is copied into the profile's file and fetched with plugin marketplace upgrade; a marketplace Personal does not declare is left to Codex. Per profile besides: auth.json, sessions/, history.jsonl, session_index.jsonl, the SQLite state and installation_id. CODEX_SQLITE_HOME, CODEX_API_KEY, CODEX_ACCESS_TOKEN and OPENAI_API_KEY are dropped from a profile's spawn: the first would put every profile's thread database in one place and the rest would override the login. A cli_auth_credentials_store of keyring in Personal's config.toml sends the login to the OS keyring, whose entry per CODEX_HOME was not measured. On Windows hooks.json and AGENTS.md are copies without Developer Mode, as for Claude Code.

  • A Codex login is a browser callback on a fixed port, and the dialog has no code to paste: codex login prints an address and waits for the page's redirect on localhost:1455 (measured on 0.162.1), so two logins cannot run at once, in lich or beside it, and a browser that cannot reach this machine needs codex login --device-auth from a terminal, which is not the command the dialog's "log in from a terminal" runs. The dialog decides whether the login took from codex login status's exit status, and reads "not logged in" in Codex's own words to tell a logged-out profile from a broken config.toml. Its account and plan are the plan gauge's reading of that directory (quota.CodexProfilePlan, the ChatGPT usage route, five minutes cached, the email claim of an id token that has not expired), so a profile whose usage call fails is "Logged in" with no name.

  • Continue in moves a Codex rollout and its generated images, and relies on Codex indexing it again (codexprofile.MoveConversation): the rollout under sessions/<yyyy>/<mm>/<dd>/ and generated_images/<id>/. Codex's thread database keeps an absolute rollout_path per thread, and a rollout alone in an empty home was found by id and written into that home's database with its path rewritten (measured on 0.162.1, thread/resume and exec resume); the interactive TUI resume of a moved conversation behind a real login was not exercised. The source's database row and history.jsonl stay, so its own picker may list a thread whose rollout is gone. codex exec resume and thread/read answered "paginated_threads is not supported yet" for a thread of this version in the origin home as well, which is a daemon older than the CLI on the measuring machine and not the move's.