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

## [Unreleased]

### Added

- **A lich subagent worker whose turn has nothing to answer now says so.** A
final turn that ends blank or refused, with nothing left running in the
background, posts `unanswered` (`blank` or `refusal`) to `/mod/answer`,
so the caller hears the errand ended instead of waiting on it. A turn an API
error ended still reports nothing: Claude Code's `StopFailure` carries no
`background_tasks`, so the mod cannot tell it left nothing running. Needs a
lich that accepts `unanswered`.
- **Every state report names the conversation it comes from**, in
`provider_session_id`, so lich can drop one from an agent CLI the session
runs as a tool (`claude -p`, `cursor-agent -p`), which inherits the session's
`LICH_*` variables and used to report as the card. The scripts, opencode, omp
and the compacting mod all send it; one the harness did not hand over is left
out. `report-state.sh` now reads stdin for every state, with a one-second
bound so a pipe nobody writes to cannot hold the turn.

## [0.19.2] - 2026-10-09

### Fixed
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ hooks/codex-hooks.json # hook registration, Codex
hooks/crush-hooks.json # hook registration, Crush (hand-merged into crush.json)
hooks/ # hook scripts (all four run them; only how they are addressed differs)
hooks/detail.jq # what a tool call acts on, shared by two of those scripts
hooks/conversation-id.sh # the conversation a payload comes from, sourced by three of them
hooks/win-run.cmd # runs one of those scripts on Windows (Codex)
hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the mods below
hooks/mod-control.js # Claude Code mod: lich drives the session
Expand Down
29 changes: 22 additions & 7 deletions docs/mod-answer.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ reply instructions, and answers the errand with what this mod posts.
| event | sent |
|-------------------------------------------|-------------------------------------------------------------|
| `classic.Stop`, then `turn.complete{reason=answer}` | `POST /mod/answer` with `{"session_id", "text"}` |
| `classic.Stop` with a blank message, then `turn.complete{reason=answer}` | `POST /mod/answer` with `{"session_id", "unanswered": "blank"}` |
| `classic.Stop`, then `turn.complete{reason=refusal}` | `POST /mod/answer` with `{"session_id", "unanswered": "refusal"}` |

## How it behaves

Expand All @@ -33,18 +35,31 @@ reply instructions, and answers the errand with what this mod posts.
subagent, a monitor, a workflow); Claude Code resumes in a new turn when that
work finishes, and that turn's `Stop`, with nothing listed, is the one that
answers.
- **An aborted, failed or refused turn does not answer**, and neither does one
whose final message is blank. `turn.complete` is matched on
`reason: "answer"`, which also keeps the hook beside mod-control's own
- **A turn with nothing to answer says so.** One whose final message is blank
posts `unanswered: "blank"`, a refused one `unanswered: "refusal"`, so the
caller hears the errand ended instead of waiting on it. Both follow the rule
above: only when the turn's own `classic.Stop` lists nothing running. A
refused turn with no `Stop` of its own reports nothing.
- **An aborted turn does not report**: its user took the worker over, and the
turn they start next is the one that answers.
- **A failed turn does not report either**, though the contract has
`unanswered: "error"` for it. An API error fires `StopFailure` instead of
`Stop`, and read off the Claude Code 2.1.296 bundle neither `StopFailure`'s
input (`error`, `error_details`, `last_assistant_message`) nor
`turn.complete`'s carries `background_tasks`, and no mod engine call lists
them. Nothing tells whether the failed turn left work running, so `error` is
pending until Claude Code exposes that list on the failure.
- `turn.complete` is matched once per `reason` (`answer`, `refusal`,
`error`, `aborted`), which keeps the hooks beside mod-control's own
`turn.complete` hook: Claude Code refuses one plugin's second hook on an event
with no matcher.
with no matcher. Every one of them drops the `Stop` it read.
- **A subagent's turn inside the worker does not answer.** `classic.Stop` fires
on the main loop only, and a `turn.complete` that carries an `agentId` is
skipped.
- **A Stop answers only for its own turn.** The text is posted when the
completion's `answer` equals the Stop's `last_assistant_message`, and the
Stop's verdict is dropped once a completion read it: an aborted turn fires no
Stop, and must not inherit the last one.
completion's `answer` equals the Stop's `last_assistant_message`, a blank
Stop only beside a blank `answer`, and the Stop is dropped once a completion
read it: an aborted turn fires no Stop, and must not inherit the last one.
- **The text is cut at 16,000 UTF-16 units** and marked `[truncated]`, as an
`ask`'s answer is.
- **The turn is never held up.** The answer is posted beside the chain; one
Expand Down
14 changes: 14 additions & 0 deletions docs/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ every turn passes through it, including the ones no `input` precedes. **Crush is
absent from the table on purpose**: its only event is `PreToolUse`, and a `busy`
nothing can end would pin a spinner to a card. See [providers.md](providers.md).

