Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **oh-my-pi runs the lich plugin.** Settings › lich plugin now offers omp the
same install as the others, writing the released extension into omp's own
`extensions/` directory — so an omp card shows what its session is doing,
renames itself from the conversation's title, and refreshes git status as
files change. The install also registers lich's MCP server in omp's
`mcp.json`, merged in beside whatever is already there, which puts the tools
for reaching the other sessions in an omp agent's own tool list. Two gaps are
the harness's own and Settings names them: omp has no observed approval event,
so a session waiting on your permission shows a spinner rather than a bell.

- **An omp session resumes its conversation.** Reopening a card that ran omp
before a restart offers the same "continue where it left off" prompt Claude
Code and Codex cards get, and lich only offers it when omp's own transcript
for that conversation is still on disk.

- **A session can be opened on a specific model.** `lich open --model <model>`
and the `open_session` MCP tool's `model` argument start the new session's
provider on the model you name, in that provider's own spelling — Claude Code,
Expand Down
24 changes: 16 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,19 @@ nobody knows it and that the call site never shows. The mechanism and the histor
the running window (best-effort, untested against a real window) and exits 0.
- **Reordering rides dnd-kit's pointer sensors** (`frontend/src/lib/use-sortable-list.ts`); the activation distance
is load-bearing — without it plain clicks stop selecting a session.
- **Installing the plugin writes into two harnesses' own directories**
(`internal/agentplugin`): Claude Code and Codex are driven through their plugin CLI, but opencode and Crush have
none, so lich writes the released files itself — a module into opencode's plugin dir, hook scripts plus a
delimited block in Crush's `crushrc`. Neither harness records what is installed, so the version lives in a marker
line lich wrote; edit the file by hand and lich reads it as not installed. Crush below 0.88.0 ignores those lines
in silence, which is why the install asks its version first. The Crush block also registers lich's MCP server, by
the absolute path of the binary that installed it — a line in a file cannot expand `$LICH_BIN` per session, and
the binary is only the transport: which lich a session reaches is decided by the coordinates in its PTY.
- **Installing the plugin writes into three harnesses' own directories**
(`internal/agentplugin`): Claude Code and Codex are driven through their plugin CLI, but opencode, oh-my-pi and
Crush have none, so lich writes the released files itself — a module into opencode's plugin dir, another into
omp's `extensions/`, hook scripts plus a delimited block in Crush's `crushrc`. None of them records what is
installed, so the version lives in a marker line lich wrote; edit the file by hand and lich reads it as not
installed. Crush below 0.88.0 ignores those lines in silence, which is why the install asks its version first.
The Crush block and omp's `mcp.json` also register lich's MCP server, by the absolute path of the binary that
installed it — a line in a file cannot expand `$LICH_BIN` per session, and the binary is only the transport:
which lich a session reaches is decided by the coordinates in its PTY. omp's is a JSON document rather than an
appendable one, so lich rewrites it: every key survives, the user's formatting does not, and a file lich cannot
parse aborts the install rather than being replaced.
- **omp's state directory answers to two variables, and the profile wins**
(`internal/agentplugin/omp.go`, `internal/terminal/resume.go`, both resolving it independently as the Claude Code
pair do): `OMP_PROFILE` moves the whole directory under `~/.omp/profiles/<name>/agent` and beats an explicit
`PI_CODING_AGENT_DIR`, which is the order `omp config path` was measured to apply. Get it backwards and the
install lands where omp is not reading and every restored card silently starts fresh.
12 changes: 6 additions & 6 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,14 +399,14 @@ own command line (`providers.AcceptsMCPServer`):
| Codex | `-c mcp_servers.lich.command=…` and `…args=["mcp"]` | at spawn |
| Crush | an `mcp add` line in the block the plugin install writes into `crushrc` | with the plugin |
| opencode | its plugin defines the same seven as tools of its own — a plugin there cannot register an MCP server | with the plugin |
| oh-my-pi | no flag, no plugin | no |
| oh-my-pi | a `lich` entry merged into `mcp.json` beside the extension the plugin install writes | with the plugin |

