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 @@ -43,6 +43,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
own cost, the model's real window and, for a long-lived token login, the plan
gauge without spending a request to measure it. Needs a lich that serves the
mod-usage contract; an older one answers 404 and the mod stops reporting.
- **A Claude Code session is told when another lich session just edited the
same file.** A subagent that shares the asking session's checkout can edit
the files it edits. After an edit, a write or a notebook edit to a file
another lich session edited in the last ten minutes, the model reads which
session it was, when, and that `send_to_session` or `lich send` reaches it.
Nothing is blocked: Claude Code already refuses an edit that would overwrite
a change it has not read. Works inside lich, in a git repository, with
Claude Code mods on.

## [0.14.0] - 2026-10-03

Expand Down
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ hooks/crush-hooks.json # hook registration, Crush (hand-merged into c
hooks/ # hook scripts (all four run them; only how they are addressed differs)
hooks/detail.jq # what a tool call acts on, shared by two of those scripts
hooks/win-run.cmd # runs one of those scripts on Windows (Codex)
hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the three mods below
hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the four mods below
hooks/mod-control.js # Claude Code mod: lich drives the session
hooks/agent-cards.js # Claude Code mod: a general-purpose subagent runs as a lich session
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
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 @@ -59,6 +60,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p
- [docs/mod-control.md](docs/mod-control.md): lich drives a Claude Code session (prompt, abort, model, effort, command) and asks it side questions (ask) through a mod that long-polls for commands and acks each one; Claude Code only, the other harnesses have no mod system
- [docs/agent-cards.md](docs/agent-cards.md): a general-purpose subagent Claude Code starts runs as a lich session, through the `lich open` CLI rather than an HTTP contract; Claude Code only
- [docs/mod-usage.md](docs/mod-usage.md): a Claude Code session reports the context window, rate limits and cost it measured itself, through a mod hooking `session.measure`; Claude Code only
- [docs/edit-guard.md](docs/edit-guard.md): an edit to a file another lich session edited in the last minutes carries a note naming it, through markers in the checkout's git dir rather than an HTTP contract; Claude Code only
- [docs/providers.md](docs/providers.md) — the per-harness map, including what installing on omp takes and what it cannot report

