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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **opencode sessions report to lich.** opencode runs no hook commands — a
plugin there is a module its server imports — so its client is one file,
`opencode/lich.js`, sending the same four reports to the same endpoints: the
session id off `session.created`, `busy`/`done` off `session.status`,
`waiting` off `permission.asked`, the tool line off `tool.execute.before`, the
card's label off `session.updated`, and the git-status refresh off
`file.edited`. Install it by dropping the file in `~/.config/opencode/plugin/`
— there is no marketplace to go through. Sub-sessions (the `task` tool) are
filtered out by their `parentID`, so a sub-agent finishing does not look like
the turn ending.
- **Crush sessions report their id and refresh git status.** Crush's hooks are
Claude Code-compatible, so the existing scripts run unchanged — but
`PreToolUse` is its only event, so only the two reports it can honour are
registered. `hooks/crush-hooks.json` is the block to merge into your own
`crush.json`, with `<lich-plugin>` where the clone's path goes. A Crush card
shows no spinner and keeps the name lich gave it; both arrive the day Crush
ships an end-of-turn event.

### Changed

- The test suite is split: `tests/contract.mjs` holds lich's fixtures and the
assertions, and both clients — the hook scripts and the opencode module —
answer to the same lines. `tests/opencode.test.mjs` drives the module through
the events a real opencode run emits.

## [0.7.0] - 2026-08-10

### Added
Expand Down
30 changes: 23 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# lich-plugin

The agent side of the integration with **lich** — a harness that orchestrates agent CLI sessions. This companion plugin is how lich observes and acts inside a running session. It ships hooks — each following a contract documented in `docs/` — plus skills for work that targets lich itself. One plugin, packaged for **Claude Code** and **OpenAI Codex** from the same repository root.
The agent side of the integration with **lich** — a harness that orchestrates agent CLI sessions. This companion plugin is how lich observes and acts inside a running session. It ships hooks — each following a contract documented in `docs/` — plus skills for work that targets lich itself. One plugin, packaged for **Claude Code**, **OpenAI Codex**, **opencode** and **Crush** from the same repository root.

## Structure

