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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **The edit guard names the other session by its card's label.** A note that a
file was also edited by another lich session read that session's id, which
meant nothing to the model or to you; it now reads the label on its card, the
name `lich send` reaches it by, and falls back to the id only when lich cannot
say which session it was. Needs lich 0.60.0, whose `lich sessions --json` lists
session ids.

## [0.16.0] - 2026-10-05

### Changed
Expand Down
15 changes: 12 additions & 3 deletions docs/edit-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,19 @@ unless `LICH_SESSION_ID` is set.
4. When the call succeeded, it writes its own marker over that one.
5. When the marker named a different lich session and is under 10 minutes old,
the result goes back with one more `context` line, which the model reads
after the tool's result: the path, the session id, the time and how long
after the tool's result: the path, the session, the time and how long
ago, and that `send_to_session` or `lich send` reaches that session. A
refused call carries the line too, since it explains the refusal.

The line names the session by the label on its card, the name the user and
`lich send` know it by. lich puts no label in a session's environment and no
command tells a session its own, so the marker keeps the id and the label is
looked up when a line is about to be added, never on an edit that gets none:
`$LICH_BIN sessions --json`, two seconds at most, matched on its `id` field
(lich 0.60.0 and later). When the CLI is missing or fails, the session is
gone, or the lich is older and lists no ids, the line names the id
instead.

Ten minutes spans the turn in which the other session made its edit; past that
the work is likely finished and the file as read is the news.

Expand All @@ -65,8 +74,8 @@ guard were not there.
- It speaks only after the edit. The `context` of a `tool.call` result is read
with the tool's result; there is no slot to warn before the call runs. The
refusals above are what keep the edit safe.
- The marker names the lich session by id. lich gives a session no label in its
environment, and the guard sends nothing to lich to ask for one.
- A lich older than 0.60.0 lists no session ids in `lich sessions --json`, so
there the line names the session by id.
- One marker per file, the last editor's: two other sessions editing the same
file leave only the newer one's name.
- A file in a folder that does not exist yet is outside any repository to git,
Expand Down
56 changes: 52 additions & 4 deletions hooks/edit-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
const RECENT_MS = 10 * 60_000
const MARKER_DIR = "lich-edits"
const GIT_TIMEOUT_MS = 5_000
// The edit's result waits on it, so it is cut well short of the git timeout.
const LICH_TIMEOUT_MS = 2_000

/**
* @typedef {import('claude-code').EngineInterface} Engine
* @typedef {{ path: string, session: string, at: number }} Marker
* @typedef {{ label: string, id?: string }} Peer
*/

