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

## [Unreleased]

### Added

- **The edit guard tells you too, not only the model.** When a file you edit
was also edited by another lich session in the last 10 minutes, the status
line under the prompt reads `lich: app.js also edited by "quiet-comet"` until
that edit leaves the window. It shares the line with the worker count:
`lich: 2 workers · app.js also edited by "quiet-comet"`.

## [0.17.0] - 2026-10-05

### Added
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ hooks/agent-cards.js # Claude Code mod: a general-purpose subagent
hooks/mod-usage.js # Claude Code mod: the session reports its own context, rate limits and cost
hooks/edit-guard.js # Claude Code mod: an edit says which other lich session edited the file
hooks/worker-answer.js # Claude Code mod: a lich subagent worker's last message answers its task
hooks/status-line.js # the one status line Claude Code gives the plugin, 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
docs/ # lich ⇄ plugin communication contracts
Expand Down Expand Up @@ -101,7 +102,9 @@ usage reports against the mod-usage fixtures, and
answer against the mod-answer fixtures.
[tests/edit-guard.test.mjs](tests/edit-guard.test.mjs) hands the edit guard a
fake `$` with an in-memory git dir and asserts the markers it leaves and the
note another session's marker adds.
note another session's marker adds, and
[tests/status-line.test.mjs](tests/status-line.test.mjs) that the mods share
the one status line Claude Code gives the plugin.
[tests/agent-cards.test.mjs](tests/agent-cards.test.mjs) hands the agent-cards
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
Expand Down
6 changes: 6 additions & 0 deletions docs/edit-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ unless `LICH_SESSION_ID` is set.
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.
6. The same edit puts `<file name> also edited by <session>` in the status
line under the prompt, for the user, who never reads the `context` line.
It stays until the marker leaves the 10-minute window, or a newer one
replaces it. Claude Code keeps one status line per plugin, so it shares the
line with the worker count of [agent-cards.md](agent-cards.md), through
`hooks/status-line.js`.

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
Expand Down
4 changes: 3 additions & 1 deletion hooks/agent-cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
// Every function that takes `$` is declared at the top level: the loader
// refuses a module that hands `$` to a nested function or keeps it in a variable.

import { statusLineWith } from "./status-line.js"

