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.
The text this mod adds for the agent follows LICH_PROMPT_LANG, the prompt language lich exports into every session (English when unset or unknown; catalog in hooks/prompt-text.js).
| 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 |
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 through list_sessions or lich sessions, which leave it out. Before lich 0.64 lich also answered its
LICH_SESSION_ID with no session named, and told to target itself, a session
measured on 2.1.295 tried that id and gave up. From lich 0.64 every tool that
names a session takes its id too, and lich whoami --json prints the session it
runs in, {label, name, project, kind, state, id} (docs/cli.md in lich).
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).
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.describeonSkillappends 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.describeonmcp__lich__control_sessionappends one too: actioncommandis accepted with this session as the target, named by itsLICH_SESSION_ID, which the paragraph spells out, and it runs once the turn ends.tool.callonmcp__lich__control_sessionlets lich answer first, so a command for another session stays lich's. Only an error answer to actioncommandis looked at, and only when the target is this session: itsLICH_SESSION_ID, or the label or namelich whoami --jsonprints for it, matched without regard to case as lich matches them.lich whoamiruns only for such an error answer, and when it fails only the id counts. The command then goes the way a Skill call's does below,/modeland/effortrefused alike, and the call is answered with a text saying it is queued and to end the turn.tool.callonSkilllets 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 abuiltincommand; 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.runfrom a$.clock.after(0)timer:$.command.runrejects 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.
/modeland/effortare 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-sessionmodelandeffortoverride instead, which another session or lich itself sets.
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.
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.
-
A session named by its label needs lich 0.64 or later. An older lich has no
whoami, so only theLICH_SESSION_IDthe description names is taken as this session, and lich answers that id withno session namedrather than its refusal; the mod queues the command either way. -
The transcript shows the call as
Successfully loaded skill. The answer takes the Skill tool's owninlineshape, 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, Known ceilings):/costwaits 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. -
/compactand/clearstart the session again (SessionStartfires), which runs the mod'ssession.startagain; it only switches it on again.