Expand All @@ -11,12 +11,21 @@ The agent side of the integration with **lich** — a harness that orchestrates
.agents/plugins/marketplace.json # marketplace, Codex
hooks/hooks.json # hook registration, Claude Code
hooks/codex-hooks.json # hook registration, Codex
hooks/ # hook scripts ($CLAUDE_PLUGIN_ROOT/hooks/<script>, both)
hooks/crush-hooks.json # hook registration, Crush (hand-merged into crush.json)
hooks/ # hook scripts ($CLAUDE_PLUGIN_ROOT/hooks/<script>, all three)
opencode/lich.js # opencode client: a module, not a command
docs/ # lich ⇄ plugin communication contracts
skills/ # skills (skills/<name>/SKILL.md, both)
skills/ # skills (skills/<name>/SKILL.md, all)
tests/ # hook payloads asserted against lich's fixtures
```

Two harnesses do not fit the script model, each its own way. **opencode** runs no
commands: a plugin there is a JavaScript module its server imports, so all four
reports live in `opencode/lich.js`. **Crush** runs the scripts unchanged — its
hooks are Claude Code-compatible down to the stdin payload — but has exactly one
event, `PreToolUse`, so only the session id and the touched refresh are
registered for it.

## Contracts

Contracts are **canonical in the lich repository** (`docs/hooks/` there); this plugin only implements the client side. Each hook has a doc in `docs/` here that points at its contract and describes the plugin-side behavior. Read it before creating or changing a hook:
Expand All @@ -26,7 +35,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p
- [docs/session-title.md](docs/session-title.md) — auto-generated `ai-title` via `Stop`
- [docs/session-touched.md](docs/session-touched.md) — git-status refresh signal via `PostToolUse` (file-mutating tools only)

Each doc carries the event mapping for **every** provider, one column each — a contract is implemented once and registered per harness. [docs/providers.md](docs/providers.md) is the map: which file each harness reads, where the two event vocabularies differ, and what adding a third provider takes.
Each doc carries the event mapping for **every** provider, one column each — a contract is implemented once and registered per harness. [docs/providers.md](docs/providers.md) is the map: which file each harness reads, where the event vocabularies differ, and what adding another provider takes. A harness that cannot close a state does not get that report registered: an unendable `busy` is worse on a card than a card with no indicator.

## Skills

Expand All @@ -43,8 +52,14 @@ subprocess — the command line taken from the registration each harness reads
against a stub HTTP server, and asserts the body it POSTs against lich's
contract fixtures: an accepted shape, no rejected one, the right endpoint and
`?token=`, no deprecated `claude_session_id`, plus the client rules (no lich
environment → no report, exit 0 on a 500 or a refused connection). Node only,
no dependencies.
environment → no report, exit 0 on a 500 or a refused connection).
[tests/opencode.test.mjs](tests/opencode.test.mjs) does the same for the
opencode module, which is imported rather than spawned, and fed the event
payloads a real opencode run emits — including the sub-session ones it must
drop and the listener that never answers, which must not hold the turn.
[tests/contract.mjs](tests/contract.mjs) is the fixtures, the assertions and the
stub, shared so both clients answer to the same lines. Node only, no
dependencies.

The fixtures are vendored in `tests/fixtures/` from lich
(`docs/hooks/fixtures/*.jsonl` there) by
Expand All @@ -60,7 +75,8 @@ the network; CI diffs the copies against upstream and fails on drift.
- A hook must never block or fail the user's turn: short timeout, errors swallowed, always exit 0. On Codex the exit code is louder still — `2` on `PermissionRequest` denies the request, so silence and exit 0 are what keep a report an observation.
- Outside lich (env vars absent) every hook is a no-op with exit 0 — the plugin must be safe to install globally.
- A skill must be useful from any working directory: the user runs the agent CLI on their own project, not on the lich checkout.
- One script per contract, shared by every provider. Registration differs per harness, the report does not — extend a script to read a second transcript format before adding a second script.
- One script per contract, shared by every provider that runs commands. Registration differs per harness, the report does not — extend a script to read a second transcript format before adding a second script. A harness that runs no commands at all is the one case for a client of its own, which is what `opencode/lich.js` is; it still sends the same payloads to the same endpoints.
- opencode awaits its plugin hooks, so `lich.js` never awaits a report and never throws — that is its version of "always exit 0".

## Local testing

Expand Down
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# lich-plugin

The agent side of the [lich](https://github.com/omartelo/lich) integration. lich is a harness that orchestrates agent CLI sessions; this companion plugin gives it eyes and hands inside each session. It installs on **Claude Code** and **OpenAI Codex** from this same repository. Every hook implements a contract that is canonical in the lich repository (`docs/hooks/` there); the docs in `docs/` here point at each contract and describe the client side:
The agent side of the [lich](https://github.com/omartelo/lich) integration. lich is a harness that orchestrates agent CLI sessions; this companion plugin gives it eyes and hands inside each session. It installs on **Claude Code**, **OpenAI Codex**, **opencode** and **Crush** from this same repository. Every hook implements a contract that is canonical in the lich repository (`docs/hooks/` there); the docs in `docs/` here point at each contract and describe the client side:

- **session state** — `busy`/`done` on the session card (`UserPromptSubmit`/`Stop`)
- **session start** — persists the agent's session id on the lich session (`SessionStart`)
- **session title** — names the card after the session's own title (`Stop`)
- **session touched** — refreshes the card's git status right after file-mutating tools (`PostToolUse`)

The four are what Claude Code, Codex and opencode report. **Crush reports two of them** — its session id and the git-status refresh — because `PreToolUse` is the only event it has, and a state nothing can end is worse on a card than no state. [docs/providers.md](docs/providers.md) has the event mapping per harness.

It also ships skills for the parts of lich you configure from inside a session:

- **theme** (`/lich:theme` in Claude Code; on Codex the `theme` skill loads from its description) — write, port or fix a lich color theme: the app tokens, the xterm palette, where the file goes, and a validator for the rules that otherwise fail silently
Expand All @@ -20,17 +22,19 @@ It also ships skills for the parts of lich you configure from inside a session:
.agents/plugins/marketplace.json # marketplace, Codex
hooks/hooks.json # hook registration, Claude Code
hooks/codex-hooks.json # hook registration, Codex
hooks/crush-hooks.json # hook registration, Crush (merged into crush.json by hand)
hooks/report-state.sh # session-state hook
hooks/report-tool.sh # session-state hook: the tool a turn is running
hooks/report-session-start.sh # session-start hook
hooks/report-title.sh # session-title hook
hooks/report-touched.sh # session-touched hook
opencode/lich.js # opencode client: all four reports, one module
docs/ # client-side docs, one per contract
skills/theme/ # theme skill: SKILL.md, template.json, validate.mjs
tests/ # hook payloads asserted against lich's fixtures
```

One set of hook scripts serves both harnesses — they live in `hooks/` and are referenced via `$CLAUDE_PLUGIN_ROOT/hooks/<script>`, a variable Codex sets too. [docs/providers.md](docs/providers.md) maps the layout and the two harnesses' event names.
One set of hook scripts serves Claude Code, Codex and Crush — they live in `hooks/` and are referenced via `$CLAUDE_PLUGIN_ROOT/hooks/<script>`, a variable Codex sets too. opencode runs no commands: a plugin there is a module its server imports, so its client is the single file `opencode/lich.js`, sending the same payloads to the same endpoints. [docs/providers.md](docs/providers.md) maps the layout and every harness's event names.

## Installation

Expand All @@ -54,6 +58,35 @@ codex plugin add lich@lich-plugin

Then start a new session and run `/hooks` to review and trust the plugin's hooks — Codex does not run a plugin's hooks until you do, so until then the plugin is installed but silent. Hooks themselves are stable and on by default in current Codex; on older versions set `[features] hooks = true` in `~/.codex/config.toml`.

### opencode

opencode has no marketplace: a plugin is a file in its plugin directory, so dropping it there is the install.

```bash
mkdir -p ~/.config/opencode/plugin
curl -fsSL -o ~/.config/opencode/plugin/lich.js \
https://raw.githubusercontent.com/omartelo/lich-plugin/main/opencode/lich.js
```

Per project instead of globally, use `.opencode/plugin/lich.js`. Updating means fetching the file again.

### Crush

Crush has no plugin system either: its hooks live in your own `crush.json`. Clone this repository, then merge [`hooks/crush-hooks.json`](hooks/crush-hooks.json) into `~/.config/crush/crush.json` (global) or the project's `crush.json`, replacing `<lich-plugin>` with the absolute path of the clone:

```jsonc
{
"hooks": {
"PreToolUse": [
{ "name": "lich session id", "command": "/home/you/src/lich-plugin/hooks/report-session-start.sh crush", "timeout": 5 },
{ "name": "lich git-status refresh", "matcher": "^(edit|write|multiedit|bash)$", "command": "/home/you/src/lich-plugin/hooks/report-touched.sh", "timeout": 5 }
]
}
}
```

`command` is resolved against the working directory, not the config file, so a global install needs the absolute path. Crush reports no session state and no title — see [docs/providers.md](docs/providers.md) for why.

### Manual (from a clone)

```bash
Expand Down Expand Up @@ -90,4 +123,4 @@ claude --plugin-dir .
node --test tests/*.test.mjs
```

Every hook script runs as a real subprocess, from the command line its registration spells, against a stub HTTP server — and the body it POSTs is asserted against [lich's contract fixtures](https://github.com/omartelo/lich/tree/main/docs/hooks/fixtures): an accepted shape, never a rejected one, the right endpoint and token, plus the client rules (no lich environment → no report; exit 0 when lich answers 500 or refuses the connection). The fixtures are vendored in `tests/fixtures/` by `tests/refresh-fixtures.sh`; CI diffs them against upstream so a contract that moves in lich goes red here.
Every hook script runs as a real subprocess, from the command line its registration spells, against a stub HTTP server — and the body it POSTs is asserted against [lich's contract fixtures](https://github.com/omartelo/lich/tree/main/docs/hooks/fixtures): an accepted shape, never a rejected one, the right endpoint and token, plus the client rules (no lich environment → no report; exit 0 when lich answers 500 or refuses the connection). The opencode module is imported instead of spawned and fed the events a real opencode run emits, against the same fixtures. Both share `tests/contract.mjs`. The fixtures are vendored in `tests/fixtures/` by `tests/refresh-fixtures.sh`; CI diffs them against upstream so a contract that moves in lich goes red here.
Loading
Loading