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]

### Added

- **A Claude Code session runs a built-in slash command when asked to.** Told
"run /compact", the model used to answer that it could not. A new mod,
`hooks/self-command.js`, takes the Skill tool's refusal of a built-in command
and queues the command with `$.command.run`, so it runs on the session itself
once the turn that asked ends; the Skill tool's description says so. Nothing
goes through lich, whose `control` refuses a session's own. `/model` and
`/effort` are refused, since run this way Claude Code saves them as the
default for every new session.

## [0.19.0] - 2026-10-08

### Added
Expand Down
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ hooks/edit-guard.js # Claude Code mod: an edit says which other li
hooks/worker-answer.js # Claude Code mod: a lich subagent worker's last message answers its task
hooks/mod-status.js # Claude Code mod: the session's relay errands in the prompt footer
hooks/mod-compacting.js # Claude Code mod: the card says when the conversation is being compacted
hooks/self-command.js # Claude Code mod: the session runs a built-in slash command the user asked for
hooks/status-line.js # the lich line in the prompt footer, shared by the mods above
opencode/lich.js # opencode client: a module, not a command
omp/lich.js # omp client: a module, not a command
Expand Down Expand Up @@ -66,6 +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/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 Expand Up @@ -116,6 +118,9 @@ one lich line in the prompt footer.
mod the same kind of fake `$` and asserts the `lich open` argv
it runs, the Agent result it answers each outcome with, and that a call it does
not take, or one lich cannot take, reaches the native agent untouched.
[tests/self-command.test.mjs](tests/self-command.test.mjs) does the same for
the self-command mod: which Skill calls it takes, that the command runs only
after the call is answered, and that `/model` and `/effort` never run.
[tests/contract.mjs](tests/contract.mjs) is the fixtures, the assertions and the
stub, shared so every client answers to the same lines. Node only, no
dependencies.
Expand All @@ -124,7 +129,8 @@ dependencies.
[tests/mod-usage.engine.test.ts](tests/mod-usage.engine.test.ts) and
[tests/mod-status.engine.test.ts](tests/mod-status.engine.test.ts) and
[tests/worker-answer.engine.test.ts](tests/worker-answer.engine.test.ts) and
[tests/edit-guard.engine.test.ts](tests/edit-guard.engine.test.ts) run the
[tests/edit-guard.engine.test.ts](tests/edit-guard.engine.test.ts) and
[tests/self-command.engine.test.ts](tests/self-command.engine.test.ts) run the
mods under Claude Code's own engine (`claude plugin test .`); they need a
`claude` binary, so CI does not run them. The kit does not check a `tool.call`
result against the tool's output schema (measured on 2.1.289), so whether
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ on its own as a `[lich]` note: the worker's last message is its report, as a nat
rules and what happens when lich cannot be reached. Since that subagent shares
the checkout, an edit to a file another lich session edited in the last ten
minutes tells the model which session it was and how to reach it
([docs/edit-guard.md](docs/edit-guard.md)).
([docs/edit-guard.md](docs/edit-guard.md)). Asked to run a built-in slash command
such as `/compact`, the session runs it on itself once its turn ends
([docs/self-command.md](docs/self-command.md)).

It also ships skills for the parts of lich you configure from inside a session:

