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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
why. Esc stops the wait, not the session; its report then arrives as a
[lich] note. Needs Claude Code 2.1.280 or later with mods on (measured on
2.1.289).
- **lich can ask a Claude Code session a side question without stopping it.**
The mod answers an `ask` with a fork of the session's own conversation, which
runs beside the turn, stays out of the conversation and is mostly served from
the prompt cache, and sends the answer back in its ack, cut at 16,000
characters. An ask still answering holds back no other command. lich asks
with `lich ask` or an agent's `ask_session` tool (lich-plugin 0.15 contract).

## [0.14.0] - 2026-10-03

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p
- [docs/session-start.md](docs/session-start.md) — Claude session id via `SessionStart`
- [docs/session-title.md](docs/session-title.md) — the provider's own session title, via `PostToolUse`/`PreInvocation` and `Stop`
- [docs/session-touched.md](docs/session-touched.md) — git-status refresh signal via `PostToolUse` (file-mutating tools only)
- [docs/mod-control.md](docs/mod-control.md): lich drives a Claude Code session (prompt, abort, model, effort, command) through a mod that long-polls for commands and acks each one; Claude Code only, the other harnesses have no mod system
- [docs/mod-control.md](docs/mod-control.md): lich drives a Claude Code session (prompt, abort, model, effort, command) and asks it side questions (ask) through a mod that long-polls for commands and acks each one; Claude Code only, the other harnesses have no mod system
- [docs/agent-cards.md](docs/agent-cards.md): a general-purpose subagent Claude Code starts runs as a lich session, through the `lich open` and `lich wait` CLI rather than an HTTP contract; Claude Code only
- [docs/providers.md](docs/providers.md) — the per-harness map, including what installing on omp takes and what it cannot report

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ On **Claude Code** it carries the other direction too, from lich to the session:
a mod, a module Claude Code runs in its own process, takes the commands given
with `lich control` or an agent's `control_session` tool (start a turn with a
prompt, stop the running turn, override the model or the effort, run a slash
command) and reports how each one went. It needs Claude Code 2.1.280 or later
command), answers a side question asked with `lich ask` or `ask_session` from a
fork of the session's conversation without stopping its turn, and reports how
each one went. It needs Claude Code 2.1.280 or later
with mods turned on; an older one keeps every report above, and `lich control`
refuses its session, naming what to fix.
[docs/mod-control.md](docs/mod-control.md) has what it does and which releases
Expand Down
29 changes: 27 additions & 2 deletions docs/mod-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ this plugin only implements them).
Lets lich drive a Claude Code session, through `lich control` on the command
line or the `control_session` MCP tool an agent calls: start a turn with a
prompt, stop the running turn, override the model or the effort of every
request, and run one of its slash commands. It runs the other way from the
request, and run one of its slash commands; and ask it a side question it
answers without stopping, through `lich ask` or the `ask_session` MCP tool. It
runs the other way from the
hooks: instead of reporting what happened, it holds a long poll open on lich,
applies the commands that come back, and acks each one.

