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

## [Unreleased]

### Fixed

- **A lich worker answers its errand when its `classic.Stop` hook is
bypassed.** On a machine with managed settings, Claude Code's builtin
`cc-plugin-sec-default` sends `classic.Stop` on past user plugins, and the
worker never answered, so the caller waited until the ticket expired. A
session that never saw a `Stop` now answers from `turn.complete`, held back
only while `$.agent.list()` shows a subagent at work; a turn that handed a
background shell, monitor or workflow off answers early
(`docs/mod-answer.md`).

## [0.20.7] - 2026-10-21

### Fixed
Expand Down
19 changes: 19 additions & 0 deletions docs/mod-answer.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ reply instructions, and answers the errand with what this mod posts.
|-------------------------------------------|-------------------------------------------------------------|
| `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"}` |
| `turn.complete{reason=answer}` in a session that never saw a `classic.Stop` | the same two bodies, from the completion's `answer` |

## How it behaves

Expand Down Expand Up @@ -52,6 +53,24 @@ reply instructions, and answers the errand with what this mod posts.
`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. Every one of them drops the `Stop` it read.
- **A worker whose `classic.Stop` is bypassed answers from `turn.complete`.**
Measured on Claude Code 2.1.306: on a machine with managed settings (Team
and Enterprise organizations) the builtin `cc-plugin-sec-default` seats
itself outermost and sends `classic.Stop` on past the plugins a person
installs; the engine marks the mod's hook `skipped` ("bypassed by") and tells
the model, but no mod call says so. The mod infers it from a session that has
not seen a single `classic.Stop`: one main-loop `turn.complete` with reason
`answer` then posts its `answer`, or `unanswered: "blank"` for a blank one.
The bypass follows the settings the session started with, so once a `Stop`
has been seen the Stop rule above holds for the rest of the session.
- **Without a `Stop`, only a subagent can hold the answer back.** There is no
`background_tasks` to read. `$.agent.list()` names the session's subagents
(not its shells, monitors or workflows), and one listed `running` or
`waiting`, or a list that cannot be read, holds the turn's report. A turn
that handed a background shell, monitor or workflow off answers with what it
said when it did, and lich takes the first answer, so the resumed turn's
answer is lost. Pending until the engine exposes the background tasks, or
the bypass, to a mod.
- **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.
Expand Down
62 changes: 59 additions & 3 deletions hooks/worker-answer.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@
// `last_assistant_message` equals the completion's `answer`; an aborted turn
// fires no Stop.
//
// A session that never saw a `classic.Stop` reports from `turn.complete`
// alone. Measured on Claude Code 2.1.306: the builtin cc-plugin-sec-default,
// seated outermost on a machine with managed settings, sends `classic.Stop` on
// past user-installed plugins ("bypassed by", "hook was skipped"), and no mod
// call says so, so the mod infers it from a session with no Stop. That is set
// by the settings the session started with, which is why one Stop is enough to
// keep the Stop rule above for the rest of the session. Without a Stop there
// is no `background_tasks`: the only running work the mod can still see is a
// subagent `$.agent.list()` names running or waiting, which holds the report.
// A background shell, monitor or workflow is not listed there, so a turn that
// handed one off answers early, and lich takes the first answer.
//
// A refused turn and one an API error ended report nothing, though the
// contract has `unanswered: "refusal"` and `"error"` for them. Measured on
// Claude Code 2.1.296, both end through `StopFailure` (a refusal with error
Expand All @@ -47,7 +59,7 @@ const ANSWER_LIMIT = 16000
* @typedef {import('claude-code').EngineInterface} Engine
* @typedef {{ base: string, token: string, session: string }} Link
* @typedef {{ idle: boolean, text: string }} Stop
* @typedef {{ link?: Link, stop?: Stop }} State
* @typedef {{ link?: Link, stop?: Stop, stopSeen?: boolean }} State
* @typedef {{ text: string } | { unanswered: "blank" }} Report
*/

Expand Down Expand Up @@ -101,6 +113,43 @@ function reportOf(stop, e) {
return stop.text === e.answer ? { text: cut(stop.text) } : undefined
}

/**
* What a main-loop turn of a session whose `classic.Stop` never fires reports.
*
* @param {import('claude-code').TurnCompleteInput} e
* @returns {Report | undefined}
*/
function reportWithoutStop(e) {
if (e.reason !== "answer") return undefined
return e.answer.trim() === "" ? { unanswered: "blank" } : { text: cut(e.answer) }
}

/**
* Whether a subagent is still at work. One the list cannot be read for counts
* as at work: the first answer lich hears wins, so an early one is worse than
* none.
*
* @param {Engine} $
*/
async function subagentAtWork($) {
try {
const agents = await $.agent.list()
return agents.some((agent) => agent.status === "running" || agent.status === "waiting")
} catch {
return true
}
}

/**
* @param {Engine} $
* @param {Link} link
* @param {Report} body
*/
async function reportUnlessSubagentAtWork($, link, body) {
if (await subagentAtWork($)) return
await report($, link, body)
}

/**
* 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
Expand Down Expand Up @@ -132,8 +181,14 @@ 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)
if (link === undefined || e.agentId !== undefined) return next(e)
if (state.stopSeen) {
const body = reportOf(stop, e)
if (body !== undefined) void report($, link, body)
return next(e)
}
const body = reportWithoutStop(e)
if (body !== undefined) void reportUnlessSubagentAtWork($, link, body)
return next(e)
}

Expand All @@ -150,6 +205,7 @@ export function register(on) {
})

on("classic.Stop", ($, e, next) => {
state.stopSeen = true
state.stop = { idle: (e.background_tasks ?? []).length === 0, text: e.last_assistant_message ?? "" }
return next(e)
})
Expand Down
11 changes: 11 additions & 0 deletions tests/worker-answer.engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,14 @@ test('a refused turn, which fires no Stop, reports nothing', async ($, on) => {
await clock.settle()
expect(world.answers).toEqual([])
})

test('a worker whose classic.Stop never fires answers from turn.complete', async ($, on) => {
mock.env(on, WORKER)
const clock = mock.clock(on)
const world = lich(on)
on('agent.list', async () => ({ value: [] }))
await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true })
await $.turn.complete(ANSWERED)
await clock.settle()
expect(world.answers).toEqual([{ session_id: 'lich-1', text: 'Rewrote docs/cli.md.' }])
})
80 changes: 79 additions & 1 deletion tests/worker-answer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const settle = () => new Promise((r) => setTimeout(r, 20))
* Registers the module against a fake engine. A hook registered with a
* matcher runs only for an event that matches it, as the engine runs it.
*/
function load({ env = WORKER_ENV, answer = () => Promise.resolve(NO_CONTENT) } = {}) {
function load({ env = WORKER_ENV, answer = () => Promise.resolve(NO_CONTENT), agents = async () => [] } = {}) {
const hooks = new Map()
register((event, ...rest) => {
const hook = rest.pop()
Expand All @@ -51,6 +51,7 @@ function load({ env = WORKER_ENV, answer = () => Promise.resolve(NO_CONTENT) } =
const requests = []
const $ = {
env: { get: async (name) => env[name] },
agent: { list: agents },
http: {
fetch: (url, init = {}) => {
const { pathname, search } = new URL(url)
Expand Down Expand Up @@ -273,3 +274,80 @@ test('the turn is never held up by lich, and a failure is dropped', async () =>
await failing.turn(stop('Done.'), complete('Done.'))
assert.equal(failing.requests.length, 1, 'a failed answer was retried')
})

// Measured on Claude Code 2.1.306: under managed settings the builtin
// cc-plugin-sec-default sends classic.Stop on past user plugins, so a worker
// there sees turn.complete alone.
test('a worker whose Stop is bypassed answers from the completion', async () => {
const mod = load()
await mod.start()

await mod.turn(undefined, complete('Rewrote docs/cli.md.'))

assert.equal(mod.requests.length, 1)
assertContractHonoured('/mod/answer', mod.requests[0])
assert.deepEqual(mod.bodies()[0], { session_id: LICH_SESSION_ID, text: 'Rewrote docs/cli.md.' })
})

test('a blank turn whose Stop is bypassed is reported unanswered', async () => {
const mod = load()
await mod.start()

await mod.turn(undefined, complete(' \n'))

assert.deepEqual(mod.bodies(), [{ session_id: LICH_SESSION_ID, unanswered: 'blank' }])
assertContractHonoured('/mod/answer', mod.requests[0])
})

test("a subagent's turn answers nothing when the Stop is bypassed", async () => {
const mod = load()
await mod.start()

await mod.turn(undefined, { ...complete('SUBDONE'), agentId: 'af8fcd1f2b867337f' })

assert.equal(mod.requests.length, 0)
})

test('a refused, failed or aborted turn whose Stop is bypassed reports nothing', async () => {
const mod = load()
await mod.start()

await mod.turn(undefined, complete('', 'refusal'))
await mod.turn(undefined, complete('', 'error'))
await mod.turn(undefined, complete('half a sentence', 'aborted'))

assert.equal(mod.requests.length, 0)
})

// $.agent.list() as Claude Code 2.1.306 answers it: subagents only, no shells.
test('a bypassed Stop holds the answer while a subagent is still at work', async () => {
let agents = [{ id: 'a1', description: 'scan', type: 'local_agent', status: 'running' }]
const mod = load({ agents: async () => agents })
await mod.start()

await mod.turn(undefined, complete('WAITING'))
assert.equal(mod.requests.length, 0, 'answered while a subagent still ran')

agents = [{ ...agents[0], status: 'completed' }]
await mod.turn(undefined, complete('FINISHED: DONE'))
assert.deepEqual(mod.bodies().map((b) => b.text), ['FINISHED: DONE'])
})

test('a bypassed Stop holds the answer when the subagents cannot be listed', async () => {
const mod = load({ agents: () => Promise.reject(new Error('no session bound')) })
await mod.start()

await mod.turn(undefined, complete('Done.'))

assert.equal(mod.requests.length, 0)
})

test('a session that saw a Stop keeps answering from Stops only', async () => {
const mod = load()
await mod.start()

await mod.turn(stop('Done.'), complete('Done.'))
await mod.turn(undefined, complete('something else'))

assert.deepEqual(mod.bodies().map((b) => b.text), ['Done.'])
})
Loading