From b1d61f2d57c3c035d4c4117ebe40a097d1adbdcf Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 21 Oct 2026 14:50:31 -0300 Subject: [PATCH] fix: answer a worker's errand when its classic.Stop is bypassed On a machine with managed settings, Claude Code's builtin cc-plugin-sec-default sends classic.Stop on past user-installed plugins, so worker-answer never read a Stop and the worker never answered. A session that has not seen a Stop now reports from turn.complete, held back while $.agent.list() shows a subagent at work. --- CHANGELOG.md | 11 ++++ docs/mod-answer.md | 19 +++++++ hooks/worker-answer.js | 62 +++++++++++++++++++++-- tests/worker-answer.engine.test.ts | 11 ++++ tests/worker-answer.test.mjs | 80 +++++++++++++++++++++++++++++- 5 files changed, 179 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30116c3..3e43e12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/mod-answer.md b/docs/mod-answer.md index 10f718a..6bb665e 100644 --- a/docs/mod-answer.md +++ b/docs/mod-answer.md @@ -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 @@ -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. diff --git a/hooks/worker-answer.js b/hooks/worker-answer.js index 21929b1..4dcd009 100644 --- a/hooks/worker-answer.js +++ b/hooks/worker-answer.js @@ -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 @@ -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 */ @@ -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 @@ -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) } @@ -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) }) diff --git a/tests/worker-answer.engine.test.ts b/tests/worker-answer.engine.test.ts index a41d956..c178761 100644 --- a/tests/worker-answer.engine.test.ts +++ b/tests/worker-answer.engine.test.ts @@ -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.' }]) +}) diff --git a/tests/worker-answer.test.mjs b/tests/worker-answer.test.mjs index 897bfc4..8022c21 100644 --- a/tests/worker-answer.test.mjs +++ b/tests/worker-answer.test.mjs @@ -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() @@ -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) @@ -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.']) +})