Skip to content

feat: say what a waiting session is waiting for - #20

Merged
omartelo merged 2 commits into
mainfrom
feat/waiting-reason
Aug 23, 2026
Merged

omartelo merged 2 commits into
mainfrom
feat/waiting-reason

Conversation

@omartelo

@omartelo omartelo commented Aug 23, 2026 •

Copy link
Copy Markdown
Collaborator

Client side of the session-state contract change in lich: the waiting report now carries a reason, so the bell on a card says why the session is blocked rather than only that it is.

reason is optional, free text, trimmed and capped by lich at 120 runes, and rides waiting alone — lich drops it on every other state. Nothing else in the contract moved; tool / detail are untouched.

Where each harness's reason comes from

Measured against what is installed here, not read off documentation.

harness source
Claude Code the Notification payload's message
Codex tool_name, qualified with the same detail a busy report carries
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 — both go out unchanged, and lich still decides which arrived.

Codex's PermissionRequest has no message field at all, so the report is the tool it is asking about with the words a busy report already puts on the card behind it: apply_patch: internal/terminal/usage.go, Bash: go test ./....

opencode's four .asked events spell it four ways, so lich.js reads it by field the way it already reads a tool call. The generic suffix rule is untouched: a prompt type nobody enumerated still reports waiting with no reason, which is the documented degrade.

One refactor

report-tool.sh's inline detail jq moves to hooks/detail.jq, now read by both scripts. Codex's reason is that same reading of a tool call, and one copy is what stops the two from drifting.

Client rules, kept

  • A reason it cannot build is never a reason to drop the report: an absent one, a blank one, and a payload with nothing to read all still ring the bell.
  • Only waiting reads stdin. A hook blocked on a pipe nobody writes to would hold the turn it must never block.
  • Without jq the message stays home — arbitrary text a hand-built body cannot escape, the same rule the detail already followed. Codex's tool name is an identifier and goes out alone, so Windows keeps a reason.
  • Silent, exit 0. On Codex PermissionRequest an exit 2 would deny the request.

Second commit: omp is a registered provider now

Separate from the feature, and reviewable on its own. fixtures-drift was already failing on main for an unrelated reason: lich has registered omp as a provider id and the vendored session-start.jsonl was a case behind. Refreshed — which retires the workaround it had forced, where omp's session-start assertion skipped the contract's own "unregistered provider" rule. That skip was written to fold back into the shared assertion the day the case landed; this is that day. Drop the commit if you would rather have it as its own PR.

Test plan

  • node --test tests/*.test.mjs — 169 pass
  • The four new accept fixtures vendored and asserted against
  • Per-registration reason assertions, per harness; every other state asserted to send none
  • Three "no reason to give" payloads still ring the bell; two no-jq cases; an opencode blank-reason case
  • busy / done / idle verified not to read stdin
  • Existing detail coverage (apply_patch, escaping, no-jq) passes unchanged after the move
  • hook contract tests green in CI
  • fixtures-drift fails on exactly the four reason lines, and nothing else — it diffs tests/fixtures against lich@main, which has not merged the contract change yet. This merges after lich does, and goes green on its own then.

Two things worth flagging

Two of these were caught by the new tests rather than by review: a whitespace message went out as " ", and a non-string one put { on the card. Both are guarded now.

hooks/detail.jq is the first non-script file the hooks read at run time, resolved via dirname "$0" — which is how win-run.cmd passes it too.

The `waiting` report now carries a `reason`, so the bell on a lich card says
why the session is blocked rather than only that it is. Client side of the
contract change that landed in lich; the field is optional there, trimmed and
capped, and belongs to `waiting` alone.

Each harness hands over something different to say it with, and none of them
was taken off documentation:

  - Claude Code's `Notification` message is already 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 — both go out unchanged, and lich
    still decides which arrived.
  - Codex's `PermissionRequest` has no message field at all, so the report is
    the tool it is asking about with the same words a busy report puts on the
    card behind it: `apply_patch: internal/terminal/usage.go`. That second half
    moves into hooks/detail.jq, shared with report-tool.sh so the two readings
    of one tool call cannot drift.
  - opencode's four `.asked` events spell it four ways, so lich.js reads it by
    field the way it already reads a tool call: `permission`, the v2 `action`,
    then a question's own short `header` with its full text behind it. The
    generic suffix rule stays — a prompt type nobody enumerated still reports
    `waiting` with no reason, which is the documented degrade.

omp and Crush report no `waiting` at all, so neither gains anything here.

A reason it cannot build is never a reason to drop the report: an absent one,
a blank one and a payload with nothing to read all still ring the bell. Without
jq the message stays home, as the detail already did, but Codex's tool name is
an identifier and goes out alone.
The vendored session-start fixtures were a case behind lich, which has since
registered `omp` as a provider id. Refreshed, so `fixtures-drift` stops failing
on a copy nobody refetched.

That retires the workaround it forced: omp's session-start assertion skipped the
contract's own "unregistered provider" rule, because the rule fired on an
otherwise correct payload. It was written to fold back into the shared assertion
the day the case landed, and this is that day — so it does, and the imports it
needed go with it.
@omartelo
omartelo merged commit 8e489dd into main Aug 23, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant