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 session asked to run /compact on itself through `control_session` now
does.** Asked "run /compact", models mostly reached for lich's
`control_session`, which refuses a session's own, and either gave up or asked
another session to run it on them. The self-command mod now takes that
refusal for action `command` and queues the built-in the way it does for the
Skill tool, `/model` and `/effort` refused alike. The tool's description says
so and names the session by its `LICH_SESSION_ID`, since a session cannot
look its own label up.

## [0.19.1] - 2026-10-09

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p
- [docs/mod-usage.md](docs/mod-usage.md): a Claude Code session reports the context window, rate limits and cost it measured itself, through a mod hooking `session.measure`; Claude Code only
- [docs/mod-answer.md](docs/mod-answer.md): a lich subagent worker answers the task it was handed with its turn's final message, through a mod reading the main loop's `classic.Stop` and `turn.complete`; Claude Code only
- [docs/mod-status.md](docs/mod-status.md): a Claude Code session shows the relay errands it is part of (owed, out, ready) in its prompt footer, through a mod reading `/mod/status`, which never collects; Claude Code only
- [docs/self-command.md](docs/self-command.md): a Claude Code session runs a built-in slash command the user asked it for (`/compact`), queued after the turn, through a mod answering the Skill tool's refusal of a built-in rather than lich's `control`, which refuses a session's own; Claude Code only
- [docs/self-command.md](docs/self-command.md): a Claude Code session runs a built-in slash command the user asked it for (`/compact`), queued after the turn, through a mod answering the Skill tool's refusal of a built-in, and lich's `control_session` refusal of the session's own, by queueing the command itself; Claude Code only
- [docs/edit-guard.md](docs/edit-guard.md): an edit to a file another lich session edited in the last minutes carries a note naming it, through markers in the checkout's git dir 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
37 changes: 36 additions & 1 deletion docs/self-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,26 @@ control itself"), and the mod runs in the session's own process anyway.

## How the model asks

Through the Skill tool, the one it already reaches for. Measured on Claude Code
Through a tool it already reaches for: the Skill tool, or lich's own
`control_session`. Measured on Claude Code
2.1.295, asked to "run /compact", the model answered that it could not because
`/compact` is a CLI command and not a skill; called on `compact`, the Skill tool
refuses with `compact is a built-in CLI command, not a skill. Ask the user to
run /compact themselves — it cannot be invoked via the Skill tool.`

Asked the same with only the Skill tool taken, sessions still went to
`control_session`, whose description says "never your own": one called it on
itself, another asked a peer session to run it on it. Claude Code names that
tool `mcp__lich__control_session` in `tool.call` and `tool.describe`, the call's
input arrives as `{ session, action, value, args }`, and lich refuses a session
named by its own label with an error result whose text is `"<label>" is this
session, and a session cannot control itself: …` (measured on 2.1.295).
`tool.describe` fires for it even while it is deferred behind ToolSearch.

A session cannot look its own label up: `list_sessions` and `lich sessions`
leave it out, and lich answers its `LICH_SESSION_ID` with `no session named`.
Told to target itself, a session measured on 2.1.295 tried that id and gave up.