Expand All @@ -62,13 +64,14 @@ hooks/report-tool.sh # session-state hook: the tool a turn is runni
hooks/report-session-start.sh # session-start hook
hooks/report-title.sh # session-title hook
hooks/report-touched.sh # session-touched hook
hooks/lich.js # the Claude Code mods' entry, which registers the six below
hooks/lich.js # the Claude Code mods' entry, which registers the seven below
hooks/mod-control.js # mod-control client, a Claude Code mod
hooks/agent-cards.js # a general-purpose subagent as a lich session, a Claude Code mod
hooks/mod-usage.js # mod-usage client, a Claude Code mod
hooks/edit-guard.js # names the other lich session that edited a file, a Claude Code mod
hooks/worker-answer.js # mod-answer client: a lich worker's last message answers its task, a Claude Code mod
hooks/mod-status.js # mod-status client: the session's errands in the prompt footer, a Claude Code mod
hooks/self-command.js # runs a built-in slash command the user asked the session for, a Claude Code mod
opencode/lich.js # opencode client: all four reports plus the seven tools, one module
omp/lich.js # omp client: the reports, one module
docs/ # client-side docs, one per contract
Expand Down
1 change: 1 addition & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ This repository is one plugin, packaged for each harness it supports:
| `hooks/edit-guard.js` | Claude Code | names the other lich session that edited a file, a mod |
| `hooks/worker-answer.js` | Claude Code | the mod-answer client: a worker's last message answers its task, a mod |
| `hooks/mod-compacting.js` | Claude Code | the session-state `compacting` report, a mod |
| `hooks/self-command.js` | Claude Code | runs a built-in slash command the user asked the session for, a mod |
| `opencode/lich.js` | opencode | the whole client, as a module |
| `omp/lich.js` | omp | the whole client, as a module |
| `skills/` | all | skills, same layout |
Expand Down
70 changes: 70 additions & 0 deletions docs/self-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Mod: self-command

