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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
so and names the session by its `LICH_SESSION_ID`. A session named by its
label is told apart by `lich whoami --json` (lich 0.64), not by the words of
lich's refusal.

## [0.19.1] - 2026-10-09

Expand Down
22 changes: 14 additions & 8 deletions docs/self-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ 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 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
Expand All @@ -53,8 +56,10 @@ variables).
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
`command` is looked at, and only when the target is this session: its
`LICH_SESSION_ID`, or the label or name `lich whoami --json` prints for it,
matched without regard to case as lich matches them. `lich whoami` runs 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, `/model` and `/effort` refused
alike, and the call is answered with a text saying it is queued and to end
the turn.
Expand Down Expand Up @@ -90,9 +95,10 @@ 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.
- **A session named by its label needs lich 0.64 or later.** An older lich has
no `whoami`, so only the `LICH_SESSION_ID` the description names is taken as
this session, and lich answers that id with `no session named` rather 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 own `inline` shape, which is what Claude Code draws.
Expand Down
36 changes: 27 additions & 9 deletions hooks/self-command.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// 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:
// a session cannot control itself"), told apart by `lich whoami`. 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).
Expand All @@ -23,14 +23,14 @@ 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).
// The name Claude Code gives lich's tool (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.
const WHOAMI_TIMEOUT_MS = 2_000

// `list_sessions` leaves this session out, so the model does not know its own
// label; the note names it by `LICH_SESSION_ID`, which lich takes as a target too.
/** @param {string} sessionId */
const controlNote = (sessionId) =>
`\n\nIn this session, action command is accepted with this session itself as the target: pass session ` +
Expand Down Expand Up @@ -89,6 +89,25 @@ async function queueBuiltin($, name, args) {
return `/${[name, args].filter(Boolean).join(" ")}`
}

/**
* Whether `target` names this session: its id, or the label or name
* `lich whoami` prints for it, which lich matches without regard to case.
*
* @param {Engine} $
* @param {string} target
* @param {string} sessionId
*/
async function isThisSession($, target, sessionId) {
if (target === sessionId) return true
const lich = await $.env.get("LICH_BIN")
if (!lich) return false
const { exitCode, stdout } = await $.process.run([lich, "whoami", "--json"], { timeoutMs: WHOAMI_TIMEOUT_MS })
if (exitCode !== 0) return false
const self = JSON.parse(stdout)
const wanted = target.toLowerCase()
return [self.label, self.name].some((name) => typeof name === "string" && name.toLowerCase() === wanted)
}

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

Expand Down Expand Up @@ -138,8 +157,7 @@ export function register(on) {
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
if (!(await isThisSession($, e.session, state.sessionId))) return native
const queued = await queueBuiltin($, bareName(e.value), e.args?.trim() ?? "")
if (queued === undefined) return native
if (typeof queued !== "string") return queued
Expand Down
53 changes: 46 additions & 7 deletions tests/self-command.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
//
// 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.
// `$.process.run` answers `lich whoami --json` with this session, and a
// `$.clock.after` timer runs when the test fires it.
//
// Run: node --test tests/

Expand Down Expand Up @@ -41,8 +42,16 @@ const DELIVERED = { ref: 1, result: 'ran /compact on "peer"', text: 'ran /compac

const CONTROL = 'mcp__lich__control_session'

// This session as `lich whoami --json` prints it (docs/cli.md in lich).
const WHOAMI = { label: 'fix/x', name: 'lich-plugin-a1b2', project: 'lich-plugin', kind: 'claude', state: 'busy', id: 'lich-1' }

/** Registers the module against a fake engine started with `env`. */
async function session({ env = { LICH_SESSION_ID: 'lich-1' }, isInteractive = true, runFails = false } = {}) {
async function session({
env = { LICH_SESSION_ID: 'lich-1', LICH_BIN: '/bin/lich' },
isInteractive = true,
runFails = false,
whoami = { exitCode: 0, stdout: JSON.stringify(WHOAMI) },
} = {}) {
const hooks = {}
register((event, matcher, hook) => {
hooks[`${event} ${JSON.stringify(matcher)}`] = hook
Expand All @@ -51,6 +60,7 @@ async function session({ env = { LICH_SESSION_ID: 'lich-1' }, isInteractive = tr
const toasts = []
const timers = []
const invalidated = []
const processes = []
const $ = {
env: { get: async (name) => env[name] },
clock: { after: (ms, fn) => timers.push({ ms, fn }) },
Expand All @@ -63,6 +73,13 @@ async function session({ env = { LICH_SESSION_ID: 'lich-1' }, isInteractive = tr
},
},
ui: { toast: (text) => toasts.push(text), invalidate: (event) => invalidated.push(event) },
process: {
run: async (argv) => {
processes.push(argv)
assert.deepEqual(argv, ['/bin/lich', 'whoami', '--json'])
return { stderr: '', ...whoami }
},
},
}
const start = hooks['session.start {"isInteractive":true}']
if (isInteractive) await start($, { cwd: '/w', surface: 'terminal', isInteractive }, async (e) => e)
Expand All @@ -71,6 +88,7 @@ async function session({ env = { LICH_SESSION_ID: 'lich-1' }, isInteractive = tr
toasts,
timers,
invalidated,
processes,
describe: (description) =>
hooks['tool.describe {"tool":"Skill"}']($, { tool: 'Skill', description }, async (e) => ({ description: e.description })),
call: (input, beneath) =>
Expand Down Expand Up @@ -160,13 +178,34 @@ test('a built-in lich refused because the target is this session is queued on it
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 () => {
test('control_session naming this session by its id, label or name, in any case, is queued on it', async () => {
for (const target of ['lich-1', 'fix/x', 'FIX/X', 'lich-plugin-a1b2']) {
const mod = await session()
const answer = await mod.control({ session: target, action: 'command', value: 'compact' }, SELF_REFUSAL)
assert.match(answer.result, /\/compact is queued on this session/, target)
await mod.fire()
assert.deepEqual(mod.runs, [{ command: 'compact', args: '' }], target)
}
})

test('lich refusing a target that whoami does not name as this session reaches the model untouched', 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.equal(await mod.control({ session: 'other', action: 'command', value: 'compact' }, SELF_REFUSAL), SELF_REFUSAL)
assert.equal(mod.timers.length, 0)
})

test('when whoami fails, only the session id is taken as this session', async () => {
const mod = await session({ whoami: { exitCode: 1, stdout: '' } })
assert.equal(await mod.control({ session: 'fix/x', action: 'command', value: 'compact' }, SELF_REFUSAL), SELF_REFUSAL)
const answer = await mod.control({ session: 'lich-1', action: 'command', value: 'compact' }, SELF_REFUSAL)
assert.match(answer.result, /\/compact is queued on this session/)
await mod.fire()
assert.deepEqual(mod.runs, [{ command: 'compact', args: '' }])
})

test('whoami runs only for a refused command, never for a delivered one', async () => {
const mod = await session()
await mod.control({ session: 'peer', action: 'command', value: 'compact' }, DELIVERED)
await mod.control({ session: 'fix/x', action: 'abort' }, SELF_REFUSAL)
assert.deepEqual(mod.processes, [])
})

test('control_session on this session refuses /model and /effort, and never runs them', async () => {
Expand Down
Loading