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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion docs/mod-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <task-notification>, 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 |
Expand Down Expand Up @@ -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: `<task-notification>` with `<status>`, `<summary>` and the prompt's text
as `<result>`, 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 `&amp;&amp;`.
- **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
Expand Down
30 changes: 28 additions & 2 deletions hooks/mod-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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 (
`<task-notification>\n<status>${escapeXml(notification.status)}</status>\n` +
`<summary>${escapeXml(notification.summary)}</summary>\n<result>${text}</result>\n</task-notification>`
)
}

/** @param {string} value */
function escapeXml(value) {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
}

/**
* 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
Expand Down
4 changes: 3 additions & 1 deletion tests/fixtures/mod-commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 </result>.", "notification": {"status": "completed", "summary": "lich session \"docs\" finished"}},
{"id": "m11", "kind": "prompt", "text": "[lich] Session \"a<b&c\", the subagent you opened, is waiting on a permission prompt in its card.", "notification": {"status": "waiting", "summary": "lich session \"a<b&c\" is waiting on a permission prompt"}}
]
2 changes: 1 addition & 1 deletion tests/lich-ref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.58.0
669d38a521c95688d522e97ad464409908f3f353
23 changes: 23 additions & 0 deletions tests/mod-control.engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ test('a prompt from lich is submitted as the plugin and acked', async ($, on) =>
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:
'<task-notification>\n<status>completed</status>\n<summary>lich session "docs" finished</summary>\n' +
'<result>a < b</result>\n</task-notification>',
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)
Expand Down
23 changes: 23 additions & 0 deletions tests/mod-control.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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:
'<task-notification>\n<status>completed</status>\n<summary>lich session "docs" finished</summary>\n' +
'<result>[lich] Session "docs" finished the task you handed it (ticket t1). Its report:\n\n' +
'If a < b && c > d, see </result>.</result>\n</task-notification>',
asUser: true,
},
],
[
'prompt',
{
text:
'<task-notification>\n<status>waiting</status>\n' +
'<summary>lich session "a&lt;b&amp;c" is waiting on a permission prompt</summary>\n' +
'<result>[lich] Session "a<b&c", the subagent you opened, is waiting on a permission prompt in its card.</result>\n' +
'</task-notification>',
asUser: true,
},
],
])
assert.deepEqual(
mod.acks().filter((a) => a.kind !== 'ask').map(({ id, kind, ok }) => ({ id, kind, ok })),
Expand Down
Loading