**Every report names the conversation it comes from** in
`provider_session_id`, the id [session-start](session-start.md) reports: an
agent CLI the session runs as a tool (`claude -p`, `cursor-agent -p`) inherits
the `LICH_*` variables, and lich drops a report naming any conversation but the
one bound to the card (Nested agent CLIs in the contract). The scripts read it
off the stdin payload (`.conversationId // .session_id`, with
report-session-start.sh's `sed` fallback when jq is absent), opencode off the
event's `sessionID`, omp off `ctx.sessionManager.getSessionId()`, and
`mod-compacting.js` off `$.session.id()`. An id the harness did not hand over
is left out, and lich takes the report as before. Because `busy`, `done` and
`idle` now read stdin too, `report-state.sh` reads it with the one-second bound
curl has: a harness that left the pipe open and unwritten still cannot hold the
turn.

**omp reports no `waiting`.** It raises `tool_approval_requested` when it asks to
run something, but that event was never observed on a real run here, and a name
taken off a type declaration is a report that silently never fires. An omp card
Expand Down
13 changes: 13 additions & 0 deletions hooks/conversation-id.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Sourced by the report scripts, not run: conversation_id reads a hook payload on
# stdin and prints the provider's id for the conversation that fired it, or
# nothing. Antigravity names it `conversationId`, every other harness
# `session_id`. Prefers jq; falls back to sed so it works on Windows, where jq is
# usually absent but sed (Git Bash) is not.
conversation_id() {
if command -v jq >/dev/null 2>&1; then
jq -r '.conversationId // .session_id // empty' 2>/dev/null
else
sed -n -e 's/.*"conversationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
-e 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
fi
}
7 changes: 6 additions & 1 deletion hooks/mod-compacting.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,21 @@ async function linkFromEnv($) {
}

/**
* Posts one state, naming the conversation it comes from (`$.session.id()`, the
* id SessionStart hands lich) so lich can tell it from an agent CLI nested in
* the session (docs/session-state.md, Nested agent CLIs).
*
* @param {Engine} $
* @param {Link} link
* @param {'compacting' | Closing} sessionState
*/
async function report($, link, sessionState) {
try {
const conversation = await $.session.id()
await $.http.fetch(`${link.base}/hook?token=${link.token}`, {
method: "POST",
headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION },
body: JSON.stringify({ session_id: link.session, state: sessionState }),
body: JSON.stringify({ session_id: link.session, state: sessionState, provider_session_id: conversation }),
})
} catch {
// The contract drops a report that cannot be sent; it never retries one.
Expand Down
17 changes: 11 additions & 6 deletions hooks/report-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@
# Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release).
plugin_version=0.19.2
provider=${1:-claude}
# Parse session_id from the stdin payload. Prefer jq; fall back to sed so the
# hook works on Windows, where jq is usually absent but sed (Git Bash) is not.
if command -v jq >/dev/null 2>&1; then
provider_session_id=$(jq -r '.conversationId // .session_id // empty' 2>/dev/null)

# A file-shipped install that lacks conversation-id.sh says so and still
# runs: sourcing a missing file would end the script. `${0%/*}` rather than
# dirname: it needs no tool on PATH.
if [ -r "${0%/*}/conversation-id.sh" ]; then
. "${0%/*}/conversation-id.sh"
else
provider_session_id=$(sed -n -e 's/.*"conversationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
-e 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
echo "lich-plugin: hooks/conversation-id.sh missing; session-start not reported" >&2
conversation_id() { :; }
fi

payload=$(cat)
provider_session_id=$(printf '%s' "$payload" | conversation_id)
[ -n "$provider_session_id" ] || exit 0

# Values are ids (UUID / lich-controlled / this file's own literal), no JSON
Expand Down
31 changes: 28 additions & 3 deletions hooks/report-state.sh
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
#!/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.
# stdin: the hook payload — read for the conversation id, and on `waiting` 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

# Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release).
plugin_version=0.19.2

# A file-shipped install that lacks conversation-id.sh says so and still
# runs: sourcing a missing file would end the script. `${0%/*}` rather than
# dirname: it needs no tool on PATH.
if [ -r "${0%/*}/conversation-id.sh" ]; then
. "${0%/*}/conversation-id.sh"
else
echo "lich-plugin: hooks/conversation-id.sh missing; reporting without provider_session_id" >&2
conversation_id() { :; }
fi

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

# Read with the bound curl has below: `busy`, `done` and `idle` ride events
# whose payload only names the conversation, and a harness that left the pipe
# open and unwritten must not hold the turn. The reader's input is fd 3 because
# an asynchronous command's stdin is /dev/null unless redirected, and the timer's
# output goes nowhere so the substitution never waits on it.
exec 3<&0
payload=$(cat <&3 & reader=$!; (exec >/dev/null 2>&1; sleep 1 && kill "$reader") & wait "$reader")
exec 3<&-

# `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
# lich drops it on every other state, so no other branch looks for one. 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
Expand Down Expand Up @@ -53,6 +72,12 @@ if [ "$state" = waiting ]; then
fi
fi

# The conversation that fired the hook, so lich can drop a report from an agent
# CLI nested in the session (docs/session-state.md, Nested agent CLIs). An id
# needs no escaping, and none is left out rather than sent empty.
provider_session_id=$(printf '%s' "$payload" | conversation_id)
[ -n "$provider_session_id" ] && body="${body%\}},\"provider_session_id\":\"${provider_session_id}\"}"

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' \
Expand Down
17 changes: 17 additions & 0 deletions hooks/report-tool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
plugin_version=0.19.2

here=$(dirname "$0")

# A file-shipped install that lacks conversation-id.sh says so and still
# runs: sourcing a missing file would end the script. `${0%/*}` rather than
# dirname: it needs no tool on PATH.
if [ -r "${0%/*}/conversation-id.sh" ]; then
. "${0%/*}/conversation-id.sh"
else
echo "lich-plugin: hooks/conversation-id.sh missing; reporting without provider_session_id" >&2
conversation_id() { :; }
fi

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

Expand Down Expand Up @@ -46,6 +57,12 @@ else
body="{\"session_id\":\"${LICH_SESSION_ID}\",\"state\":\"busy\",\"tool\":\"${tool}\"}"
fi

# The conversation that fired the hook, so lich can drop a report from an agent
# CLI nested in the session (docs/session-state.md, Nested agent CLIs). An id
# needs no escaping, and none is left out rather than sent empty.
provider_session_id=$(printf '%s' "$payload" | conversation_id)
[ -n "$provider_session_id" ] && body="${body%\}},\"provider_session_id\":\"${provider_session_id}\"}"

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' \
Expand Down
91 changes: 64 additions & 27 deletions hooks/worker-answer.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@
// cards off for also carries; lich ignores the report from a session with no
// subagent errand open, so that one costs a request per turn and nothing else.
//
// A turn is the worker's answer when its main-loop `classic.Stop` lists no
// background task (a shell, subagent, monitor or workflow still running means
// the turn handed work to the background, and the turn Claude Code resumes in
// once it finishes is the one that answers) and its `turn.complete` ended with
// an answer that is not blank. Measured on Claude Code 2.1.289: `classic.Stop`
// fires on the main loop only, before `turn.complete`, and its
// `last_assistant_message` equals the completion's `answer`; an aborted turn
// fires no Stop.
// A main-loop turn reports only when its `classic.Stop` lists no background
// task (a shell, subagent, monitor or workflow still running means the turn
// handed work to the background, and the turn Claude Code resumes in once it
// finishes is the one that reports). Its `turn.complete` then says what: an
// answer that is not blank is the worker's answer, a blank one is
// `unanswered: "blank"`, a refusal `unanswered: "refusal"`. Measured on
// Claude Code 2.1.289: `classic.Stop` fires on the main loop only, before
// `turn.complete`, and its `last_assistant_message` equals the completion's
// `answer`; an aborted turn fires no Stop.
//
// A turn an API error ended reports nothing, though the contract has
// `unanswered: "error"` for it: such a turn fires `StopFailure` instead of
// `Stop`, and read off the Claude Code 2.1.296 bundle neither `StopFailure`'s
// input nor `turn.complete`'s carries `background_tasks`, so nothing tells
// whether the failed turn left work running.
//
// Every function that takes `$` is declared here at the top level: the loader
// refuses a module that hands `$` to a nested function.
Expand All @@ -37,7 +44,9 @@ const ANSWER_LIMIT = 16000
/**
* @typedef {import('claude-code').EngineInterface} Engine
* @typedef {{ base: string, token: string, session: string }} Link
* @typedef {{ link?: Link, idleText?: string }} State
* @typedef {{ idle: boolean, text: string }} Stop
* @typedef {{ link?: Link, stop?: Stop }} State
* @typedef {{ text: string } | { unanswered: "blank" | "refusal" }} Report
*/

/**
Expand Down Expand Up @@ -75,26 +84,58 @@ function cut(text) {
}

/**
* Posts one answer beside the chain. One that cannot be sent is dropped: the
* What a finished main-loop turn reports, if anything. The Stop must be the
* turn's own: an answer's text equals the completion's, a blank Stop comes
* with a blank completion.
*
* @param {Stop | undefined} stop the turn's main-loop `classic.Stop`
* @param {import('claude-code').TurnCompleteInput} e
* @returns {Report | undefined}
*/
function reportOf(stop, e) {
if (stop === undefined || !stop.idle) return undefined
if (e.reason === "refusal") return { unanswered: "refusal" }
if (e.reason !== "answer") return undefined
if (stop.text.trim() === "") return e.answer.trim() === "" ? { unanswered: "blank" } : undefined
return stop.text === e.answer ? { text: cut(stop.text) } : undefined
}

/**
* Posts one report beside the chain. One that cannot be sent is dropped: the
* worker's card still holds it, and a second copy of a report is worse than
* none.
*
* @param {Engine} $
* @param {Link} link
* @param {string} text
* @param {Report} body
*/
async function report($, link, text) {
async function report($, link, body) {
try {
await $.http.fetch(`${link.base}/mod/answer?token=${link.token}`, {
method: "POST",
headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION },
body: JSON.stringify({ session_id: link.session, text: cut(text) }),
body: JSON.stringify({ session_id: link.session, ...body }),
})
} catch {
// The contract's client rule: a report that cannot be sent is dropped.
}
}

/**
* @param {Engine} $
* @param {State} state
* @param {import('claude-code').TurnCompleteInput} e
* @param {(e: import('claude-code').TurnCompleteInput) => unknown} next
*/
function settleTurn($, state, e, next) {
const stop = state.stop
state.stop = undefined
const link = state.link
const body = link !== undefined && e.agentId === undefined ? reportOf(stop, e) : undefined
if (link !== undefined && body !== undefined) void report($, link, body)
return next(e)
}

/** @param {import('claude-code').On} on */
export function register(on) {
/** @type {State} */
Expand All @@ -108,21 +149,17 @@ export function register(on) {
})

on("classic.Stop", ($, e, next) => {
const idle = (e.background_tasks ?? []).length === 0
const text = e.last_assistant_message ?? ""
state.idleText = idle && text.trim() !== "" ? text : undefined
state.stop = { idle: (e.background_tasks ?? []).length === 0, text: e.last_assistant_message ?? "" }
return next(e)
})

// The matcher keeps this beside mod-control.js's own turn.complete hook, which
// has none: Claude Code refuses one plugin's second hook on an event with no
// matcher. An aborted, failed or refused turn never matches.
on("turn.complete", { reason: "answer" }, ($, e, next) => {
const text = state.idleText
state.idleText = undefined
const link = state.link
const isWorkersAnswer = link !== undefined && e.agentId === undefined && !e.isAborted && text === e.answer
if (isWorkersAnswer && text !== undefined) void report($, link, text)
return next(e)
})
// One hook per reason a turn ends with: the matchers keep them beside
// mod-control.js's own turn.complete hook, which has none, and Claude Code
// refuses one plugin's second hook on an event with no matcher. The aborted
// and failed ones report nothing; they are here so every turn drops the Stop
// it read, and a later turn never inherits it.
on("turn.complete", { reason: "answer" }, ($, e, next) => settleTurn($, state, e, next))
on("turn.complete", { reason: "refusal" }, ($, e, next) => settleTurn($, state, e, next))
on("turn.complete", { reason: "error" }, ($, e, next) => settleTurn($, state, e, next))
on("turn.complete", { reason: "aborted" }, ($, e, next) => settleTurn($, state, e, next))
}
Loading
Loading