From 021e8528c4101bf58a2f4a8a0f50d41f34aaf8eb Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 9 Oct 2026 08:09:24 -0300 Subject: [PATCH] feat(mod): run a built-in slash command the user asks the session for Told to run /compact, the model answered that it could not: the Skill tool refuses a built-in. hooks/self-command.js takes that refusal and queues the command with $.command.run from a timer, so it runs on the session itself once the asking turn ends; the Skill tool's description says so. /model and /effort are refused, since run this way they become every session's default. A tool of the mod's own is not possible: $.tool.register refuses mcp__lich__* in a session whose MCP config has lich's server. --- CHANGELOG.md | 11 +++ CLAUDE.md | 8 +- README.md | 7 +- docs/providers.md | 1 + docs/self-command.md | 70 +++++++++++++++ hooks/lich.js | 2 + hooks/self-command.js | 99 +++++++++++++++++++++ tests/self-command.engine.test.ts | 49 +++++++++++ tests/self-command.test.mjs | 142 ++++++++++++++++++++++++++++++ 9 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 docs/self-command.md create mode 100644 hooks/self-command.js create mode 100644 tests/self-command.engine.test.ts create mode 100644 tests/self-command.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index df684c3..0cd2c66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 1fb6416..9fdb0cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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. @@ -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 diff --git a/README.md b/README.md index e38ee19..bbd66a9 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/docs/providers.md b/docs/providers.md index 1c44561..897b511 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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 | diff --git a/docs/self-command.md b/docs/self-command.md new file mode 100644 index 0000000..2082bbf --- /dev/null +++ b/docs/self-command.md @@ -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____`, 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. diff --git a/hooks/lich.js b/hooks/lich.js index b808bc0..8a4773b 100644 --- a/hooks/lich.js +++ b/hooks/lich.js @@ -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" @@ -20,5 +21,6 @@ export function register(on) { registerWorkerAnswer(on) registerModStatus(on) registerCompacting(on) + registerSelfCommand(on) registerStatusLine(on) } diff --git a/hooks/self-command.js b/hooks/self-command.js new file mode 100644 index 0000000..c049660 --- /dev/null +++ b/hooks/self-command.js @@ -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__`, +// 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.`], + } + }) +} diff --git a/tests/self-command.engine.test.ts b/tests/self-command.engine.test.ts new file mode 100644 index 0000000..952b107 --- /dev/null +++ b/tests/self-command.engine.test.ts @@ -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([]) +}) diff --git a/tests/self-command.test.mjs b/tests/self-command.test.mjs new file mode 100644 index 0000000..87cce7a --- /dev/null +++ b/tests/self-command.test.mjs @@ -0,0 +1,142 @@ +// Pins the Claude Code mod that runs a built-in slash command the model asks +// for through the Skill tool (hooks/self-command.js): which calls it takes, +// what it queues, and what reaches the model. +// +// Like the other mod suites it imports the module and hands it a fake engine: +// `$.command.list` answers a few commands, `$.command.run` records each run, +// and a `$.clock.after` timer runs when the test fires it. +// +// Run: node --test tests/ + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { register } from '../hooks/self-command.js' + +const COMMANDS = [ + { name: 'compact', description: 'Compacts the conversation.', source: 'builtin' }, + { name: 'clear', description: 'Clears the conversation.', source: 'builtin' }, + { name: 'model', description: 'Sets the model.', source: 'builtin' }, + { name: 'effort', description: 'Sets the effort.', source: 'builtin' }, + { name: 'deploy', description: 'A user command.', source: 'user' }, +] + +// What the Skill tool answers for a built-in, as Claude Code 2.1.295 did. +const refusedBy = (name) => { + const text = `${name} is a built-in CLI command, not a skill. Ask the user to run /${name} themselves — it cannot be invoked via the Skill tool.` + return { ref: 1, result: `Error: ${text}`, text: `${text}`, isError: true } +} +const LOADED = { ref: 1, result: { success: true, commandName: 'review' }, text: 'Launching skill: review' } + +/** 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 = {} + register((event, matcher, hook) => { + hooks[`${event} ${JSON.stringify(matcher)}`] = hook + }) + const runs = [] + const toasts = [] + const timers = [] + const invalidated = [] + const $ = { + env: { get: async (name) => env[name] }, + clock: { after: (ms, fn) => timers.push({ ms, fn }) }, + command: { + list: async () => COMMANDS, + run: async (input) => { + runs.push(input) + if (runFails) throw new Error('the command queue is closed') + return {} + }, + }, + ui: { toast: (text) => toasts.push(text), invalidate: (event) => invalidated.push(event) }, + } + const start = hooks['session.start {"isInteractive":true}'] + if (isInteractive) await start($, { cwd: '/w', surface: 'terminal', isInteractive }, async (e) => e) + return { + runs, + toasts, + timers, + invalidated, + describe: (description) => + 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), + fire: async () => { + for (const timer of timers.splice(0)) await timer.fn() + }, + } +} + +test('a built-in the Skill tool refused is queued, and runs only after the hook answered', async () => { + const mod = await session() + const answer = await mod.call({ skill: 'compact', args: ' focus on the tests ' }, refusedBy('compact')) + + assert.deepEqual(answer.result, { success: true, commandName: 'compact' }) + assert.match(answer.context[0], /\/compact focus on the tests is queued and runs once this turn ends/) + assert.deepEqual(mod.runs, [], 'ran inside the hook the turn is waiting on') + await mod.fire() + assert.deepEqual(mod.runs, [{ command: 'compact', args: 'focus on the tests' }]) +}) + +test('a leading slash in the name is dropped', async () => { + const mod = await session() + await mod.call({ skill: '/clear' }, refusedBy('/clear')) + await mod.fire() + assert.deepEqual(mod.runs, [{ command: 'clear', args: '' }]) +}) + +test('/model and /effort are refused and never run: they would save a default for every session', async () => { + const mod = await session() + for (const name of ['model', 'effort']) { + const answer = await mod.call({ skill: name, args: 'opus' }, refusedBy(name)) + assert.match(answer.deny, new RegExp(`/${name} .*default for every new Claude Code session`)) + assert.match(answer.deny, /lich/) + } + await mod.fire() + assert.deepEqual(mod.runs, []) +}) + +test('what the Skill tool served stays its own', async () => { + const mod = await session() + assert.equal(await mod.call({ skill: 'review' }, LOADED), LOADED) + assert.equal(mod.timers.length, 0) +}) + +test('a refusal for a name that is no built-in reaches the model untouched', async () => { + const mod = await session() + for (const name of ['deploy', 'nonexistent']) { + const refused = refusedBy(name) + assert.equal(await mod.call({ skill: name }, refused), refused) + } + assert.equal(mod.timers.length, 0) +}) + +test('a command that fails once queued says so in a toast', async () => { + const mod = await session({ runFails: true }) + await mod.call({ skill: 'compact' }, refusedBy('compact')) + await mod.fire() + assert.deepEqual(mod.toasts, ['/compact did not run: the command queue is closed']) +}) + +test('the Skill description tells the model it can name a built-in, and is redrawn once enabled', async () => { + const mod = await session() + assert.deepEqual(mod.invalidated, ['tool.describe']) + const { description } = await mod.describe('Execute a skill.') + assert.ok(description.startsWith('Execute a skill.')) + assert.match(description, /built-in slash command .* queued and runs once your turn ends/) +}) + +for (const [why, options] of [ + ['outside lich', { env: {} }], + ['in a non-interactive run', { isInteractive: false }], +]) { + test(`${why} the mod changes nothing`, async () => { + const mod = await session(options) + 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(mod.timers.length, 0) + assert.deepEqual(mod.invalidated, []) + }) +}