Expand All @@ -31,9 +33,10 @@ nothing.
| `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 |
| `command` | `$.command.run({ command: name, args })` | it ran, once the session was idle |
| `ask` | `$.model.fork({ prompt })`, the question behind a preamble | the fork answered; `answer` carries it |

`GET /mod/commands?token=…&session_id=…` to fetch, `POST /mod/acks` with
`{"session_id", "id", "kind", "ok", "error"?}` to ack, `X-Lich-Plugin` on both.
`{"session_id", "id", "kind", "ok", "error"?, "answer"?}` to ack, `X-Lich-Plugin` on both.

## How it behaves

Expand All @@ -58,6 +61,19 @@ nothing.
turn ended, and behind a prompt it would then cancel the prompt's own turn
instead. A `model` or `effort` sent meanwhile likewise applies to the running
turn's next request.
- **An `ask` waits on nothing and nothing waits on it.** A fork runs beside the
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.
- **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
in a second request: twice the latency and the uncached tokens (measured on
2.1.289).
- **An answer is cut at 16,000 UTF-16 units**, with `\n[truncated]` after it,
which keeps the ack under lich's 64 KiB body limit. A fork that does not
answer acks `ok: false` with its reason: `nothing-to-fork`,
`api-error <status> <kind>`, `empty-reply` or `aborted`.
- **Every other ack is awaited before the next command.** That is the
contract's rule for an `abort`: lich ends the turn it has open when the ack
lands, so a late one would end the turn the next `prompt` opened. An ack that
Expand Down Expand Up @@ -114,3 +130,12 @@ the trust prompt is answered, the module after.
- **`/model` and `/effort` are refused by lich, not by the mod.** Run through a
mod, Claude Code saves what they set as the default for every new session;
the `model` and `effort` commands are the per-session route.
- **A fork sees the conversation as of the session's last finished model
response.** A reply being written, a tool call running, or a prompt whose
first reply is still being written is not in it. Measured on 2.1.289.
- **A fork cannot be cancelled, and survives an Esc on the turn.** It takes no
signal; measured on 2.1.289, it answered in full after the turn it ran beside
was interrupted. A module reload is what kills one, and then nothing is acked:
lich's wait runs out.
- **A fork's tokens are not in the transcript**, so nothing that reads cost
from it counts an `ask`.
47 changes: 43 additions & 4 deletions hooks/mod-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,24 @@ const MAX_BACKOFF_MS = 10000
// the hook, so a level outside these is refused here, where lich hears of it.
const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"])

// Put before an `ask`'s question. Without it, a fork made while a turn runs
// reaches for a tool, is refused (a fork has none) and answers in a second
// request, at twice the latency and the uncached tokens: measured on Claude
// Code 2.1.289.
const ASK_PREAMBLE =
"This is a side question asked from outside your turn, while you work. It does not " +
"interrupt your turn and your answer is not added to the conversation. Tools are " +
"unavailable: do not call any tool. Answer from what the conversation already holds, " +
"in plain text, briefly. Question: "

// The contract cuts an answer at this many UTF-16 units, which keeps an ack
// under lich's 64 KiB body limit; a fork has no length bound of its own.
const ANSWER_LIMIT = 16000

/**
* @typedef {import('claude-code').EngineInterface} Engine
* @typedef {'low' | 'medium' | 'high' | 'xhigh' | 'max'} Effort
* @typedef {{ id: string, kind: string, text?: string, model?: string, effort?: string, name?: string, args?: string }} Command
* @typedef {{ id: string, kind: string, text?: string, model?: string, effort?: string, name?: string, args?: string, question?: string }} Command
* @typedef {{
* base: string,
* token: string,
Expand Down Expand Up @@ -84,6 +98,12 @@ async function poll($, state, link) {
}
link.backoffMs = 0
for (const command of commands) {
// An answer can take a minute and changes nothing the other commands
// depend on, so it is not queued behind them, nor they behind it.
if (command.kind === "ask") {
void applyAndAck($, state, link, command)
continue
}
link.applying = link.applying.then(() => {
const acked = applyAndAck($, state, link, command)
// A prompt or a slash command settles only once the session is idle and
Expand All @@ -108,11 +128,10 @@ async function poll($, state, link) {
* @param {Command} command
*/
async function applyAndAck($, state, link, command) {
/** @type {{ ok: boolean, error?: string }} */
/** @type {{ ok: boolean, error?: string, answer?: string }} */
let outcome
try {
await apply($, state, command)
outcome = { ok: true }
outcome = { ok: true, ...(await apply($, state, command)) }
} catch (error) {
outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }
}
Expand All @@ -131,6 +150,7 @@ async function applyAndAck($, state, link, command) {
* @param {Engine} $
* @param {State} state
* @param {Command} command
* @returns {Promise<{ answer: string } | undefined>} what the ack carries beyond `ok`
*/
async function apply($, state, command) {
switch (command.kind) {
Expand All @@ -153,11 +173,30 @@ async function apply($, state, command) {
case "command":
await $.command.run({ command: command.name ?? "", args: command.args })
return
case "ask":
return { answer: await answer($, command.question ?? "") }
default:
throw new Error("unknown kind")
}
}

/**
* 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
* carries as its error.
*
* @param {Engine} $
* @param {string} question
*/
async function answer($, question) {
const reply = await $.model.fork({ prompt: ASK_PREAMBLE + question })
if (!reply.isAnswered) {
throw new Error(reply.reason === "api-error" ? `api-error ${reply.status} ${reply.error}` : reply.reason)
}
if (reply.text.length <= ANSWER_LIMIT) return reply.text
return `${reply.text.slice(0, ANSWER_LIMIT)}\n[truncated]`
}

/** @param {import('claude-code').On} on */
export function register(on) {
/** @type {State} */
Expand Down
2 changes: 2 additions & 0 deletions tests/contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export const REJECT_RULES = {
'missing kind': (b) => !('kind' in b),
'unknown kind': (b) => 'kind' in b && !MOD_KINDS.has(b.kind),
'ok is not a bool': (b) => 'ok' in b && typeof b.ok !== 'boolean',
'answer on another kind': (b) => 'answer' in b && b.kind !== 'ask',
'answer on a failed ask': (b) => 'answer' in b && b.kind === 'ask' && b.ok !== true,
'malformed json': (b, raw) => !isJsonObject(raw),
},
}
Expand Down
3 changes: 2 additions & 1 deletion tests/fixtures/mod-commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
{"id": "m5", "kind": "effort", "effort": "high"},
{"id": "m6", "kind": "effort"},
{"id": "m7", "kind": "command", "name": "compact", "args": "keep the test plan"},
{"id": "m8", "kind": "command", "name": "clear"}
{"id": "m8", "kind": "command", "name": "clear"},
{"id": "m9", "kind": "ask", "question": "what are you working on?"}
]
6 changes: 5 additions & 1 deletion tests/fixtures/mod-control.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
{"name":"long error is clamped","body":{"session_id":"s1","id":"m5","kind":"command","ok":false,"error":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},"accept":{"error":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}}
{"name":"id from a previous process","body":{"session_id":"s1","id":"m999","kind":"prompt","ok":true},"accept":{"id":"m999"}}
{"name":"extra fields are ignored","body":{"session_id":"s1","id":"m6","kind":"abort","ok":true,"turn_id":"t"},"accept":{"id":"m6"}}
{"name":"answered ask carries its answer","body":{"session_id":"s1","id":"m9","kind":"ask","ok":true,"answer":"Fixing the login test."},"accept":{"ok":true,"answer":"Fixing the login test."}}
{"name":"unanswered ask carries why","body":{"session_id":"s1","id":"m9","kind":"ask","ok":false,"error":"nothing-to-fork"},"accept":{"ok":false,"error":"nothing-to-fork"}}
{"name":"missing session_id","body":{"id":"m1","kind":"abort","ok":true},"reject":"session_id is required"}
{"name":"missing id","body":{"session_id":"s1","kind":"abort","ok":true},"reject":"id is required"}
{"name":"missing kind","body":{"session_id":"s1","id":"m1","ok":true},"reject":"kind is required"}
{"name":"unknown kind","body":{"session_id":"s1","id":"m1","kind":"type","ok":true},"reject":"kind must be prompt, abort, model, effort or command"}
{"name":"unknown kind","body":{"session_id":"s1","id":"m1","kind":"type","ok":true},"reject":"kind must be prompt, abort, model, effort, command or ask"}
{"name":"answer on another kind","body":{"session_id":"s1","id":"m1","kind":"prompt","ok":true,"answer":"x"},"reject":"only an answered ask carries an answer"}
{"name":"answer on a failed ask","body":{"session_id":"s1","id":"m9","kind":"ask","ok":false,"error":"aborted","answer":"x"},"reject":"only an answered ask carries an answer"}
{"name":"ok is not a bool","body":{"session_id":"s1","id":"m1","kind":"abort","ok":"true"},"reject":"ok must be a boolean"}
{"name":"malformed json","raw":"{","reject":"body must be a JSON object"}
44 changes: 43 additions & 1 deletion tests/mod-control.engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// hooks/lich.js from hooks/hooks.json the way a session does, which registers
// hooks/mod-control.js, and runs these against it, the test's hooks standing
// for lich (`http.fetch`) and for the engine's bottom (`prompt.submit`,
// `turn.abort`, `turn.step`, `command.run`).
// `turn.abort`, `turn.step`, `command.run`, `model.fork`).
//
// tests/mod-control.test.mjs is the suite CI runs, against the contract
// fixtures; this one needs a claude binary, and proves the module loads and its
Expand Down Expand Up @@ -144,3 +144,45 @@ test('an abort reaches the turn while a slash command never settles', async ($,
expect(aborted).toEqual(['t1'])
expect(world.acks).toEqual([{ session_id: 'lich-1', id: 'm2', kind: 'abort', ok: true }])
})

test('an ask from lich is answered by a fork and the answer rides the ack', async ($, on) => {
mock.env(on, ENV)
const clock = mock.clock(on)
const world = lich(on, [{ id: 'm9', kind: 'ask', question: 'what are you on?' }])
const asked: string[] = []
on('model.fork', async (_$, e) => {
asked.push(e.prompt)
return {
value: {
isAnswered: true,
text: 'Fixing the login test.',
usage: { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
},
}
})
await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true })
await clock.settle()
expect(asked.length).toBe(1)
expect(asked[0].endsWith('Question: what are you on?')).toBe(true)
expect(world.acks).toEqual([
{ session_id: 'lich-1', id: 'm9', kind: 'ask', ok: true, answer: 'Fixing the login test.' },
])
})

test('an abort reaches the turn while an ask never answers', async ($, on) => {
mock.env(on, ENV)
const clock = mock.clock(on)
const world = lich(on, [{ id: 'm9', kind: 'ask', question: 'why?' }], [{ id: 'm2', kind: 'abort' }])
const aborted: string[] = []
on('turn.start', async (_$, e) => ({ turnId: e.turnId }))
on('model.fork', () => new Promise<never>(() => {}))
on('turn.abort', async (_$, e) => {
aborted.push(e.turnId)
return { value: undefined }
})
await $.turn.start({ text: 'go', turnId: 't1' })
await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true })
await clock.settle()
expect(aborted).toEqual(['t1'])
expect(world.acks).toEqual([{ session_id: 'lich-1', id: 'm2', kind: 'abort', ok: true }])
})
Loading
Loading