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

### Added

- **A waiting card says what the session is waiting for.** The `waiting` report
now carries a `reason`, so the bell says *why* rather than only *that*. Each
harness hands over something different to say it with: Claude Code's
`Notification` message, which is already a sentence written for a human
("Claude needs your permission to use Bash") and covers the plain idle nudge
too; the tool Codex's `PermissionRequest` is asking about, qualified with the
same words a busy report puts on the card (`apply_patch:
internal/terminal/usage.go`); and, on opencode, the permission, the v2 action
or a question's own short header. Read by field rather than by event name, so
a prompt type nobody enumerated still rings the bell — the reason is optional
in the contract, and a missing one is never a reason to drop the report. omp
and Crush report no `waiting` at all, so neither gains anything here. No
minimum lich: the field is additive in both directions, so an older lich
ignores it and rings the bell exactly as before.
- **An opencode session can rename a lich card.** `rename_session` is the eighth
tool, beside the seven that were already there: with a session it renames that
one, with only a name it renames the session the agent is running in — which is
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ hooks/hooks.json # hook registration, Claude Code
hooks/codex-hooks.json # hook registration, Codex
hooks/crush-hooks.json # hook registration, Crush (hand-merged into crush.json)
hooks/ # hook scripts ($CLAUDE_PLUGIN_ROOT/hooks/<script>, all three)
hooks/detail.jq # what a tool call acts on, shared by two of those scripts
hooks/win-run.cmd # runs one of those scripts on Windows (Codex)
opencode/lich.js # opencode client: a module, not a command
omp/lich.js # omp client: a module, not a command
Expand Down
10 changes: 8 additions & 2 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ swallows its own errors and never awaits a report.
| session id | `SessionStart` | `SessionStart` | `session.created` | `session_start` | `PreToolUse` |
| `busy` | `UserPromptSubmit`, `PostToolUse` | `UserPromptSubmit`, `PostToolUse` | `session.status` (`busy`) | `input`, `turn_start` | — |
| `busy` + tool | `PreToolUse` | `PreToolUse` | `tool.execute.before` | `tool_call` | — |
| `waiting` | `Notification` | `PermissionRequest` | any `*.asked` | — (see below) | — |
| `waiting` + reason | `Notification` | `PermissionRequest` | any `*.asked` | — (see below) | — |
| `done` | `Stop` | `Stop` | `session.status` (`idle`) | `session_stop` | — |
| title | `Stop` | `Stop` | `session.updated` | `session_stop`, `turn_start` | — |
| `idle` | `SessionEnd` | — (registered, never fires) | — (nothing outlives it) | — (nothing outlives it) | — |
Expand All @@ -75,7 +75,13 @@ It asks the user in more than one way — `permission.asked` and `question.asked
each with a `.v2.` spelling published beside it — and its catalogue is not
exhaustive: a real run emits types (`server.heartbeat`) absent from its own
schema. Enumerating names there is how a prompt goes unreported, so the module
matches the suffix. See [session-state.md](session-state.md).
matches the suffix, and reads what the prompt is about by field for the same
reason. See [session-state.md](session-state.md).

A `waiting` report says what the session is blocked on, and each harness hands
over something different to say it with — a sentence, a tool name, a permission.
The per-harness table is in [session-state.md](session-state.md#the-reason);
omp and Crush have no cell there, because neither reports `waiting` at all.

The session id carries the provider that reported it (`report-session-start.sh
codex`), which is what puts Codex's icon rather than Claude's on the card, and
Expand Down
53 changes: 48 additions & 5 deletions docs/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ toast) when the agent is blocked on the user.
| `report-tool.sh` | `busy` + `tool` | `PreToolUse` | `PreToolUse` | `tool.execute.before` | `tool_call` |
| `report-state.sh busy` | `busy` | `PostToolUse` | `PostToolUse` | `session.status` (`busy`) | `turn_start` |
| `report-state.sh done` | `done` | `Stop` | `Stop` | `session.status` (`idle`) | `session_stop` |
| `report-state.sh waiting` | `waiting` | `Notification` | `PermissionRequest` | any `*.asked` | — (not measured)|
| `report-state.sh waiting` | `waiting`+`reason`| `Notification` | `PermissionRequest` | any `*.asked` | — (not measured)|
| `report-state.sh idle` | `idle` | `SessionEnd` | — (never fires) | — (never fires) | — (never fires) |

opencode and omp run no scripts: `opencode/lich.js` and `omp/lich.js` send the
Expand All @@ -33,9 +33,10 @@ therefore shows a spinner, not a bell, while the agent waits on you — one stat
late, never wrong. The event is the first thing to measure when someone can drive
an approval prompt.

`POST /hook` with `{"session_id": $LICH_SESSION_ID, "state":
"busy"|"done"|"waiting"|"idle"}`, plus the optional `tool` / `detail` pair the
pre-tool report adds.
The endpoint, the accepted states and the optional fields (`tool` / `detail`
on the pre-tool report, `reason` on the waiting one) are spelled out once, in
[lich's contract](https://github.com/omartelo/lich/blob/main/docs/hooks/session-state.md).
What is below is only what each harness gives this plugin to fill them with.

`Notification` fires when Claude needs a permission decision or has been idle
waiting for input — both mean "your turn". A later `busy` or `done` clears it.
Expand All @@ -57,6 +58,46 @@ Answering (`question.replied` → `session.status busy`) and dismissing
(`question.rejected` → `session.status idle`) both re-arm the card on their own,
which is why neither is registered here.

## The reason

A `waiting` report carries what the agent is blocked on, so the bell on the card
says *why* rather than only *that*. It rides `waiting` alone — lich drops it on
every other state — and it is optional in both directions: the bell has to land
whether or not a reason could be built, so no shape below is ever a reason to
refuse the report.

| harness | where the reason comes from |
|-------------|------------------------------------------------------------------------|
| Claude Code | the `Notification` payload's `message` |
| Codex | `tool_name`, qualified with the same `detail` a busy report would carry |
| opencode | `permission`, the v2 `action`, or a question's `header` |
| omp, Crush | — neither reports `waiting` at all |

Claude Code is the only harness that hands over a sentence written for a human
("Claude needs your permission to use Bash"), and it writes one for the plain
idle-at-the-prompt nudge too — which is a reason as much as a permission is, so
both go out unchanged and lich still decides which arrived. Codex's
`PermissionRequest` has no message field at all: its payload is the `PreToolUse`
envelope plus `model`, `permission_mode` and `turn_id`, so the report is the
tool it is asking about with the words the busy report already puts on the card
behind it — `apply_patch: internal/terminal/usage.go`. That second half is
[`detail.jq`](../hooks/detail.jq), shared with `report-tool.sh` so the two
readings of a tool call cannot drift.

opencode's four `.asked` events spell it four ways, so `lich.js` reads it by
field the way it reads a tool call's detail: `permission`, then the v2 `action`,
then the first question's `header` — opencode's own ≤30-character label, built
for exactly this kind of surface — with the full question behind it when a
caller left the header out. A field rule covers the next prompt type too, and
one that carries none of them still reports `waiting` with nothing attached,
which is the documented degrade rather than a bug.

Nothing here measures the reason's length: lich caps it, and a cap is not a
refusal. Without `jq` (Windows, usually) the message stays home for the same
reason the detail does — a hand-built body cannot escape arbitrary text — but
Codex's tool name is an identifier and goes out alone, and Codex is the harness
that needs the Windows wrapper.

`SessionEnd → idle` clears the card's indicator (no spinner/check/bell). It
fires when the session ends or is reset, so a stale state does not linger on a
dead session, and a `/clear` starts the next session with a clean card. Codex
Expand Down Expand Up @@ -107,7 +148,9 @@ spell it `path` (read, write, edit, glob), `command` (bash) or `pattern`
`query` / `url` spellings after them, for the tools omp does not define itself:
an extension's or an MCP server's, named however their author named them.

Two shapes need more than the plain rule:
Two shapes need more than the plain rule, and both live in
[`detail.jq`](../hooks/detail.jq) — one copy, because the waiting report reads
the same tool call for the second half of its reason:

- **`apply_patch`** passes the *whole patch* as its command, so the plain rule
would put `*** Begin Patch` on the card. The file its `*** Add/Update/Delete
Expand Down
31 changes: 31 additions & 0 deletions hooks/detail.jq
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# The agent's own words for what a tool call acts on, read by field rather than
# by tool name: one rule then covers every harness, because a Codex shell call
# arrives as `Bash` carrying the same `command` string Claude Code sends.
#
# Shared, so the two readings of it cannot drift: `report-tool.sh` sends it as
# the `detail` of a busy report, `report-state.sh` as half the `reason` of a
# waiting one. Contract: ../docs/session-state.md.
#
# `apply_patch` is the exception. Codex passes the whole patch as the command,
# so the plain rule would put "*** Begin Patch" on the card; the file the patch
# names is the readable half of it. A patch touching several files shows the
# first, which is the one the eye would land on anyway.
#
# A path is shortened against the session's own directory, because a card is
# 240px wide and both harnesses report absolute ones: a full path fills the line
# with the part every card shares. Outside that directory only the file name is
# left, which at least names the file. Commands are never shortened — a leading
# slash there belongs to a binary, not to a path worth cutting.
.cwd as $cwd
| def short: if ($cwd // "") != "" and startswith($cwd + "/") then ltrimstr($cwd + "/")
elif startswith("/") then sub(".*/"; "")
else . end;
(.tool_input // {})
| if ((.command? // "") | tostring) != "" then
(.command | tostring
| if startswith("*** Begin Patch")
then ((capture("\\*\\*\\* (?:Add|Update|Delete) File: (?<p>[^\n]*)") // {}) | .p // "")
else . end)
else
((.file_path? // .path? // .pattern? // .url? // .query? // "") | tostring | short)
end
47 changes: 46 additions & 1 deletion hooks/report-state.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,59 @@
#!/bin/sh
# Reports session state to lich. Contract: docs/session-state.md
# Usage: report-state.sh <busy|done|waiting|idle>
# stdin: the hook payload — read for `waiting` alone, for the reason below.

# Outside lich (vars absent) → no-op. Safe to install globally.
[ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0

state=$1
body="{\"session_id\":\"${LICH_SESSION_ID}\",\"state\":\"${state}\"}"

# `reason` says what the agent is blocked on, and belongs to `waiting` alone —
# lich drops it on every other state, so no other branch reads stdin. It is
# optional in both directions: an absent one still rings the bell, and a value
# lich finds too long is capped there rather than refused, so nothing here
# measures it.
if [ "$state" = waiting ]; then
payload=$(cat)
if command -v jq >/dev/null 2>&1; then
# Claude Code's Notification is the one payload carrying a sentence written
# for a human ("Claude needs your permission to use Bash"), and it writes one
# for the plain idle-at-the-prompt nudge too — which is a reason as well.
# `strings` guards against a harness that ever sends something else there,
# `\S` against whitespace, and `head` keeps it to the one line a card is.
reason=$(printf '%s' "$payload" | \
jq -r '.message | strings | select(test("\\S"))' 2>/dev/null | head -n 1)
if [ -z "$reason" ]; then
# Codex's PermissionRequest has no message field at all, so its report is
# the tool it is asking about, qualified with the words the busy report
# already puts on the card.
tool=$(printf '%s' "$payload" | jq -r '.tool_name // empty' 2>/dev/null | head -n 1)
detail=$(printf '%s' "$payload" | \
jq -r -f "$(dirname "$0")/detail.jq" 2>/dev/null | head -n 1)
if [ -n "$tool" ]; then
reason=$tool
[ -n "$detail" ] && reason="$tool: $detail"
fi
fi
[ -n "$reason" ] && body=$(jq -cn --arg sid "$LICH_SESSION_ID" --arg reason "$reason" \
'{session_id: $sid, state: "waiting", reason: $reason}')
else
# Without jq (Windows, usually) the message does not go out: it is arbitrary
# text, and a hand-built body cannot escape it. A tool name is an identifier
# and can, which is the whole of what Codex — the harness that needs the
# Windows wrapper — sends anyway.
tool=$(printf '%s' "$payload" | \
sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
[ -n "$tool" ] && \
body="{\"session_id\":\"${LICH_SESSION_ID}\",\"state\":\"waiting\",\"reason\":\"${tool}\"}"
fi
fi

curl -s -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/hook?token=${LICH_TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"session_id\":\"${LICH_SESSION_ID}\",\"state\":\"$1\"}" \
-d "$body" \
|| true

# Never blocks or fails the turn.
Expand Down
32 changes: 4 additions & 28 deletions hooks/report-tool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# Outside lich (vars absent) → no-op. Safe to install globally.
[ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0

here=$(dirname "$0")
payload=$(cat)
command -v jq >/dev/null 2>&1 && has_jq=1 || has_jq=

Expand All @@ -25,36 +26,11 @@ fi
[ -n "$tool" ] || exit 0

# What the call is about, taken from whichever field the harness filled rather
# than from the tool's name: one rule then covers both, because a Codex shell
# call arrives as `Bash` with the same `command` field Claude Code uses. Only
# with jq — the value is arbitrary text, and a hand-built body cannot escape it.
#
# `apply_patch` is the exception. Codex passes the whole patch as the command,
# so the plain rule would put "*** Begin Patch" on the card; the file the patch
# names is the readable half of it. A patch touching several files shows the
# first, which is the one the eye would land on anyway.
#
# A path is shortened against the session's own directory, because a card is
# 240px wide and both harnesses report absolute ones: a full path fills the line
# with the part every card shares. Outside that directory only the file name is
# left, which at least names the file. Commands are never shortened — a leading
# slash there belongs to a binary, not to a path worth cutting.
# than from the tool's name — detail.jq, shared with the waiting report. Only
# with jq: the value is arbitrary text, and a hand-built body cannot escape it.
detail=""
if [ -n "$has_jq" ]; then
detail=$(printf '%s' "$payload" | jq -r '
.cwd as $cwd
| def short: if ($cwd // "") != "" and startswith($cwd + "/") then ltrimstr($cwd + "/")
elif startswith("/") then sub(".*/"; "")
else . end;
(.tool_input // {})
| if ((.command? // "") | tostring) != "" then
(.command | tostring
| if startswith("*** Begin Patch")
then ((capture("\\*\\*\\* (?:Add|Update|Delete) File: (?<p>[^\n]*)") // {}) | .p // "")
else . end)
else
((.file_path? // .path? // .pattern? // .url? // .query? // "") | tostring | short)
end' 2>/dev/null | head -n 1)
detail=$(printf '%s' "$payload" | jq -r -f "$here/detail.jq" 2>/dev/null | head -n 1)
fi

# The detail is omitted rather than sent empty: the contract's optional field is
Expand Down
14 changes: 13 additions & 1 deletion opencode/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ function detailOf(args) {
return undefined
}

// What an opencode prompt is blocking on. Read by field, the way detailOf
// above reads a tool call: the four `.asked` events spell it four ways
// (`permission`, the v2 `action`, and a question's own short `header` with its
// full text behind it), and a field rule covers the next one too. A type that
// carries none of them still reports `waiting` with no reason, which is the
// documented degrade rather than a bug.
function reasonOf(properties) {
const first = properties?.questions?.[0]
const reason = properties?.permission ?? properties?.action ?? first?.header ?? first?.question
return typeof reason === "string" && reason.trim() !== "" ? reason : undefined
}

// The lich command every tool below shells out to. lich exports it into each
// PTY it spawns, and the contract says to call *that* one rather than whatever
// `lich` resolves to on PATH: a machine running an installed lich beside a
Expand Down Expand Up @@ -345,7 +357,7 @@ export const LichPlugin = async ({ $ } = {}, helper) => {
// next status report, which follows a reply within ~100ms; a missing
// one costs the user the session.
if (event?.type?.endsWith(".asked") && !subSessions.has(properties.sessionID)) {
report("hook", { state: "waiting" })
report("hook", { state: "waiting", reason: reasonOf(properties) })
}
}
},
Expand Down
1 change: 1 addition & 0 deletions tests/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
Loading
Loading