Lets a Claude Code session inside lich run one of its own built-in slash
commands when the user asks for it ("run /compact"). There is no lich contract
behind it and nothing is sent to lich: lich's `control` refuses a session
controlling itself on purpose (`docs/ceilings.md` in lich, "A session cannot
control itself"), and the mod runs in the session's own process anyway.

| client | Claude Code | Codex | Antigravity | opencode | omp | Crush |
|-------------------------|---------------------------------------------------------|---------------------|---------------------|---------------------|---------------------|---------------------|
| `hooks/self-command.js` | mod, registered by `hooks/lich.js`, the `modules` entry | none: no mod system | none: no mod system | none: no mod system | none: no mod system | none: no mod system |

## How the model asks

Through the Skill tool, the one it already reaches for. 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.`

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
"lich" in its MCP config; registering would replace it`, which is every lich
session (measured on 2.1.295).

## How it behaves

It does nothing unless `LICH_SESSION_ID` is set and the session is interactive
(a `claude -p` started from a tool inside a lich session inherits the
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.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()`
names it as a `builtin` command; anything else gets the Skill tool's own
answer back.
- That call is answered as a loaded skill (`{ success: true, commandName }`),
with a note telling the model the command is queued and to end its turn, and
the command goes to `$.command.run` from a `$.clock.after(0)` timer:
`$.command.run` rejects inside a hook the turn is waiting on. It queues the
command, which runs once the session is idle, after the turn that asked.
- A command that fails once queued has no turn left to answer, so its error is
a toast.
- `/model` and `/effort` are refused, and never run: run through a mod, Claude
Code saves what they set as the default for every new session (measured on
2.1.288 and 2.1.289). The refusal points at lich's per-session `model` and
`effort` override instead, which another session or lich itself sets.

## Measured

Claude Code 2.1.295, the plugin loaded with `--plugin-dir`, lich's MCP server in
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.

## Known ceilings

- **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
it**, as with lich's `command` ([mod-control.md](mod-control.md), Known
ceilings): `/cost` waits for an Esc in the session's terminal. Since the user
asked for it, they are at that terminal; nothing tells such a command apart in
`$.command.list()`, so none is refused.
- **`/compact` and `/clear` start the session again** (`SessionStart` fires),
which runs the mod's `session.start` again; it only switches it on again.
2 changes: 2 additions & 0 deletions hooks/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { register as registerEditGuard } from "./edit-guard.js"
import { register as registerModControl } from "./mod-control.js"
import { register as registerModStatus } from "./mod-status.js"
import { register as registerModUsage } from "./mod-usage.js"
import { register as registerSelfCommand } from "./self-command.js"
import { register as registerStatusLine } from "./status-line.js"
import { register as registerWorkerAnswer } from "./worker-answer.js"

Expand All @@ -20,5 +21,6 @@ export function register(on) {
registerWorkerAnswer(on)
registerModStatus(on)
registerCompacting(on)
registerSelfCommand(on)
registerStatusLine(on)
}
99 changes: 99 additions & 0 deletions hooks/self-command.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Lets a Claude Code session run one of its own built-in slash commands when the
// person asks for one ("run /compact"). Plugin side only: lich's `control`
// 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).
//
// Every function that takes `$` is declared here at the top level: the loader
// refuses a module that hands `$` to a nested function.

// Run through a mod, these save what they set as the default for every new
// session (measured on Claude Code 2.1.288 and 2.1.289).
const SAVES_A_DEFAULT = new Set(["model", "effort"])

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."

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

/**
* Queues `/name args` for when the session is idle. `$.command.run` rejects
* inside a hook the turn is waiting on, so it is called from a timer; a
* command that then fails has no turn left to answer, so its error goes to a
* toast.
*
* @param {Engine} $
* @param {string} name
* @param {string} args
*/
async function runCommand($, name, args) {
try {
await $.command.run({ command: name, args })
} catch (error) {
$.ui.toast(`/${name} did not run: ${error instanceof Error ? error.message : String(error)}`)
}
}

/**
* @param {Engine} $
* @param {string} name
*/
async function isBuiltin($, name) {
const commands = await $.command.list()
return commands.some((command) => command.name === name && command.source === "builtin")
}

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

// 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")
}
return next(e)
})

on("tool.describe", { tool: "Skill" }, async ($, e, next) => {
const described = await next(e)
if (!state.isEnabled) 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))
return {
result: { success: true, commandName: name },
context: [`/${[name, args].filter(Boolean).join(" ")} is queued and runs once this turn ends. End the turn now.`],
}
})
}
49 changes: 49 additions & 0 deletions tests/self-command.engine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// The self-command mod under Claude Code's own engine: `claude plugin test .`
// loads hooks/lich.js, which registers hooks/self-command.js. The test's hooks
// stand for the Skill tool's own refusal of a built-in (the `tool.call` beneath
// the mod) and for the command's run (`command.run`).
//
// tests/self-command.test.mjs is the suite CI runs; this one proves the module
// loads beside the other mods and that the command it queues reaches
// `command.run` as the plugin, once the call has been answered.

import { expect, mock, test } from 'claude-code/testing'
import type { On } from 'claude-code'

const ENV = { LICH_SESSION_ID: 'lich-1' }
const REFUSAL = 'compact is a built-in CLI command, not a skill.'

function world(on: On) {
const ran: unknown[] = []
mock.env(on, ENV)
const clock = mock.clock(on)
on('session.start', async (_$, e) => ({ cwd: e.cwd }))
// The kit has no command list of its own ("no implementation for
// command.list", measured on 2.1.295): this hook stands for the session's.
on('command.list', async () => ({ value: [{ name: 'compact', description: '', source: 'builtin' }] }))
on('tool.call', { tool: 'Skill' }, async () => ({ result: `Error: ${REFUSAL}`, isError: true as const }))
on('command.run', async (_$, e) => {
ran.push({ command: e.command, args: e.args, origin: e.origin })
return { text: '' }
})
return { ran, clock }
}

test('a built-in the Skill tool refused runs as the plugin after the call is answered', async ($, on) => {
const w = world(on)
await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true })
const answer = await $.tool.call({ tool: 'Skill', skill: 'compact', args: 'keep the plan' })
expect(answer.result).toEqual({ success: true, commandName: 'compact' })
expect(w.ran).toEqual([])
await w.clock.settle()
expect(w.ran).toEqual([{ command: 'compact', args: 'keep the plan', origin: { kind: 'plugin', name: 'lich' } }])
})

test('a non-interactive run leaves the refusal alone', async ($, on) => {
const w = world(on)
await $.session.start({ cwd: '/w', surface: null, isInteractive: false })
const answer = await $.tool.call({ tool: 'Skill', skill: 'compact' })
expect(answer.isError).toBe(true)
await w.clock.settle()
expect(w.ran).toEqual([])
})
Loading
Loading