diff --git a/CHANGELOG.md b/CHANGELOG.md index cc9faf6..62ca746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **A lich worker's report arrives the way Claude Code's own background agent's + does.** The asking session shows one dim `● lich session "docs" finished` line + instead of a prompt from the plugin, and the agent still reads the whole + report and carries on with it. The note that a worker is waiting on a + permission arrives the same way. Needs a lich that sends it. + ## [0.15.1] - 2026-10-05 ### Fixed diff --git a/docs/mod-control.md b/docs/mod-control.md index efa47f0..daffc56 100644 --- a/docs/mod-control.md +++ b/docs/mod-control.md @@ -28,7 +28,7 @@ nothing. | command | applied with | ack `ok: true` once | |-----------|--------------------------------------|-----------------------------------------| -| `prompt` | `$.prompt.submit({ text })` | its turn started | +| `prompt` | `$.prompt.submit({ text })`, or with `notification` `$.prompt.submit({ text: , asUser: true })` | its turn started | | `abort` | `$.turn.abort({ turnId })`, the id `turn.start` gave | the turn ended | | `model` | every `turn.step` sent with `model` | the override is set, or dropped when absent | | `effort` | every `turn.step` sent with `effort` | the override is set, or dropped when absent | @@ -65,6 +65,14 @@ nothing. turn and can take over a minute (measured at 109 seconds for a long answer on 2.1.289), so it starts the moment its poll returns and acks whenever it answers, outside the order the other commands keep. Several run at once. +- **A prompt with a `notification` reads like a background agent's + completion.** It is submitted as the user in the shape Claude Code gives its + own: `` with ``, `` and the prompt's text + as ``, no task id. Claude Code shows the summary as one dim line, and + the model reads the text and starts its turn (measured on 2.1.289). Status and + summary are XML-escaped, which Claude Code decodes for the line; the text is + not, because the model reads it as written and an escaped `&&` would reach it + as `&&`. - **The question goes behind a preamble.** It says the question is a side one, that the answer stays out of the conversation, and that tools are unavailable. Without it, a fork made mid-turn reaches for a tool, is refused, and answers diff --git a/hooks/mod-control.js b/hooks/mod-control.js index 3cc77a1..fa837f4 100644 --- a/hooks/mod-control.js +++ b/hooks/mod-control.js @@ -43,8 +43,9 @@ const ANSWER_LIMIT = 16000 /** * @typedef {import('claude-code').EngineInterface} Engine + * @typedef {{ status: string, summary: string }} Notification * @typedef {'low' | 'medium' | 'high' | 'xhigh' | 'max'} Effort - * @typedef {{ id: string, kind: string, text?: string, model?: string, effort?: string, name?: string, args?: string, question?: string }} Command + * @typedef {{ id: string, kind: string, text?: string, notification?: Notification, model?: string, effort?: string, name?: string, args?: string, question?: string }} Command * @typedef {{ * base: string, * token: string, @@ -155,7 +156,10 @@ async function applyAndAck($, state, link, command) { async function apply($, state, command) { switch (command.kind) { case "prompt": { - const submitted = await $.prompt.submit({ text: command.text ?? "" }) + const text = command.text ?? "" + const submitted = await $.prompt.submit( + command.notification ? { text: notificationText(command.notification, text), asUser: true } : { text }, + ) if (submitted.drop !== undefined) throw new Error(submitted.drop) return } @@ -180,6 +184,28 @@ async function apply($, state, command) { } } +/** + * A prompt lich marks as news from outside the session, in the shape Claude + * Code gives its own background agent's completion: it shows `summary` as one + * line and the model reads the whole text. `text` goes in unescaped because the + * model reads it byte for byte, entities included; Claude Code decodes them in + * the line it shows (measured on 2.1.289). + * + * @param {Notification} notification + * @param {string} text + */ +function notificationText(notification, text) { + return ( + `\n${escapeXml(notification.status)}\n` + + `${escapeXml(notification.summary)}\n${text}\n` + ) +} + +/** @param {string} value */ +function escapeXml(value) { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") +} + /** * The session's answer to a side question: a fork of its own conversation, cut * at ANSWER_LIMIT. A fork with no answer throws its reason, which the ack diff --git a/tests/fixtures/mod-commands.json b/tests/fixtures/mod-commands.json index 7df499c..c4c3ed5 100644 --- a/tests/fixtures/mod-commands.json +++ b/tests/fixtures/mod-commands.json @@ -7,5 +7,7 @@ {"id": "m6", "kind": "effort"}, {"id": "m7", "kind": "command", "name": "compact", "args": "keep the test plan"}, {"id": "m8", "kind": "command", "name": "clear"}, - {"id": "m9", "kind": "ask", "question": "what are you working on?"} + {"id": "m9", "kind": "ask", "question": "what are you working on?"}, + {"id": "m10", "kind": "prompt", "text": "[lich] Session \"docs\" finished the task you handed it (ticket t1). Its report:\n\nIf a < b && c > d, see .", "notification": {"status": "completed", "summary": "lich session \"docs\" finished"}}, + {"id": "m11", "kind": "prompt", "text": "[lich] Session \"a expect(world.acks).toEqual([{ session_id: 'lich-1', id: 'm1', kind: 'prompt', ok: true }]) }) +test('a prompt carrying a notification is submitted as the user, in the task-notification shape', async ($, on) => { + mock.env(on, ENV) + const clock = mock.clock(on) + const notification = { status: 'completed', summary: 'lich session "docs" finished' } + const world = lich(on, [{ id: 'm10', kind: 'prompt', text: 'a < b', notification }]) + const submitted: unknown[] = [] + on('prompt.submit', async (_$, e) => { + submitted.push({ text: e.text, origin: e.origin }) + return { text: e.text } + }) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await clock.settle() + expect(submitted).toEqual([ + { + text: + '\ncompleted\nlich session "docs" finished\n' + + 'a < b\n', + origin: { kind: 'plugin', name: 'lich', asUser: true }, + }, + ]) + expect(world.acks).toEqual([{ session_id: 'lich-1', id: 'm10', kind: 'prompt', ok: true }]) +}) + test('an abort from lich ends the turn turn.start named', async ($, on) => { mock.env(on, ENV) const clock = mock.clock(on) diff --git a/tests/mod-control.test.mjs b/tests/mod-control.test.mjs index 8d063d3..e140836 100644 --- a/tests/mod-control.test.mjs +++ b/tests/mod-control.test.mjs @@ -338,6 +338,8 @@ test('a slash command waiting for idle holds back none of the commands after it' // The contract changed here: an `ask` is applied outside the order, at once, // so its fork comes ahead of the commands queued before it, and its ack lands // whenever the fork answers. +// And again: a `prompt` carrying a `notification` is submitted as the user, in +// the task-notification shape, its status and summary escaped and its text not. test('every command shape lich sends is applied in order and acked ok', async () => { const mod = await applied(COMMANDS, { before: (m) => m.turnStart('t1') }) const asked = COMMANDS.find((c) => c.kind === 'ask') @@ -347,6 +349,27 @@ test('every command shape lich sends is applied in order and acked ok', async () ['abort', { turnId: 't1' }], ['command', { command: 'compact', args: 'keep the test plan' }], ['command', { command: 'clear', args: undefined }], + [ + 'prompt', + { + text: + '\ncompleted\nlich session "docs" finished\n' + + '[lich] Session "docs" finished the task you handed it (ticket t1). Its report:\n\n' + + 'If a < b && c > d, see .\n', + asUser: true, + }, + ], + [ + 'prompt', + { + text: + '\nwaiting\n' + + 'lich session "a<b&c" is waiting on a permission prompt\n' + + '[lich] Session "a\n' + + '', + asUser: true, + }, + ], ]) assert.deepEqual( mod.acks().filter((a) => a.kind !== 'ask').map(({ id, kind, ok }) => ({ id, kind, ok })),