Each doc carries the event mapping for **every** provider, one column each — a contract is implemented once and registered per harness. [docs/providers.md](docs/providers.md) is the map: which file each harness reads, where the event vocabularies differ, and what adding another provider takes. A harness that cannot close a state does not get that report registered: an unendable `busy` is worse on a card than a card with no indicator.
Expand Down Expand Up @@ -93,6 +95,9 @@ Claude Code mod, handing it a fake `$` and asserting its acks against the
mod-control fixtures and its polls against the commands lich sends;
[tests/mod-usage.test.mjs](tests/mod-usage.test.mjs) does the same for its
usage reports against the mod-usage 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.
[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 All @@ -102,7 +107,8 @@ stub, shared so every client answers to the same lines. Node only, no
dependencies.
[tests/mod-control.engine.test.ts](tests/mod-control.engine.test.ts) and
[tests/agent-cards.engine.test.ts](tests/agent-cards.engine.test.ts) and
[tests/mod-usage.engine.test.ts](tests/mod-usage.engine.test.ts) run the
[tests/mod-usage.engine.test.ts](tests/mod-usage.engine.test.ts) and
[tests/edit-guard.engine.test.ts](tests/edit-guard.engine.test.ts) run the
mods under Claude Code's own engine (`claude plugin test .`); they need a
`claude` binary, so CI does not run them. The kit does not check a `tool.call`
result against the tool's output schema (measured on 2.1.289), so whether
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ checkout as a native subagent does, or in a worktree of its own when the
subagent asks for isolation. The Agent call returns at once as a background
subagent, and the session's full report arrives at the asking session's prompt
on its own as a `[lich]` note; Explore, Plan and other agent types stay inside Claude Code. [docs/agent-cards.md](docs/agent-cards.md) has the
rules and what happens when lich cannot be reached.
rules and what happens when lich cannot be reached. Since that subagent shares
the checkout, an edit to a file another lich session edited in the last ten
minutes tells the model which session it was and how to reach it
([docs/edit-guard.md](docs/edit-guard.md)).

It also ships skills for the parts of lich you configure from inside a session:

Expand All @@ -58,10 +61,11 @@ hooks/report-tool.sh # session-state hook: the tool a turn is runni
hooks/report-session-start.sh # session-start hook
hooks/report-title.sh # session-title hook
hooks/report-touched.sh # session-touched hook
hooks/lich.js # the Claude Code mods' entry, which registers the three below
hooks/lich.js # the Claude Code mods' entry, which registers the four below
hooks/mod-control.js # mod-control client, a Claude Code mod
hooks/agent-cards.js # a general-purpose subagent as a lich session, a Claude Code mod
hooks/mod-usage.js # mod-usage client, a Claude Code mod
hooks/edit-guard.js # names the other lich session that edited a file, a Claude Code mod
opencode/lich.js # opencode client: all four reports plus the seven tools, one module
omp/lich.js # omp client: the reports, one module
docs/ # client-side docs, one per contract
Expand Down
77 changes: 77 additions & 0 deletions docs/edit-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Mod: edit guard

Tells a Claude Code session inside lich that the file it just edited was also
edited, minutes earlier, by another lich session working in the same checkout.
There is no lich contract behind it: the sessions meet in the checkout's git
dir, and nothing is sent to lich.

| client | Claude Code | Codex | Antigravity | opencode | omp | Crush |
|-----------------------|---------------------------------------------------------|---------------------|---------------------|---------------------|---------------------|---------------------|
| `hooks/edit-guard.js` | mod, registered by `hooks/lich.js`, the `modules` entry | none: no mod system | none: no mod system | none: no mod system | none: no mod system | none: no mod system |

## Why

A general-purpose subagent lich opens ([agent-cards.md](agent-cards.md)) works
in the asking session's checkout by default, so two Claude Code sessions can
edit the same file at once. Each one only sees the other's change when it reads
the file again, and neither knows who made it or that the other may still be at
work on it.

## What Claude Code already does

Measured on Claude Code 2.1.289 (`--model haiku`, a scratch git repository): a
session read four files, another process then changed each of them, and the
session edited them without reading again.

| tool | result |
|-----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Edit`, `old_string` still in the file | applied, the other change kept: `The file … has been updated successfully. (note: the file had been modified on disk since you last read it — the edit applied cleanly, but the file contains other changes not in your context. Read it before edits that depend on surrounding content.)` |
| `Edit`, `old_string` changed by the other side | refused: `File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.` |
| `Write` | refused, same message |
| `NotebookEdit` | refused, same message |

`MultiEdit` is not a tool in that release. So Claude Code never overwrites a
change it has not read: the guard denies nothing, and only adds who made the
change.

## How it behaves

It hooks `tool.call` on `Edit`, `Write` and `NotebookEdit`, and does nothing
unless `LICH_SESSION_ID` is set.

1. Before the call it asks git for the git dir of the folder holding the file
(`git -C <folder> rev-parse --absolute-git-dir`, the worktree's own for a
linked worktree). Outside a repository it stops here.
2. It reads `<git dir>/lich-edits/<hash of the path>`, the marker the last
session to edit that file left: `{"path", "session", "at"}`, `at` in epoch
milliseconds.
3. It runs the call. A `deny` from a hook beneath is passed on as it came.
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
ago, and that `send_to_session` or `lich send` reaches that session. A
refused call carries the line too, since it explains the refusal.

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.

A failure of the guard itself (git, a marker it cannot read or write) is logged
with `$.ui.log`, a dim line in the transcript, and the edit goes on as if the
guard were not there.

## Limits

- 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.
- 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,
so a `Write` creating a new folder is not recorded.
- Edits made by `Bash` (`sed -i`, a formatter) are not seen, nor are edits by
harnesses other than Claude Code, which have no mod system.
- Markers are never cleaned up; each is a few hundred bytes, one per file a
lich session edited, under the git dir where git ignores it.
3 changes: 2 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ This repository is one plugin, packaged for each harness it supports:
| `hooks/crush-hooks.json` | Crush | hook registration, merged by hand |
| `hooks/*.sh` | the four | the reports themselves |
| `hooks/win-run.cmd` | Codex | runs a script on Windows |
| `hooks/lich.js` | Claude Code | the one module `hooks/hooks.json` names under `modules`; registers the three below |
| `hooks/lich.js` | Claude Code | the one module `hooks/hooks.json` names under `modules`; registers the four below |
| `hooks/mod-control.js` | Claude Code | the mod-control client, a mod |
| `hooks/agent-cards.js` | Claude Code | runs a general-purpose subagent as a lich session, a mod |
| `hooks/mod-usage.js` | Claude Code | the mod-usage client, a mod |
| `hooks/edit-guard.js` | Claude Code | names the other lich session that edited a file, a mod |
| `opencode/lich.js` | opencode | the whole client, as a module |
| `omp/lich.js` | omp | the whole client, as a module |
| `skills/` | all | skills, same layout |
Expand Down
131 changes: 131 additions & 0 deletions hooks/edit-guard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Tells a Claude Code session inside lich when another lich session edited the
// file it is editing. Docs: ../docs/edit-guard.md. No lich contract behind it:
// the sessions meet in the checkout's git dir, not over HTTP.
//
// A subagent lich opens shares its caller's checkout, so two sessions can edit
// one file. Claude Code already refuses an edit that would overwrite a change
// it has not read (measured on 2.1.289, in the doc), so this is coordination,
// not protection: every edit leaves a marker naming the session, and an edit
// to a file another session marked recently carries a note naming it.
//
// Every function that takes `$` is declared here at the top level: the loader
// refuses a module that hands `$` to a nested function.

// 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
const MARKER_DIR = "lich-edits"
const GIT_TIMEOUT_MS = 5_000

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

/** FNV-1a, 32 bits: a file name for a path; the marker keeps the path itself. */
function hashOf(text) {
let hash = 0x811c9dc5
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(16).padStart(8, "0")
}

/** @param {string} path */
function folderOf(path) {
const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
return cut <= 0 ? path.slice(0, cut + 1) || "." : path.slice(0, cut)
}

/**
* Where the marker for `path` lives, or undefined outside a git repository.
*
* @param {Engine} $
* @param {string} path
*/
async function markerPathOf($, path) {
const git = await $.process.run(["git", "-C", folderOf(path), "rev-parse", "--absolute-git-dir"], {
timeoutMs: GIT_TIMEOUT_MS,
})
if (git.exitCode !== 0) return undefined
return `${git.stdout.trim()}/${MARKER_DIR}/${hashOf(path)}`
}

/**
* The marker another session left on `path`, if any.
*
* @param {Engine} $
* @param {string} markerPath
* @param {string} path
* @returns {Promise<Marker | undefined>}
*/
async function readMarker($, markerPath, path) {
if (!(await $.fs.exists(markerPath))) return undefined
/** @type {Marker} */
const marker = JSON.parse(await $.fs.read(markerPath))
return marker.path === path ? marker : undefined
}

/**
* @param {Marker | undefined} marker
* @param {string} session
* @param {number} now
*/
function noteFor(marker, session, now) {
if (!marker || marker.session === session || now - marker.at > RECENT_MS) return undefined
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 ` +
`${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.`
)
}

/** @param {Engine} $ @param {unknown} error */
function logFailure($, error) {
$.ui.log(`lich edit guard: ${error instanceof Error ? error.message : String(error)}`)
}

/**
* @param {Engine} $
* @param {{ file_path?: string, notebook_path?: string }} e
* @param {(e: any) => Promise<any>} next
*/
async function guard($, e, next) {
const session = await $.env.get("LICH_SESSION_ID")
const path = e.file_path ?? e.notebook_path
if (!session || !path) return next(e)

let markerPath
let previous
try {
markerPath = await markerPathOf($, path)
previous = markerPath && (await readMarker($, markerPath, path))
} catch (error) {
logFailure($, error)
return next(e)
}

const result = await next(e)
if (!markerPath || result.deny !== undefined) return result

let note
try {
const now = await $.clock.now()
note = noteFor(previous, session, now)
if (!result.isError) await $.fs.write(markerPath, JSON.stringify({ path, session, at: now }))
} catch (error) {
logFailure($, error)
}
return note ? { ...result, context: [...(result.context ?? []), note] } : result
}

/** @param {import('claude-code').On} on */
export function register(on) {
on("tool.call", { tool: "Edit" }, guard)
on("tool.call", { tool: "Write" }, guard)
on("tool.call", { tool: "NotebookEdit" }, guard)
}
2 changes: 2 additions & 0 deletions hooks/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// from here.

import { register as registerAgentCards } from "./agent-cards.js"
import { register as registerEditGuard } from "./edit-guard.js"
import { register as registerModControl } from "./mod-control.js"
import { register as registerModUsage } from "./mod-usage.js"

Expand All @@ -11,4 +12,5 @@ export function register(on) {
registerModControl(on)
registerAgentCards(on)
registerModUsage(on)
registerEditGuard(on)
}
Loading
Loading