From e1cfce6ff1efd15c513948e1044e43376cc97e63 Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 5 Oct 2026 16:13:00 -0300 Subject: [PATCH 1/2] fix: name the other session by its card's label in the edit guard's note The note named the session that edited the file by its lich id, which means nothing to the model or the user. When a note is about to be added, the guard now asks `$LICH_BIN sessions --json` (2 s timeout) and names the session by its label, falling back to the id when the CLI fails, the session is gone or the match is ambiguous. That roster lists no ids, so the session is found by its roster name, which lich derives from the id (folder, dash, first four alphanumerics). A session renamed with /rename falls back to the id; an `id` field in the roster would close that. --- CHANGELOG.md | 8 +++ docs/edit-guard.md | 19 +++++-- hooks/edit-guard.js | 62 ++++++++++++++++++++-- tests/edit-guard.engine.test.ts | 26 +++++++--- tests/edit-guard.test.mjs | 91 +++++++++++++++++++++++++++++++-- 5 files changed, 189 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6706971..138952d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ 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. + ## [0.16.0] - 2026-10-05 ### Changed diff --git a/docs/edit-guard.md b/docs/edit-guard.md index f91193c..13addc0 100644 --- a/docs/edit-guard.md +++ b/docs/edit-guard.md @@ -49,10 +49,21 @@ 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. That roster carries no ids +either, only each session's roster name, which lich derives from the id (the +folder name, a dash, the first four letters and digits of the id); the guard +takes the one session whose name ends that way. When the CLI is missing or +fails, the session is gone, or no single name matches, 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. @@ -65,8 +76,10 @@ 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. +- The label is found through a derivation, not a contract: `lich sessions + --json` lists no session ids. A Claude Code session renamed with `/rename` + answers to a name nothing can derive, and is named by id. An `id` field in + that roster, which lich would have to add, would close this. - 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, diff --git a/hooks/edit-guard.js b/hooks/edit-guard.js index aa380d8..fa5bbef 100644 --- a/hooks/edit-guard.js +++ b/hooks/edit-guard.js @@ -16,10 +16,16 @@ 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 +// lich's roster name for a session ends in this many letters and digits of its +// id (RosterName in lich's internal/relay/rostername.go). +const ROSTER_ID_CHARS = 4 /** * @typedef {import('claude-code').EngineInterface} Engine * @typedef {{ path: string, session: string, at: number }} Marker + * @typedef {{ label: string, name: string }} Peer */ /** FNV-1a, 32 bits: a file name for a path; the marker keeps the path itself. */ @@ -71,19 +77,67 @@ 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. + * + * `lich sessions --json` lists no ids, so the session is found by its roster + * name, which lich derives from the id. A session renamed with `/rename` has a + * name nothing can derive, and goes unnamed. + * + * @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) + const tail = `-${id.replace(/[^0-9A-Za-z]/g, "").slice(0, ROSTER_ID_CHARS)}` + const matches = peers.filter((peer) => peer.name.endsWith(tail)) + return matches.length === 1 ? matches[0].label : undefined +} + +/** + * @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)}`) @@ -115,7 +169,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) diff --git a/tests/edit-guard.engine.test.ts b/tests/edit-guard.engine.test.ts index 12d639d..ebab9e5 100644 --- a/tests/edit-guard.engine.test.ts +++ b/tests/edit-guard.engine.test.ts @@ -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 @@ -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() 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) => { @@ -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' }]) + 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) diff --git a/tests/edit-guard.test.mjs b/tests/edit-guard.test.mjs index 1cfc612..26ca968 100644 --- a/tests/edit-guard.test.mjs +++ b/tests/edit-guard.test.mjs @@ -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/ @@ -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' } +const OTHER_PEER = { label: 'docs', name: 'repo-9f8e', project: 'repo', kind: 'codex', state: 'done' } + const STALE = { ref: 2, isError: true, @@ -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}/`) @@ -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' }), @@ -221,6 +244,66 @@ 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') + }, + 'two sessions match the id': async () => ({ + exitCode: 0, + stdout: `${JSON.stringify([A_PEER, { ...A_PEER, label: 'twin', name: 'other-1fd2' }])}\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) => { From 5e1971b41ec12dc784a48e04545b660900884de4 Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 5 Oct 2026 16:14:20 -0300 Subject: [PATCH 2/2] fix: match the other session by the id lich sessions lists, not its roster name Matching the roster name's tail derived from the id broke on /rename and could collide; lich 0.60.0 lists each session's id in lich sessions --json, so the guard matches on it and names the id only on an older lich. The tests change because the roster they script gains the id field. --- CHANGELOG.md | 3 ++- docs/edit-guard.md | 14 +++++--------- hooks/edit-guard.js | 14 ++++---------- tests/edit-guard.engine.test.ts | 2 +- tests/edit-guard.test.mjs | 13 ++++++------- 5 files changed, 18 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 138952d..cc9721b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. + say which session it was. Needs lich 0.60.0, whose `lich sessions --json` lists + session ids. ## [0.16.0] - 2026-10-05 diff --git a/docs/edit-guard.md b/docs/edit-guard.md index 13addc0..3a9b186 100644 --- a/docs/edit-guard.md +++ b/docs/edit-guard.md @@ -57,11 +57,9 @@ 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. That roster carries no ids -either, only each session's roster name, which lich derives from the id (the -folder name, a dash, the first four letters and digits of the id); the guard -takes the one session whose name ends that way. When the CLI is missing or -fails, the session is gone, or no single name matches, the line names the id +`$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 @@ -76,10 +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 label is found through a derivation, not a contract: `lich sessions - --json` lists no session ids. A Claude Code session renamed with `/rename` - answers to a name nothing can derive, and is named by id. An `id` field in - that roster, which lich would have to add, would close this. +- 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, diff --git a/hooks/edit-guard.js b/hooks/edit-guard.js index fa5bbef..809294d 100644 --- a/hooks/edit-guard.js +++ b/hooks/edit-guard.js @@ -18,14 +18,11 @@ 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 -// lich's roster name for a session ends in this many letters and digits of its -// id (RosterName in lich's internal/relay/rostername.go). -const ROSTER_ID_CHARS = 4 /** * @typedef {import('claude-code').EngineInterface} Engine * @typedef {{ path: string, session: string, at: number }} Marker - * @typedef {{ label: string, name: string }} Peer + * @typedef {{ label: string, id?: string }} Peer */ /** FNV-1a, 32 bits: a file name for a path; the marker keeps the path itself. */ @@ -86,9 +83,8 @@ function isNews(marker, session, now) { /** * The label on the card of the session `id`, or undefined when lich cannot say. * - * `lich sessions --json` lists no ids, so the session is found by its roster - * name, which lich derives from the id. A session renamed with `/rename` has a - * name nothing can derive, and goes unnamed. + * A lich older than the `id` field in `lich sessions --json` lists none, and + * the session goes unnamed there. * * @param {Engine} $ * @param {string} id @@ -100,9 +96,7 @@ async function labelOf($, id) { if (exitCode !== 0) return undefined /** @type {Peer[]} */ const peers = JSON.parse(stdout) - const tail = `-${id.replace(/[^0-9A-Za-z]/g, "").slice(0, ROSTER_ID_CHARS)}` - const matches = peers.filter((peer) => peer.name.endsWith(tail)) - return matches.length === 1 ? matches[0].label : undefined + return peers.find((peer) => peer.id === id)?.label } /** diff --git a/tests/edit-guard.engine.test.ts b/tests/edit-guard.engine.test.ts index ebab9e5..2a78252 100644 --- a/tests/edit-guard.engine.test.ts +++ b/tests/edit-guard.engine.test.ts @@ -59,7 +59,7 @@ test('an edit to a file another lich session just edited goes ahead with a note' }) 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' }]) + 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 })) diff --git a/tests/edit-guard.test.mjs b/tests/edit-guard.test.mjs index 26ca968..6fe331d 100644 --- a/tests/edit-guard.test.mjs +++ b/tests/edit-guard.test.mjs @@ -24,8 +24,8 @@ const EDITED = { ref: 1, result: { filePath: FILE }, text: 'The file has been up 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' } -const OTHER_PEER = { label: 'docs', name: 'repo-9f8e', project: 'repo', kind: 'codex', state: 'done' } +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, @@ -272,11 +272,10 @@ test('the note falls back to the session id when the label cannot be learned', a 'the CLI cannot run': async () => { throw new Error('timed out') }, - 'two sessions match the id': async () => ({ - exitCode: 0, - stdout: `${JSON.stringify([A_PEER, { ...A_PEER, label: 'twin', name: 'other-1fd2' }])}\n`, - stderr: '', - }), + '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 () => {