/** FNV-1a, 32 bits: a file name for a path; the marker keeps the path itself. */
Expand Down Expand Up @@ -71,19 +74,64 @@ async function readMarker($, markerPath, path) {
* @param {Marker | undefined} marker
* @param {string} session
* @param {number} now
* @returns {marker is Marker}
*/
function noteFor(marker, session, now) {
if (!marker || marker.session === session || now - marker.at > RECENT_MS) return undefined
function isNews(marker, session, now) {
return !!marker && marker.session !== session && now - marker.at <= RECENT_MS
}

/**
* The label on the card of the session `id`, or undefined when lich cannot say.
*
* A lich older than the `id` field in `lich sessions --json` lists none, and
* the session goes unnamed there.
*
* @param {Engine} $
* @param {string} id
*/
async function labelOf($, id) {
const lich = await $.env.get("LICH_BIN")
if (!lich) return undefined
const { exitCode, stdout } = await $.process.run([lich, "sessions", "--json"], { timeoutMs: LICH_TIMEOUT_MS })
if (exitCode !== 0) return undefined
/** @type {Peer[]} */
const peers = JSON.parse(stdout)
return peers.find((peer) => peer.id === id)?.label
}

/**
* @param {Marker} marker
* @param {string} who
* @param {number} now
*/
function noteFor(marker, who, now) {
const minutes = Math.round((now - marker.at) / 60_000)
const ago = minutes === 0 ? "under a minute ago" : `${minutes} min ago`
return (
`${marker.path} was also edited by the lich session ${marker.session} at ` +
`${marker.path} was also edited by the lich session ${who} at ` +
`${new Date(marker.at).toISOString()} (${ago}), which shares this checkout and may still be ` +
`working on it. Re-read the file before building on it, and coordinate with that session through ` +
`send_to_session or lich send rather than undoing its change.`
)
}

/**
* The note for `marker`, naming its session by label when lich can say which.
*
* @param {Engine} $
* @param {Marker} marker
* @param {number} now
*/
async function noteNaming($, marker, now) {
let label
try {
label = await labelOf($, marker.session)
} catch (error) {
logFailure($, error)
}
return noteFor(marker, label ? `"${label}"` : marker.session, now)
}

/** @param {Engine} $ @param {unknown} error */
function logFailure($, error) {
$.ui.log(`lich edit guard: ${error instanceof Error ? error.message : String(error)}`)
Expand Down Expand Up @@ -115,7 +163,7 @@ async function guard($, e, next) {
let note
try {
const now = await $.clock.now()
note = noteFor(previous, session, now)
if (isNews(previous, session, now)) note = await noteNaming($, previous, now)
if (!result.isError) await $.fs.write(markerPath, JSON.stringify({ path, session, at: now }))
} catch (error) {
logFailure($, error)
Expand Down
26 changes: 20 additions & 6 deletions tests/edit-guard.engine.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// The edit guard under Claude Code's own engine: `claude plugin test .` loads
// hooks/lich.js from hooks/hooks.json, which registers hooks/edit-guard.js.
// The test's hooks stand for git (`process.run`), the checkout's git dir
// The test's hooks stand for git and the lich CLI (`process.run`), the checkout's git dir
// (`fs.*`) and the Edit tool itself (the `tool.call` beneath the mod).
//
// tests/edit-guard.test.mjs is the suite CI runs; this one proves the module
Expand All @@ -13,17 +13,20 @@ import type { On } from 'claude-code'
const GIT_DIR = '/w/.git'
const FILE = '/w/src/app.js'
const T0 = Date.parse('2026-10-05T12:00:00.000Z')
const LICH = '/usr/bin/lich'
const B_ID = '7c0e93aa-0b1d-4f52-a8e1-2d9f6c4b3e10'
const EDIT = { tool: 'Edit', tool_use_id: 'toolu_1', file_path: FILE, old_string: 'a', new_string: 'b' } as const

/** A checkout another lich session (`lich-a`) edited one minute before T0. */
function world(on: On, session: string | undefined) {
function world(on: On, session: string | undefined, roster?: object[]) {
const disk = new Map<string, string>()
const edited: string[] = []
mock.env(on, session ? { LICH_SESSION_ID: session } : {})
mock.env(on, session ? { LICH_SESSION_ID: session, ...(roster ? { LICH_BIN: LICH } : {}) } : {})
mock.clock(on, { now: T0 })
on('process.run', async () => ({
value: { exitCode: 0, stdout: `${GIT_DIR}\n`, stderr: '', isStdoutTruncated: false, isStderrTruncated: false },
}))
on('process.run', async (_$, e) => {
const stdout = e.argv[0] === LICH ? `${JSON.stringify(roster)}\n` : `${GIT_DIR}\n`
return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
})
on('fs.exists', async (_$, e) => ({ value: disk.has(e.path) }))
on('fs.read', async (_$, e) => ({ value: disk.get(e.path) ?? '' }))
on('fs.write', async (_$, e) => {
Expand Down Expand Up @@ -55,6 +58,17 @@ test('an edit to a file another lich session just edited goes ahead with a note'
expect(JSON.parse(markerOf(w.disk)![1]).session).toBe('lich-a')
})

test('the note names the other session by the label on its card', async ($, on) => {
const w = world(on, 'lich-a', [{ label: 'quiet-comet', name: 'repo-7c0e', project: 'repo', kind: 'claude', state: 'busy', id: '7c0e93aa-0b1d-4f52-a8e1-2d9f6c4b3e10' }])
await $.tool.call(EDIT)
const [path] = markerOf(w.disk) ?? []
w.disk.set(path!, JSON.stringify({ path: FILE, session: B_ID, at: T0 - 60_000 }))

const ran = await $.tool.call(EDIT)

expect(ran.context?.[0]).toContain('lich session "quiet-comet" at')
})

test('outside lich the edit runs untouched and nothing is recorded', async ($, on) => {
const w = world(on, undefined)
const ran = await $.tool.call(EDIT)
Expand Down
90 changes: 86 additions & 4 deletions tests/edit-guard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//
// Like the other mod suites it imports the module and hands it a fake engine:
// `$.fs` is an in-memory map, `$.process.run` answers `git rev-parse` for one
// repository, `$.clock.now` is a number the test moves.
// repository and `lich sessions --json` with the roster a test hands it,
// `$.clock.now` is a number the test moves.
//
// Run: node --test tests/

Expand All @@ -20,6 +21,12 @@ const MINUTE = 60_000
const T0 = Date.parse('2026-10-05T12:00:00.000Z')

const EDITED = { ref: 1, result: { filePath: FILE }, text: 'The file has been updated successfully.' }
const LICH = '/usr/bin/lich'
const A_ID = '1fd224be-45cd-4322-9cb9-157b15cdd3cc'
const B_ID = '7c0e93aa-0b1d-4f52-a8e1-2d9f6c4b3e10'
const A_PEER = { label: 'quiet-comet', name: 'repo-1fd2', project: 'repo', kind: 'claude', state: 'busy', id: A_ID }
const OTHER_PEER = { label: 'docs', name: 'repo-9f8e', project: 'repo', kind: 'codex', state: 'done', id: B_ID }

const STALE = {
ref: 2,
isError: true,
Expand All @@ -31,20 +38,35 @@ const STALE = {
* One Claude Code session with the guard loaded. Sessions built over the same
* `disk` share a checkout. `beneath` answers the call as core would.
*/
function session({ id, disk = new Map(), clock = { now: T0 }, beneath = async () => EDITED, failWrites = false }) {
function session({
id,
disk = new Map(),
clock = { now: T0 },
beneath = async () => EDITED,
failWrites = false,
lich,
roster = async () => ({ exitCode: 0, stdout: '[]\n', stderr: '' }),
}) {
const hooks = new Map()
register((event, matcher, hook) => {
assert.equal(event, 'tool.call')
hooks.set(matcher.tool, hook)
})
const logs = []
const env = id === undefined ? {} : { LICH_SESSION_ID: id }
const env = id === undefined ? {} : { LICH_SESSION_ID: id, LICH_BIN: lich }
const lichRuns = []
const $ = {
env: { get: async (name) => env[name] },
clock: { now: async () => clock.now },
ui: { log: (text) => logs.push(text) },
process: {
run: async (argv) => {
run: async (argv, options) => {
if (argv[0] === LICH) {
assert.deepEqual(argv, [LICH, 'sessions', '--json'])
assert.ok(options?.timeoutMs > 0, 'the lich CLI runs without a timeout')
lichRuns.push(argv)
return roster()
}
assert.deepEqual(argv.slice(0, 1), ['git'])
const dir = argv[2]
const inside = dir === REPO || dir.startsWith(`${REPO}/`)
Expand Down Expand Up @@ -75,6 +97,7 @@ function session({ id, disk = new Map(), clock = { now: T0 }, beneath = async ()
clock,
logs,
hooks,
lichRuns,
edit: (path = FILE) => call('Edit', { file_path: path, old_string: 'a', new_string: 'b' }),
write: (path = FILE) => call('Write', { file_path: path, content: 'x' }),
notebook: (path) => call('NotebookEdit', { notebook_path: path, new_source: 'x = 1' }),
Expand Down Expand Up @@ -221,6 +244,65 @@ test('an unreadable marker is logged and the edit result still returned', async
assert.equal(b.logs.length, 1)
})

/** Session A edits, then session B edits the same file a minute later. */
async function editedByAThenB(options) {
const disk = new Map()
const clock = { now: T0 }
await session({ id: A_ID, disk, clock }).edit()
clock.now = T0 + MINUTE
const b = session({ id: B_ID, disk, clock, lich: LICH, ...options })
return { b, result: await b.edit() }
}

test('the note names the other session by the label on its card', async () => {
const { b, result } = await editedByAThenB({
roster: async () => ({ exitCode: 0, stdout: `${JSON.stringify([OTHER_PEER, A_PEER])}\n`, stderr: '' }),
})

assert.match(result.context[0], /lich session "quiet-comet" at/)
assert.doesNotMatch(result.context[0], new RegExp(A_ID))
assert.equal(b.lichRuns.length, 1)
})

test('the note falls back to the session id when the label cannot be learned', async (t) => {
const answers = {
'the session is gone': async () => ({ exitCode: 0, stdout: `${JSON.stringify([OTHER_PEER])}\n`, stderr: '' }),
'the CLI fails': async () => ({ exitCode: 1, stdout: '', stderr: 'lich: no running lich' }),
'the CLI prints no JSON': async () => ({ exitCode: 0, stdout: 'No other live sessions.\n', stderr: '' }),
'the CLI cannot run': async () => {
throw new Error('timed out')
},
'an older lich lists no ids': async () => {
const { id, ...withoutId } = A_PEER
return { exitCode: 0, stdout: `${JSON.stringify([withoutId])}\n`, stderr: '' }
},
}
for (const [name, roster] of Object.entries(answers)) {
await t.test(name, async () => {
const { result } = await editedByAThenB({ roster })

assert.equal(result.ref, EDITED.ref, 'the edit went ahead')
assert.match(result.context[0], new RegExp(`lich session ${A_ID} at`))
})
}
})

test('without LICH_BIN the note names the session id', async () => {
const { b, result } = await editedByAThenB({ lich: undefined })

assert.match(result.context[0], new RegExp(`lich session ${A_ID} at`))
assert.equal(b.lichRuns.length, 0)
})

test('the lich CLI runs only when a note is added', async () => {
const a = session({ id: A_ID, lich: LICH })

await a.edit()
await a.edit()

assert.equal(a.lichRuns.length, 0)
})

test('the plugin entry module registers the guard on every file-editing tool', () => {
const tools = []
registerEntry((event, matcher) => {
Expand Down
Loading