diff --git a/CHANGELOG.md b/CHANGELOG.md
index 432f9a4e..437295e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
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,
diff --git a/CLAUDE.md b/CLAUDE.md
index 51f80d63..ef9f3f3e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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//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.
diff --git a/docs/cli.md b/docs/cli.md
index 9a405328..2bd62377 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -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:
diff --git a/docs/hooks/README.md b/docs/hooks/README.md
index 47514343..cf37ae60 100644
--- a/docs/hooks/README.md
+++ b/docs/hooks/README.md
@@ -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
diff --git a/docs/hooks/fixtures/session-start.jsonl b/docs/hooks/fixtures/session-start.jsonl
index 21d2460d..eadbb301 100644
--- a/docs/hooks/fixtures/session-start.jsonl
+++ b/docs/hooks/fixtures/session-start.jsonl
@@ -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"}}
diff --git a/docs/hooks/session-start.md b/docs/hooks/session-start.md
index a5f1ff8b..da1426ed 100644
--- a/docs/hooks/session-start.md
+++ b/docs/hooks/session-start.md
@@ -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
@@ -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
@@ -101,14 +104,13 @@ 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
- `, `codex resume `), 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 `, `codex resume
+ `, `omp -r `), 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 `, `crush --session `), 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
@@ -116,14 +118,19 @@ cares when the id arrived, only which conversation it names.
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/*/.jsonl` for Claude Code and
- `~/.codex/sessions/*/*/*/rollout-*-.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/*/.jsonl` for Claude Code,
+ `~/.codex/sessions/*/*/*/rollout-*-.jsonl` for Codex and
+ `~/.omp/agent/sessions/*/*_.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.
diff --git a/docs/hooks/session-state.md b/docs/hooks/session-state.md
index 2aad9c26..07723777 100644
--- a/docs/hooks/session-state.md
+++ b/docs/hooks/session-state.md
@@ -34,14 +34,14 @@ 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
@@ -49,6 +49,11 @@ 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
@@ -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
diff --git a/docs/hooks/session-title.md b/docs/hooks/session-title.md
index 0410ca41..3cf3ec7c 100644
--- a/docs/hooks/session-title.md
+++ b/docs/hooks/session-title.md
@@ -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`.
@@ -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.
diff --git a/docs/hooks/session-touched.md b/docs/hooks/session-touched.md
index 0e123626..f4dff75c 100644
--- a/docs/hooks/session-touched.md
+++ b/docs/hooks/session-touched.md
@@ -25,9 +25,9 @@ Both sides test against the payloads in
## Event → action mapping
-| Claude Code hook | Codex hook | opencode event | Crush hook | action |
-|-----------------------------------------|-------------------------------------|----------------|-------------------------------------|----------------------------------|
-| `PostToolUse` (file-mutating tools) | `PostToolUse` (file-mutating tools) | `file.edited` | `PreToolUse` (file-mutating tools) | refresh the session's git status |
+| Claude Code hook | Codex hook | opencode event | oh-my-pi event | Crush hook | action |
+|-----------------------------------------|-------------------------------------|----------------|--------------------------------------|-------------------------------------|----------------------------------|
+| `PostToolUse` (file-mutating tools) | `PostToolUse` (file-mutating tools) | `file.edited` | `tool_result` (file-mutating tools) | `PreToolUse` (file-mutating tools) | refresh the session's git status |
Fire it from `PostToolUse` **only for tools that write to disk** — the names are
the provider's, so match its own: `Edit`, `Write`, `NotebookEdit`, `Bash` on
@@ -39,7 +39,10 @@ hook's stdin payload if a single script filters instead of per-tool matchers.
opencode needs no matcher: `file.edited` fires only when a file actually
changed, which is the signal the other harnesses approximate by filtering tool
-names. Crush is the opposite — `PreToolUse` fires *before* the write, so its
+names. oh-my-pi filters like Claude Code does, on the result rather than the
+call so the refresh sees the tree after the write; its `python` tool is left out
+on purpose, being a general evaluator that would fire a refresh per
+computation. Crush is the opposite — `PreToolUse` fires *before* the write, so its
refresh reads a tree the tool has not touched yet (see the ceilings).
## lich server side
diff --git a/frontend/src/components/settings/PluginSetting.tsx b/frontend/src/components/settings/PluginSetting.tsx
index 3ce0d5cf..b8d5802b 100644
--- a/frontend/src/components/settings/PluginSetting.tsx
+++ b/frontend/src/components/settings/PluginSetting.tsx
@@ -4,7 +4,12 @@ import { Button } from "@/components/ui/button"
import { ProviderIcon } from "@/components/ProviderIcon"
import { SettingBlock } from "./SettingBlock"
import { AgentPlugin } from "@/lib/rpc"
-import { CODEX_TRUST_HINT, CRUSH_SCOPE_HINT, RESTART_HINT } from "@/lib/update/plugin-gate"
+import {
+ CODEX_TRUST_HINT,
+ CRUSH_SCOPE_HINT,
+ OMP_APPROVAL_HINT,
+ RESTART_HINT,
+} from "@/lib/update/plugin-gate"
import { runWithToast } from "@/lib/toast-async"
import type { PluginStatus } from "@/lib/api-types"
@@ -46,6 +51,7 @@ export function PluginSetting({ statuses, onRefresh }: PluginSettingProps) {
const spinner =
const showTrustHint = statuses?.some((s) => s.provider === "codex" && s.installed)
const showCrushHint = statuses?.some((s) => s.provider === "crush" && s.installed)
+ const showOMPHint = statuses?.some((s) => s.provider === "omp" && s.installed)
return (
Crush: {CRUSH_SCOPE_HINT}