// lich's `promptLimit` (internal/relay). lich checks it only after the session
// was opened, so a task over it would leave an empty card behind.
const PROMPT_LIMIT_BYTES = 8192
Expand Down Expand Up @@ -334,7 +336,7 @@ async function runAsSession($, state, e, next) {
*/
function showWorkers($, state) {
const count = state.workers.size
$.ui.status(count === 0 ? undefined : `${count} worker${count === 1 ? "" : "s"}`)
$.ui.status(statusLineWith("workers", count === 0 ? undefined : `${count} worker${count === 1 ? "" : "s"}`))
}

/**
Expand Down
43 changes: 38 additions & 5 deletions hooks/edit-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
// Every function that takes `$` is declared here at the top level: the loader
// refuses a module that hands `$` to a nested function.

import { statusLineWith } from "./status-line.js"

// Long enough to span the other session's turn that made the edit, short
// enough that a marker from work long finished stops raising notes.
const RECENT_MS = 10 * 60_000
Expand Down Expand Up @@ -116,20 +118,46 @@ function noteFor(marker, who, now) {
}

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

/** The marker the status line shows, so an older one's timer does not clear a newer one. @type {Marker | undefined} */
let shownMarker

/**
* Tells the user too, in the status line, until the marker stops being news.
* The note only reaches the model.
*
* @param {Engine} $
* @param {Marker} marker
* @param {string} who
*/
function showEditBy($, marker, who) {
shownMarker = marker
const name = marker.path.slice(Math.max(marker.path.lastIndexOf("/"), marker.path.lastIndexOf("\\")) + 1)
$.ui.status(statusLineWith("edits", `${name} also edited by ${who}`))
}

/**
* @param {Engine} $
* @param {Marker} marker
*/
function clearEditBy($, marker) {
if (shownMarker !== marker) return
shownMarker = undefined
$.ui.status(statusLineWith("edits", undefined))
}

/** @param {Engine} $ @param {unknown} error */
Expand Down Expand Up @@ -163,7 +191,12 @@ async function guard($, e, next) {
let note
try {
const now = await $.clock.now()
if (isNews(previous, session, now)) note = await noteNaming($, previous, now)
if (isNews(previous, session, now)) {
const who = await whoMarked($, previous)
note = noteFor(previous, who, now)
showEditBy($, previous, who)
$.clock.after(previous.at + RECENT_MS - now, () => clearEditBy($, previous))
}
if (!result.isError) await $.fs.write(markerPath, JSON.stringify({ path, session, at: now }))
} catch (error) {
logFailure($, error)
Expand Down
28 changes: 28 additions & 0 deletions hooks/status-line.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// The lich status line under the prompt, shared by the mods. Claude Code keeps
// one `$.ui.status` line per plugin and every mod here is the plugin `lich`, so
// a mod that set it directly would wipe what another mod put there. Each mod
// owns one part instead, and the line is the parts joined in a fixed order.

/** @typedef {"workers" | "edits"} Part */

/** @type {Part[]} */
const ORDER = ["workers", "edits"]
const SEPARATOR = " · "

/** @type {Map<Part, string>} */
const parts = new Map()

/**
* The line with this mod's part set to `text`, undefined removing it; the
* caller passes it to `$.ui.status` itself, since the loader refuses `$`
* handed across an import. Undefined when no part is left, which clears it.
*
* @param {Part} part
* @param {string | undefined} text
*/
export function statusLineWith(part, text) {
if (text === undefined) parts.delete(part)
else parts.set(part, text)
const shown = ORDER.filter((p) => parts.has(p)).map((p) => parts.get(p))
return shown.length === 0 ? undefined : shown.join(SEPARATOR)
}
16 changes: 16 additions & 0 deletions tests/edit-guard.engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ test('the note names the other session by the label on its card', async ($, on)
expect(ran.context?.[0]).toContain('lich session "quiet-comet" at')
})

test('the user sees the other session in the status line', async ($, on) => {
const w = world(on, 'lich-a', [{ label: 'quiet-comet', name: 'repo-7c0e', project: 'repo', kind: 'claude', state: 'busy', id: B_ID }])
const statuses: (string | undefined)[] = []
on('ui.status', async (_$, e) => {
statuses.push(e.text)
return { value: undefined }
})
await $.tool.call(EDIT)
const [path] = markerOf(w.disk) ?? []
w.disk.set(path!, JSON.stringify({ path: FILE, session: B_ID, at: T0 - 60_000 }))

await $.tool.call(EDIT)

expect(statuses).toEqual(['app.js also edited by "quiet-comet"'])
})

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
55 changes: 52 additions & 3 deletions tests/edit-guard.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:
// `$.fs` is an in-memory map, `$.process.run` answers `git rev-parse` for one
// repository and `lich sessions --json` with the roster a test hands it,
// `$.clock.now` is a number the test moves.
// `$.clock.now` is a number the test moves, and a `$.clock.after` timer runs
// when the test fires it.
//
// Run: node --test tests/

Expand Down Expand Up @@ -53,12 +54,20 @@ function session({
hooks.set(matcher.tool, hook)
})
const logs = []
const statuses = []
const timers = []
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) },
clock: {
now: async () => clock.now,
after: (ms, fn) => {
timers.push({ ms, fn })
return { cancel: () => {} }
},
},
ui: { log: (text) => logs.push(text), status: (text) => statuses.push(text) },
process: {
run: async (argv, options) => {
if (argv[0] === LICH) {
Expand Down Expand Up @@ -96,6 +105,8 @@ function session({
disk,
clock,
logs,
statuses,
timers,
hooks,
lichRuns,
edit: (path = FILE) => call('Edit', { file_path: path, old_string: 'a', new_string: 'b' }),
Expand Down Expand Up @@ -303,6 +314,44 @@ test('the lich CLI runs only when a note is added', async () => {
assert.equal(a.lichRuns.length, 0)
})

test('the user sees the other session in the status line until its marker leaves the window', async () => {
const { b } = await editedByAThenB({
roster: async () => ({ exitCode: 0, stdout: `${JSON.stringify([A_PEER])}\n`, stderr: '' }),
})

assert.deepEqual(b.statuses, ['app.js also edited by "quiet-comet"'])
assert.deepEqual(b.timers.map((t) => t.ms), [9 * MINUTE], 'cleared when the 10-minute window of A\'s edit ends')
b.timers[0].fn()
assert.deepEqual(b.statuses, ['app.js also edited by "quiet-comet"', undefined])
})

test('an older edit leaving the window does not clear a newer one from the status line', async () => {
const disk = new Map()
const clock = { now: T0 }
const a = session({ id: 'lich-a', disk, clock })
const b = session({ id: 'lich-b', disk, clock })
const other = `${REPO}/src/other.js`

await a.edit()
await a.edit(other)
clock.now = T0 + MINUTE
await b.edit()
await b.edit(other)
b.timers[0].fn()

assert.deepEqual(b.statuses, ['app.js also edited by lich-a', 'other.js also edited by lich-a'])
})

test('no note, no status line', async () => {
const a = session({ id: 'lich-a' })

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

assert.deepEqual(a.statuses, [])
assert.deepEqual(a.timers, [])
})

test('the plugin entry module registers the guard on every file-editing tool', () => {
const tools = []
registerEntry((event, matcher) => {
Expand Down
25 changes: 25 additions & 0 deletions tests/status-line.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Pins hooks/status-line.js: Claude Code keeps one status line per plugin, so
// the mods' parts share it instead of overwriting each other.
//
// Run: node --test tests/

import { test } from 'node:test'
import assert from 'node:assert/strict'

import { statusLineWith } from '../hooks/status-line.js'

test('parts share the one line in a fixed order, and the line goes with the last part', () => {
const statuses = [
statusLineWith('edits', 'app.js also edited by "docs"'),
statusLineWith('workers', '2 workers'),
statusLineWith('edits', undefined),
statusLineWith('workers', undefined),
]

assert.deepEqual(statuses, [
'app.js also edited by "docs"',
'2 workers · app.js also edited by "docs"',
'2 workers',
undefined,
])
})
Loading