diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b4ac1..c2d0eaa 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] +### 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 diff --git a/CLAUDE.md b/CLAUDE.md index 1fb4a14..7d43366 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/docs/edit-guard.md b/docs/edit-guard.md index 3a9b186..e4e022f 100644 --- a/docs/edit-guard.md +++ b/docs/edit-guard.md @@ -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 ` also edited by ` 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 diff --git a/hooks/agent-cards.js b/hooks/agent-cards.js index 1ce4ee6..aeac4a4 100644 --- a/hooks/agent-cards.js +++ b/hooks/agent-cards.js @@ -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 @@ -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"}`)) } /** diff --git a/hooks/edit-guard.js b/hooks/edit-guard.js index 809294d..c9204eb 100644 --- a/hooks/edit-guard.js +++ b/hooks/edit-guard.js @@ -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 @@ -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 */ @@ -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) diff --git a/hooks/status-line.js b/hooks/status-line.js new file mode 100644 index 0000000..53c17c8 --- /dev/null +++ b/hooks/status-line.js @@ -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} */ +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) +} diff --git a/tests/edit-guard.engine.test.ts b/tests/edit-guard.engine.test.ts index 2a78252..e4973c9 100644 --- a/tests/edit-guard.engine.test.ts +++ b/tests/edit-guard.engine.test.ts @@ -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) diff --git a/tests/edit-guard.test.mjs b/tests/edit-guard.test.mjs index 6fe331d..b9ba042 100644 --- a/tests/edit-guard.test.mjs +++ b/tests/edit-guard.test.mjs @@ -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/ @@ -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) { @@ -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' }), @@ -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) => { diff --git a/tests/status-line.test.mjs b/tests/status-line.test.mjs new file mode 100644 index 0000000..2ad636f --- /dev/null +++ b/tests/status-line.test.mjs @@ -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, + ]) +})