Only the first two can be told on their own command line, which is what makes
their registration per-session and secret-free. The next two arrive with the
plugin instead (Settings › lich plugin), because that install already writes
into those harnesses' own directories — so the server is registered where the
hooks are, removed by the same uninstall, and absent for anyone who never
installed it. oh-my-pi has neither, and its sessions use the command line above.
their registration per-session and secret-free. The rest arrive with the plugin
instead (Settings › lich plugin), because that install already writes into
those harnesses' own directories — so the server is registered where the hooks
are, removed by the same uninstall, and absent for anyone who never installed
it.

Two rules the registration must keep:

Expand Down
8 changes: 4 additions & 4 deletions docs/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
lich observes and drives provider sessions through **hooks**. Each hook runs
inside a session (shipped by the companion plugin
[`omartelo/lich-plugin`](https://github.com/omartelo/lich-plugin), which installs
on Claude Code, Codex, opencode and Crush) and talks to lich over a shared local
transport. On the first three it is a small script the harness runs; opencode
loads a JavaScript module instead, which is a difference in packaging, not in
what a report is.
on Claude Code, Codex, opencode, oh-my-pi and Crush) and talks to lich over a
shared local transport. On Claude Code, Codex and Crush it is a small script the
harness runs; opencode and oh-my-pi load a JavaScript module instead, which is a
difference in packaging, not in what a report is.