A tool of the mod's own was the first design and does not work: Claude Code
lists a mod's tool as `mcp__<plugin>__<name>`, this plugin is `lich`, and
`$.tool.register` refuses with `the session already has an MCP server named
Expand All @@ -33,6 +47,17 @@ variables).
- `tool.describe` on `Skill` appends one paragraph to the tool's description:
a built-in slash command can be named there too when the user asks for it,
and it runs once the turn ends.
- `tool.describe` on `mcp__lich__control_session` appends one too: action
`command` is accepted with this session as the target, named by its
`LICH_SESSION_ID`, which the paragraph spells out, and it runs once the turn
ends.
- `tool.call` on `mcp__lich__control_session` lets lich answer first, so a
command for another session stays lich's. Only an error answer to action
`command` is looked at, and only when the target is this session: named by
its `LICH_SESSION_ID`, or lich's refusal says it is this session. The command
then goes the way a Skill call's does below, `/model` and `/effort` refused
alike, and the call is answered with a text saying it is queued and to end
the turn.
- `tool.call` on `Skill` lets the Skill tool run first, so a skill, and a
built-in it serves as a prompt (`/init`, `/review`), stay its own. Only a call
it answered with an error is looked at, and only when `$.command.list()`
Expand All @@ -57,8 +82,18 @@ the MCP config: asked "roda /compact pra mim", the model called
`Skill(compact)`, said the command would run once the turn ended, the turn
ended, and `/compact` then ran and compacted the conversation.

The same, from a new lich session handed "roda um /compact" by another session:
`Skill(compact)`, then a `compact_boundary` in its transcript. Told to use
`control_session` on itself instead, it called it with its `LICH_SESSION_ID`
from the description, got `/compact is queued on this session`, ended the turn,
and compacted.

## Known ceilings

- **The `control_session` path reads lich's refusal by its words.** lich has no
contract for it; should the words change, a session named by its label gets
lich's refusal back unchanged, and the `LICH_SESSION_ID` path still works.

- **The transcript shows the call as `Successfully loaded skill`.** The answer
takes the Skill tool's own `inline` shape, which is what Claude Code draws.
- **A command that opens a dialog holds the command queue until someone closes
Expand Down
103 changes: 76 additions & 27 deletions hooks/self-command.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
// refuses a session controlling itself, so nothing here goes through lich.
// Described in ../docs/self-command.md.
//
// The model asks through the Skill tool, the one it already reaches for: Claude
// Code refuses a built-in there ("compact is a built-in CLI command, not a
// skill"), and this mod answers that refusal by queueing the command. A tool of
// the mod's own is not an option: Claude Code lists it as `mcp__lich__<name>`,
// and `$.tool.register` refuses that in a session whose MCP config already has
// lich's own server under that name, which is every lich session (measured on
// 2.1.295).
// The model asks through a tool it already reaches for, and the mod answers
// that tool's refusal by queueing the command: the Skill tool, which refuses a
// built-in ("compact is a built-in CLI command, not a skill"), and lich's
// control_session, which refuses the session's own ("... is this session, and
// a session cannot control itself"). A tool of the mod's own is not an option:
// Claude Code lists it as `mcp__lich__<name>`, and `$.tool.register` refuses
// that in a session whose MCP config already has lich's own server under that
// name, which is every lich session (measured on 2.1.295).
//
// Every function that takes `$` is declared here at the top level: the loader
// refuses a module that hands `$` to a nested function.
Expand All @@ -22,9 +23,22 @@ const SKILL_NOTE =
"\n\nIn this session a built-in slash command (compact, clear, and the like) can be named here too, " +
"when the user asks you to run it: it is queued and runs once your turn ends, so end your turn right after."

// The name Claude Code gives lich's tool, and the words lich refuses a
// session's own label with (both measured on 2.1.295 with lich's MCP server).
const CONTROL_TOOL = "mcp__lich__control_session"
const CONTROLS_ITSELF = /is this session, and a session cannot control itself/

// A session cannot look its own label up: `list_sessions` and `lich sessions`
// leave it out. The note names it by `LICH_SESSION_ID` instead, which lich
// answers with "no session named" and the mod takes as this session.
/** @param {string} sessionId */
const controlNote = (sessionId) =>
`\n\nIn this session, action command is accepted with this session itself as the target: pass session ` +
`"${sessionId}". The command is queued and runs once your turn ends, so end your turn right after.`

/**
* @typedef {import('claude-code').EngineInterface} Engine
* @typedef {{ isEnabled: boolean }} State
* @typedef {{ sessionId: string | undefined }} State
*/

/**
Expand Down Expand Up @@ -54,46 +68,81 @@ async function isBuiltin($, name) {
return commands.some((command) => command.name === name && command.source === "builtin")
}

/**
* Queues the built-in `name` the model asked for, or refuses it. Undefined
* when `name` is no built-in, so the caller answers with the tool's own reply.
*
* @param {Engine} $
* @param {string} name
* @param {string} args
*/
async function queueBuiltin($, name, args) {
if (SAVES_A_DEFAULT.has(name)) {
return {
deny:
`/${name} run from inside the session would be saved as the default for every new Claude Code ` +
`session. Ask the user to set it from lich, whose ${name} override applies to this session only.`,
}
}
if (!(await isBuiltin($, name))) return undefined
$.clock.after(0, () => runCommand($, name, args))
return `/${[name, args].filter(Boolean).join(" ")}`
}

/** @param {string} name */
const bareName = (name) => name.trim().replace(/^\//, "")

/** @param {import('claude-code').On} on */
export function register(on) {
/** @type {State} */
const state = { isEnabled: false }
const state = { sessionId: undefined }

// Interactive lich sessions only: outside lich the plugin adds nothing, and a
// `claude -p` started from a tool inside a lich session inherits its variables.
on("session.start", { isInteractive: true }, async ($, e, next) => {
if (await $.env.get("LICH_SESSION_ID")) {
state.isEnabled = true
$.ui.invalidate("tool.describe")
}
state.sessionId = await $.env.get("LICH_SESSION_ID")
if (state.sessionId) $.ui.invalidate("tool.describe")
return next(e)
})

on("tool.describe", { tool: "Skill" }, async ($, e, next) => {
const described = await next(e)
if (!state.isEnabled) return described
if (!state.sessionId) return described
return { ...described, description: described.description + SKILL_NOTE }
})

// The Skill tool runs first: a skill, or a built-in it serves as a prompt
// (/init, /review), stays its own. Only what it refused is taken.
on("tool.call", { tool: "Skill" }, async ($, e, next) => {
const native = await next(e)
if (!state.isEnabled || !("isError" in native && native.isError)) return native
const name = e.skill.trim().replace(/^\//, "")
if (SAVES_A_DEFAULT.has(name)) {
return {
deny:
`/${name} run from inside the session would be saved as the default for every new Claude Code ` +
`session. Ask the user to set it from lich, whose ${name} override applies to this session only.`,
}
}
if (!(await isBuiltin($, name))) return native
const args = e.args?.trim() ?? ""
$.clock.after(0, () => runCommand($, name, args))
if (!state.sessionId || !("isError" in native && native.isError)) return native
const name = bareName(e.skill)
const queued = await queueBuiltin($, name, e.args?.trim() ?? "")
if (queued === undefined) return native
if (typeof queued !== "string") return queued
return {
result: { success: true, commandName: name },
context: [`/${[name, args].filter(Boolean).join(" ")} is queued and runs once this turn ends. End the turn now.`],
context: [`${queued} is queued and runs once this turn ends. End the turn now.`],
}
})

on("tool.describe", { tool: CONTROL_TOOL }, async ($, e, next) => {
const described = await next(e)
if (!state.sessionId) return described
return { ...described, description: described.description + controlNote(state.sessionId) }
})

// lich answers first: a command for another session is its own. Only its
// refusal of this session as the target is taken.
on("tool.call", { tool: CONTROL_TOOL }, async ($, e, next) => {
const native = await next(e)
if (!state.sessionId || e.action !== "command" || typeof e.value !== "string") return native
if (!("isError" in native && native.isError)) return native
const targetsItself = e.session === state.sessionId || CONTROLS_ITSELF.test(native.text)
if (!targetsItself) return native
const queued = await queueBuiltin($, bareName(e.value), e.args?.trim() ?? "")
if (queued === undefined) return native
if (typeof queued !== "string") return queued
return { result: `${queued} is queued on this session and runs once this turn ends. End the turn now.` }
})
}
76 changes: 76 additions & 0 deletions tests/self-command.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ const refusedBy = (name) => {
}
const LOADED = { ref: 1, result: { success: true, commandName: 'review' }, text: 'Launching skill: review' }

// What lich's control_session answers, as the lich MCP server did on Claude
// Code 2.1.295: a session targeting itself, and one targeting a peer.
const SELF_REFUSAL = (() => {
const text =
'"fix/x" is this session, and a session cannot control itself: an abort would end the turn asking for it, ' +
'and a prompt or a slash command would only run once that turn is over'
return { ref: 1, result: `Error: ${text}`, text, isError: true }
})()
const NO_SUCH = { ref: 1, result: 'Error: no session named "ghost"', text: 'no session named "ghost"', isError: true }
const DELIVERED = { ref: 1, result: 'ran /compact on "peer"', text: 'ran /compact on "peer"' }

const CONTROL = 'mcp__lich__control_session'

/** Registers the module against a fake engine started with `env`. */
async function session({ env = { LICH_SESSION_ID: 'lich-1' }, isInteractive = true, runFails = false } = {}) {
const hooks = {}
Expand Down Expand Up @@ -62,6 +75,12 @@ async function session({ env = { LICH_SESSION_ID: 'lich-1' }, isInteractive = tr
hooks['tool.describe {"tool":"Skill"}']($, { tool: 'Skill', description }, async (e) => ({ description: e.description })),
call: (input, beneath) =>
hooks['tool.call {"tool":"Skill"}']($, { tool: 'Skill', tool_use_id: 'toolu_1', ...input }, async () => beneath),
describeControl: (description) =>
hooks[`tool.describe {"tool":"${CONTROL}"}`]($, { tool: CONTROL, description }, async (e) => ({
description: e.description,
})),
control: (input, beneath) =>
hooks[`tool.call {"tool":"${CONTROL}"}`]($, { tool: CONTROL, tool_use_id: 'toolu_2', ...input }, async () => beneath),
fire: async () => {
for (const timer of timers.splice(0)) await timer.fn()
},
Expand Down Expand Up @@ -127,6 +146,61 @@ test('the Skill description tells the model it can name a built-in, and is redra
assert.match(description, /built-in slash command .* queued and runs once your turn ends/)
})

test('a built-in lich refused because the target is this session is queued on it', async () => {
const mod = await session()
const answer = await mod.control(
{ session: 'fix/x', action: 'command', value: '/compact', args: ' keep the plan ' },
SELF_REFUSAL,
)

assert.match(answer.result, /\/compact keep the plan is queued on this session and runs once this turn ends/)
assert.equal(answer.isError, undefined)
assert.deepEqual(mod.runs, [], 'ran inside the hook the turn is waiting on')
await mod.fire()
assert.deepEqual(mod.runs, [{ command: 'compact', args: 'keep the plan' }])
})

test('control_session naming this session by its LICH_SESSION_ID is queued on it', async () => {
const mod = await session()
const notFound = { ref: 1, result: 'Error: no session named "lich-1"', text: 'no session named "lich-1"', isError: true }
const answer = await mod.control({ session: 'lich-1', action: 'command', value: 'compact' }, notFound)
assert.match(answer.result, /\/compact is queued on this session/)
await mod.fire()
assert.deepEqual(mod.runs, [{ command: 'compact', args: '' }])
})

test('control_session on this session refuses /model and /effort, and never runs them', async () => {
const mod = await session()
for (const name of ['model', 'effort']) {
const answer = await mod.control({ session: 'fix/x', action: 'command', value: name }, SELF_REFUSAL)
assert.match(answer.deny, new RegExp(`/${name} .*default for every new Claude Code session`))
}
await mod.fire()
assert.deepEqual(mod.runs, [])
})

test('what lich answered for another session, or for no self command, reaches the model untouched', async () => {
const mod = await session()
for (const [input, beneath] of [
[{ session: 'peer', action: 'command', value: 'compact' }, DELIVERED],
[{ session: 'ghost', action: 'command', value: 'compact' }, NO_SUCH],
[{ session: 'fix/x', action: 'abort' }, SELF_REFUSAL],
[{ session: 'fix/x', action: 'prompt', value: 'hi' }, SELF_REFUSAL],
[{ session: 'fix/x', action: 'command', value: 'deploy' }, SELF_REFUSAL],
]) {
assert.equal(await mod.control(input, beneath), beneath)
}
assert.equal(mod.timers.length, 0)
})

test('the control_session description says a command on this session is accepted', async () => {
const mod = await session()
const { description } = await mod.describeControl('Drive another session. Claude Code sessions only, and never your own.')
assert.ok(description.startsWith('Drive another session.'))
assert.match(description, /action command .* this session itself as the target: pass session "lich-1"/)
assert.match(description, /queued and runs once your turn ends/)
})

for (const [why, options] of [
['outside lich', { env: {} }],
['in a non-interactive run', { isInteractive: false }],
Expand All @@ -136,6 +210,8 @@ for (const [why, options] of [
const refused = refusedBy('compact')
assert.equal(await mod.call({ skill: 'compact' }, refused), refused)
assert.deepEqual(await mod.describe('Execute a skill.'), { description: 'Execute a skill.' })
assert.equal(await mod.control({ session: 'fix/x', action: 'command', value: 'compact' }, SELF_REFUSAL), SELF_REFUSAL)
assert.deepEqual(await mod.describeControl('Drive.'), { description: 'Drive.' })
assert.equal(mod.timers.length, 0)
assert.deepEqual(mod.invalidated, [])
})
Expand Down
Loading