The contracts are provider-agnostic: lich injects the same variables into every
PTY it spawns, so what changes per provider is only which of its lifecycle
Expand Down
1 change: 1 addition & 0 deletions docs/hooks/fixtures/session-start.jsonl
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{"name":"reported provider","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"codex"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"codex"}}
{"name":"opencode session id","body":{"session_id":"s1","provider_session_id":"ses_011856954ffe5NEMhsYeTuWykR","provider":"opencode"},"accept":{"session_id":"s1","provider_session_id":"ses_011856954ffe5NEMhsYeTuWykR","provider":"opencode"}}
{"name":"omp session id","body":{"session_id":"s1","provider_session_id":"019ffb38-ceab-7000-afae-20b8eae145d8","provider":"omp"},"accept":{"session_id":"s1","provider_session_id":"019ffb38-ceab-7000-afae-20b8eae145d8","provider":"omp"}}
{"name":"crush session id","body":{"session_id":"s1","provider_session_id":"db3492a0-4a6b-4335-8c0b-d4cc43fc4cb0","provider":"crush"},"accept":{"session_id":"s1","provider_session_id":"db3492a0-4a6b-4335-8c0b-d4cc43fc4cb0","provider":"crush"}}
{"name":"absent provider defaults to claude","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"claude"}}
{"name":"legacy claude_session_id is folded","body":{"session_id":"s1","claude_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02","provider":"claude"}}
Expand Down
51 changes: 29 additions & 22 deletions docs/hooks/session-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Content-Type: application/json
Code and Codex, the hook payload's `session_id` field on stdin. Must be
non-empty.
- `provider` — which CLI is reporting, as a provider id from
`internal/providers.Registry` (`claude`, `codex`, `opencode`, `crush`).
`internal/providers.Registry` (`claude`, `codex`, `opencode`, `omp`, `crush`).
Optional: absent means `claude`, the only provider that reported before the
field existed. An id outside the registry is rejected, like an unknown state
on `/hook` — lich ships its side of a contract first, so a provider it has no
Expand All @@ -42,19 +42,22 @@ deprecated alias and the defaulted `provider`.

## Event → action mapping

| Claude Code hook | Codex hook | opencode event | Crush hook | action |
|------------------|----------------|--------------------|---------------|-------------------------------------------|
| `SessionStart` | `SessionStart` | `session.created` | `PreToolUse` | store `provider_session_id` on the lich |
| | | | | session row, and mark the card as running |
| | | | | `provider` (the `session-agent` app event)|
| Claude Code hook | Codex hook | opencode event | oh-my-pi event | Crush hook | action |
|------------------|----------------|--------------------|-----------------|---------------|-------------------------------------------|
| `SessionStart` | `SessionStart` | `session.created` | `session_start` | `PreToolUse` | store `provider_session_id` on the lich |
| | | | | | session row, and mark the card as running |
| | | | | | `provider` (the `session-agent` app event)|

`SessionStart` fires on startup, resume, `/clear` and compaction. A resume
reports the resumed session's id and overwrites the stored value — lich always
holds the id of the provider session currently in the card.

The other two report the same id from the earliest place each one offers it.
The others report the same id from the earliest place each one offers it.
opencode raises `session.created` when the conversation is created, before the
first turn, so it lands as early as `SessionStart` does. **Crush has only
first turn, so it lands as early as `SessionStart` does; oh-my-pi's
`session_start` fires before the first turn too, and the id is read off the
session manager the event hands the extension — omp puts nothing about the
session in the environment. **Crush has only
`PreToolUse`**, whose payload carries the same `session_id` every other harness
reports on stdin — so a Crush session announces itself when it first reaches for
a tool, and a conversation that answers without one never announces itself at
Expand Down Expand Up @@ -101,29 +104,33 @@ cares when the id arrived, only which conversation it names.
- **Not the transcript path.** The path is reconstructable from the id and cwd;
storing it too is a contract change — add a field only when a feature needs
it, per the versioning note in the README.
- **Only Claude Code and Codex resume.** The field and the column are
provider-agnostic, but each CLI spells resume its own way (`claude --resume
<id>`, `codex resume <id>`), so both `resumeArgs`
(`internal/terminal/command.go`) and `resumableSession`
(`frontend/src/lib/session/sessions.ts`) list the kinds that have one. A provider
outside that list reporting an id has it stored and ignored until its own
invocation is wired.
- **opencode and Crush report an id lich does not yet resume.** Both spell it
- **Three providers resume.** The field and the column are provider-agnostic,
but each CLI spells resume its own way (`claude --resume <id>`, `codex resume
<id>`, `omp -r <id>`), so both `resumeArgs` (`internal/terminal/command.go`)
and `resumableSession` (`frontend/src/lib/session/sessions.ts`) list the kinds
that have one. A provider outside that list reporting an id has it stored and
ignored until its own invocation is wired.
- **opencode and Crush report an id lich does not resume.** Both spell it
(`opencode --session <id>`, `crush --session <id>`), so the invocation is the
easy half. The gate is the one below: lich only offers a resume it has proof
of, and both keep their conversations in SQLite — `opencode.db` under the data
dir, Crush's under its `--data-dir` — where there is no per-session file to
glob for. Wiring them means either reading another tool's schema or offering a
resume that can die inside the PTY, and neither is worth it for a mark on a
card. The id is stored today so the day one of them is wired, the sessions
already carry it.
already carry it. oh-my-pi is the counterexample that shows where the line is:
it files one JSONL per session, so there is something to glob for, and it
resumes.
- **Resume availability is asked of each provider's transcript directory.**
`ResumeAvailable` (`internal/terminal/resume.go`) globs
`~/.claude/projects/*/<id>.jsonl` for Claude Code and
`~/.codex/sessions/*/*/*/rollout-*-<id>.jsonl` for Codex
(`CLAUDE_CONFIG_DIR` / `CODEX_HOME` override the roots). Both layouts are
internal to the provider: one that moves its files makes every restored card
start fresh instead of erroring, which is the direction this fails in.
`~/.claude/projects/*/<id>.jsonl` for Claude Code,
`~/.codex/sessions/*/*/*/rollout-*-<id>.jsonl` for Codex and
`~/.omp/agent/sessions/*/*_<id>.jsonl` for oh-my-pi (`CLAUDE_CONFIG_DIR`,
`CODEX_HOME` and `OMP_PROFILE`/`PI_CODING_AGENT_DIR` override the roots; a
named omp profile wins over the explicit override, which is the order
`omp config path` applies). Every layout is internal to the provider: one that
moves its files makes every restored card start fresh instead of erroring,
which is the direction this fails in.
- **The deprecated `claude_session_id` alias stays until the install gate can
no longer meet a plugin older than v0.3.0.** Dropping it earlier silently
breaks resume for anyone who has not updated the plugin.
29 changes: 21 additions & 8 deletions docs/hooks/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,26 @@ Both sides test against the payloads in

## Event → state mapping

| Claude Code hook | Codex hook | opencode event | Crush hook | state |
|--------------------|---------------------|--------------------------|------------|-----------|
| `UserPromptSubmit` | `UserPromptSubmit` | `session.status` (`busy`) | — | `busy` |
| `PreToolUse` | `PreToolUse` | `tool.execute.before` | — | `busy` + `tool` |
| `PostToolUse` | `PostToolUse` | `tool.execute.after` | — | `busy` |
| `Notification` | `PermissionRequest` | any `*.asked` | — | `waiting` |
| `Stop` | `Stop` | `session.status` (`idle`) | — | `done` |
| `SessionEnd` | — | — | — | `idle` |
| Claude Code hook | Codex hook | opencode event | oh-my-pi event | Crush hook | state |
|--------------------|---------------------|--------------------------|----------------|------------|-----------|
| `UserPromptSubmit` | `UserPromptSubmit` | `session.status` (`busy`) | `input` | — | `busy` |
| `PreToolUse` | `PreToolUse` | `tool.execute.before` | `tool_call` | — | `busy` + `tool` |
| `PostToolUse` | `PostToolUse` | `tool.execute.after` | `turn_start` | — | `busy` |
| `Notification` | `PermissionRequest` | any `*.asked` | — | — | `waiting` |
| `Stop` | `Stop` | `session.status` (`idle`) | `session_stop` | — | `done` |
| `SessionEnd` | — | — | — | — | `idle` |

opencode is the one harness that reports a state rather than an event: its
`session.status` carries `busy`, `idle` or `retry` for the session named in the
same payload. Its `idle` means the turn ended, which is lich's `done` — not
lich's `idle`, which says the CLI itself has left. Nothing in opencode's event
list says that, so like Codex it never reports `idle`.

**oh-my-pi carries `busy` on `turn_start` rather than after a tool.** Its
non-interactive runs never emit `input` at all, so the turn boundary is the one
event every turn passes through — the same place the title is re-read, since omp
writes it asynchronously after the turn.

**opencode's `waiting` is a rule, not an event name.** It asks the user in more
than one way — a permission decision and an interactive question, each with a
`.v2.` spelling published alongside — so the client reports `waiting` for any
Expand Down Expand Up @@ -205,6 +210,14 @@ a broken script could do was lose a status report.
is the server going away with the plugin inside it. Nothing survives to report
it. An opencode card therefore keeps its last indicator until lich respawns
that PTY, exactly like a Codex one.
- **oh-my-pi reports neither `waiting` nor `idle`.** Its extension runs
in-process, so `idle` dies with the process that would send it, exactly as
opencode's does. `waiting` is absent for a different reason: omp declares a
`tool_approval_requested` event, but no real run was ever observed emitting it
— and a report wired to an event name that never fires is one that silently
never arrives. So an omp session waiting on a permission shows a spinner
rather than a bell, which Settings says out loud (`OMP_APPROVAL_HINT`) because
a missing bell reads as a broken install.
- **An opencode report is only as precise as the server it runs in.** The plugin
reads `LICH_SESSION_ID` from the environment of the process it was loaded by,
and one opencode server can hold several conversations. Sub-sessions (the
Expand Down
11 changes: 7 additions & 4 deletions docs/hooks/session-title.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ Both sides test against the payloads in

## Event → action mapping

| Claude Code hook | Codex hook | opencode event | Crush hook | action |
|------------------|------------|-------------------|------------|--------------------------------------------------|
| `Stop` | `Stop` | `session.updated` | — | set the session label to `title` (if still auto) |
| Claude Code hook | Codex hook | opencode event | oh-my-pi event | Crush hook | action |
|------------------|------------|-------------------|-------------------------------|------------|--------------------------------------------------|
| `Stop` | `Stop` | `session.updated` | `session_stop` + `turn_start` | — | set the session label to `title` (if still auto) |

The `ai-title` is an internal Haiku summary of the first prompt, written to the
transcript **after** the first turn — so it does not exist at `SessionStart`.
Expand All @@ -51,7 +51,10 @@ while the label is still automatic (see below), so a stable title is idempotent.

opencode is the one harness that hands the title over instead of being read out
of a transcript: every `session.updated` carries the whole session, title
included, and the plugin forwards it when it changed. It arrives more than once
included, and the plugin forwards it when it changed. oh-my-pi hands it over too
— off the session manager every event carries — but writes it asynchronously
after the turn, which is why the client reads it again on the next `turn_start`
and sends only what changed. It arrives more than once
per turn, which the idempotence above already covers. **Crush has no title to
report** — its only event is `PreToolUse`, whose payload is about the tool — so a
Crush card keeps the name lich gave it.
Expand Down
Loading