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

### Changed

- **Plugin releases older than 0.13.0 are marked unsupported**, and lich offers
the release it supports in their place. 0.13.0 is the first that tells lich
which release it is. Releases older than 0.3.0 can no longer link a session
to its conversation, because their old `claude_session_id` field is not read
anymore.

- **Installing or updating the lich plugin picks the newest release this lich
supports, not the newest one published.** In Claude Code and Codex the
plugin's marketplace is pinned to that release, so those CLIs no longer move
Expand Down
6 changes: 3 additions & 3 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,9 @@ work when nobody knows it and that the call site never shows. The mechanism and
Neither CLI can change a declared ref, so an install removes the marketplace and adds it back; on Claude Code the
remove also uninstalls the plugin, and an add that fails there (offline mid-install) leaves it uninstalled until
the next attempt, which the install prompt then offers. An install that was already outside the range, from
before the pin or by hand, is caught by the `X-Lich-Plugin` header its hooks send (docs/hooks/README.md): only
once a session reports, and a plugin older than the header sends none, so until the floor passes that release
such a plugin is accepted in silence.
before the pin or by hand, is caught at startup from its installed version, and again by the `X-Lich-Plugin`
header its hooks send (docs/hooks/README.md) once a session reports. A report whose payload lich refuses never
reaches that check, so a plugin old enough to send one only shows up in the startup prompt and the log.
- **The plan gauge answers to two undocumented endpoints, and only two providers have one**
(`internal/quota`): Claude Code's and Codex's usage routes are what their own CLIs poll, not published API. A
field renamed upstream drops the window it fed rather than raising anything — an entry lich has no name for is
Expand Down
3 changes: 2 additions & 1 deletion docs/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ sent it.
lich remembers the last release each session named. A release outside the range
under [Versioning](#versioning) is still applied wherever its payload parses; it
is logged, and the window offers the release this lich supports. A request with
no header is a plugin older than the header and is accepted as before.
no header comes from a plugin older than the header, which is below the floor
and handled the same way.

## Client rules (all hooks)

Expand Down
4 changes: 1 addition & 3 deletions docs/hooks/fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ Every line carries exactly one of `body` / `raw`, and exactly one of `accept` /
`accept` is the payload **normalized**, not echoed: it is what the contract's
defaulting and trimming produce, so it is where `provider` defaults to `claude`
and a title loses its surrounding whitespace. It lists only the fields a case is
about — a field absent from `accept` is not asserted, which is how the
deprecated `claude_session_id` stays out of the expectations while still being
exercised by the payloads that send it.
about — a field absent from `accept` is not asserted.

`reject` prose is documentation, not a matcher. lich's error strings are its own
and change freely; what the fixture fixes is *that* the payload is refused.
Expand Down
2 changes: 0 additions & 2 deletions docs/hooks/fixtures/session-start.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
{"name":"cursor chat id","body":{"session_id":"s1","provider_session_id":"bec68d79-a208-4e40-8d2f-f8f8964da216","provider":"cursor"},"accept":{"session_id":"s1","provider_session_id":"bec68d79-a208-4e40-8d2f-f8f8964da216","provider":"cursor"}}
{"name":"kiro session id","body":{"session_id":"s1","provider_session_id":"f5d98acc-da1a-4029-a2eb-ecc1f666af06","provider":"kiro"},"accept":{"session_id":"s1","provider_session_id":"f5d98acc-da1a-4029-a2eb-ecc1f666af06","provider":"kiro"}}
{"name":"absent provider defaults to claude","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"claude"}}
{"name":"legacy claude_session_id is folded","body":{"session_id":"s1","claude_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02","provider":"claude"}}
{"name":"provider_session_id wins over legacy","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","claude_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"claude"}}
{"name":"missing session_id","body":{"provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01"},"reject":"session_id is required"}
{"name":"missing provider_session_id","body":{"session_id":"s1"},"reject":"provider_session_id is required"}
{"name":"empty provider_session_id","body":{"session_id":"s1","provider_session_id":""},"reject":"provider_session_id is required"}
Expand Down
9 changes: 1 addition & 8 deletions docs/hooks/session-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ Content-Type: application/json
field existed. An id outside the registry is rejected, like an unknown state
on `/hook` — lich ships its side of a contract first, so a provider it has no
entry for is a client running ahead of it.
- `claude_session_id` — **deprecated** alias for `provider_session_id`, still
accepted so plugin releases before v0.3.0 keep working. When both are
present, `provider_session_id` wins. New clients must not send it.

Responses: `204` ok · `401` invalid token · `400` invalid body · `500` lich
failed to persist.
Expand Down Expand Up @@ -85,8 +82,7 @@ scripted run would have said this contract could not be closed.

- **Endpoint** — `internal/terminal/transport.go`, `transport.sessionStart`:
validates the token and body (`parseSessionStart`) on the same loopback
listener as terminal I/O, folds the deprecated `claude_session_id` into
`provider_session_id`, defaults an absent `provider` to `claude` and rejects
listener as terminal I/O, defaults an absent `provider` to `claude` and rejects
an unregistered one, then forwards `(session_id, provider_session_id,
provider)`.
- **Persistence** — `internal/store/mutations.go`, `Service.SetProviderSession`:
Expand Down Expand Up @@ -163,6 +159,3 @@ scripted run would have said this contract could not be closed.
there and withheld for one that is not. A lock held by the provider running
right now is waited out briefly and then read as absent — the failure that
costs a live conversation, which is why the wait exists at all.
- **The deprecated `claude_session_id` alias stays until the install gate can
no longer meet a plugin older than v0.3.0.** Dropping it earlier silently
breaks resume for anyone who has not updated the plugin.
30 changes: 15 additions & 15 deletions internal/agentplugin/agentplugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,17 @@ func TestComputeStatus(t *testing.T) {
wantUpdate bool
wantCompatible bool
}{
{"not installed", false, "", "0.12.2", false, true},
{"installed, no latest known", true, "0.12.1", "", false, true},
{"update available", true, "0.12.1", "0.12.2", true, true},
{"already latest", true, "0.12.2", "0.12.2", false, true},
{"installed newer than latest, still compatible", true, "0.13.0", "0.12.2", false, true},
{"pre-release install sees the stable release", true, "0.12.0-rc.3", "0.12.0", true, true},
{"not installed", false, "", "0.13.2", false, true},
{"installed, no latest known", true, "0.13.1", "", false, true},
{"update available", true, "0.13.1", "0.13.2", true, true},
{"already latest", true, "0.13.2", "0.13.2", false, true},
{"installed newer than latest, still compatible", true, "0.13.9", "0.13.2", false, true},
{"pre-release install sees the stable release", true, "0.13.1-rc.3", "0.13.1", true, true},
// Past the ceiling: the compatible release is offered even though it is
// older, because it is the one this lich speaks.
{"installed past the ceiling", true, PluginVersionCeiling, "0.13.1", true, false},
{"installed below the floor", true, "0.2.9", "0.13.1", true, false},
{"incompatible, no compatible release known", true, "0.2.9", "", false, false},
{"installed below the floor", true, "0.12.9", "0.13.1", true, false},
{"incompatible, no compatible release known", true, "0.12.9", "", false, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
Expand Down Expand Up @@ -279,9 +279,9 @@ func statusOf(t *testing.T, list []Status, provider string) Status {
}

func TestStatus(t *testing.T) {
t.Setenv("CLAUDE_CONFIG_DIR", writeClaudeState(t, `{"plugins":{"lich@lich-plugin":[{"scope":"user","version":"0.12.0-rc.3"}]}}`))
t.Setenv("CLAUDE_CONFIG_DIR", writeClaudeState(t, `{"plugins":{"lich@lich-plugin":[{"scope":"user","version":"0.13.1-rc.3"}]}}`))

s := serveBody(t, http.StatusOK, `[{"tag_name":"v0.12.0"}]`)
s := serveBody(t, http.StatusOK, `[{"tag_name":"v0.13.1"}]`)
// Codex's version comes from its CLI, which is not spawned here; only the
// Claude Code entry is asserted on.
s.lookPath = func(name string) (string, error) {
Expand All @@ -293,7 +293,7 @@ func TestStatus(t *testing.T) {

want := Status{
Provider: providers.Claude, Name: "Claude Code", Available: true,
Installed: true, InstalledVersion: "0.12.0-rc.3", LatestVersion: "0.12.0", UpdateAvailable: true,
Installed: true, InstalledVersion: "0.13.1-rc.3", LatestVersion: "0.13.1", UpdateAvailable: true,
Compatible: true,
}
if got := statusOf(t, s.Status(), providers.Claude); got != want {
Expand All @@ -306,7 +306,7 @@ func TestStatus(t *testing.T) {
// install button on a CLI with nothing to install.
func TestStatusListsEveryHarness(t *testing.T) {
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
s := serveBody(t, http.StatusOK, `[{"tag_name":"v0.12.0"}]`)
s := serveBody(t, http.StatusOK, `[{"tag_name":"v0.13.1"}]`)
s.lookPath = func(string) (string, error) { return "", errors.New("not found") }

var got []string
Expand Down Expand Up @@ -339,14 +339,14 @@ func TestStatusWithoutTheCLI(t *testing.T) {

func TestStatusNotInstalled(t *testing.T) {
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
s := serveBody(t, http.StatusOK, `[{"tag_name":"v0.12.0"}]`)
s := serveBody(t, http.StatusOK, `[{"tag_name":"v0.13.1"}]`)

got := statusOf(t, s.Status(), providers.Claude)
if got.Installed || got.UpdateAvailable {
t.Fatalf("Status() = %+v, want not installed and no update", got)
}
if got.LatestVersion != "0.12.0" {
t.Fatalf("LatestVersion = %q, want %q", got.LatestVersion, "0.12.0")
if got.LatestVersion != "0.13.1" {
t.Fatalf("LatestVersion = %q, want %q", got.LatestVersion, "0.13.1")
}
}

Expand Down
7 changes: 4 additions & 3 deletions internal/agentplugin/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import (
// change (its major, from 1.0) and its patch for anything else, so every release
// below the ceiling sends only what this lich accepts.
const (
// PluginVersionFloor is the oldest plugin release whose reports this lich
// still parses.
PluginVersionFloor = "0.3.0"
// PluginVersionFloor is the oldest plugin release this lich supports: the
// first to name itself in the X-Lich-Plugin header, so every supported
// install says which release a report comes from.
PluginVersionFloor = "0.13.0"
// PluginVersionCeiling is the first release past the newest contract this
// lich implements (0.13), and the first one it cannot vouch for.
PluginVersionCeiling = "0.14.0"
Expand Down
11 changes: 6 additions & 5 deletions internal/agentplugin/compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import "testing"
// moving a bound is a visible edit here too.
func TestCompatible(t *testing.T) {
tests := map[string]bool{
"0.2.9": false,
"0.3.0": true,
"0.12.9": false,
"0.13.0-rc.1": false,
"0.13.0": true,
"0.13.9": true,
"0.14.0-rc.1": true,
"0.14.0": false,
Expand All @@ -28,9 +29,9 @@ func TestNewestCompatible(t *testing.T) {
tags []string
want string
}{
{"newest in range wins over a newer one past it", []string{"0.14.0", "0.13.2", "0.13.10", "0.12.0"}, "0.13.10"},
{"order does not matter", []string{"0.12.0", "0.13.1"}, "0.13.1"},
{"nothing in range", []string{"0.14.0", "0.2.0"}, ""},
{"newest in range wins over a newer one past it", []string{"0.14.0", "0.13.2", "0.13.10", "0.12.9"}, "0.13.10"},
{"order does not matter", []string{"0.13.0", "0.13.1"}, "0.13.1"},
{"nothing in range", []string{"0.14.0", "0.12.0"}, ""},
{"no releases", nil, ""},
}
for _, tc := range tests {
Expand Down
2 changes: 1 addition & 1 deletion internal/agentplugin/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import (
// release published, the marker that names the version, and — for Crush — that
// every line the user wrote survives the write.

const testVersion = "0.8.0"
const testVersion = "0.13.0"

// fileServer serves the plugin repository's files at a tag and records the
// paths asked for. A path with no body 404s, which is how a test says "this
Expand Down
7 changes: 3 additions & 4 deletions internal/terminal/pluginversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ type pluginVersions struct {
}

// note records the release a session's report named. An absent header is a
// plugin older than the header itself, whose reports are accepted as they
// always were; it is recorded but raises nothing.
// plugin older than the header, and so older than the floor.
func (p *pluginVersions) note(id, version string) {
p.mu.Lock()
if p.byID == nil {
Expand All @@ -57,10 +56,10 @@ func (p *pluginVersions) note(id, version string) {
fn := p.onIncompatible
p.mu.Unlock()

if (seen && prev == version) || version == "" || agentplugin.Compatible(version) {
if (seen && prev == version) || agentplugin.Compatible(version) {
return
}
slog.Warn("hook: plugin outside the supported range", "session", id, "plugin", version,
slog.Warn("hook: plugin outside the supported range", "session", id, "plugin", pluginLabel(version),
"floor", agentplugin.PluginVersionFloor, "ceiling", agentplugin.PluginVersionCeiling)
if fn != nil {
fn(id, version)
Expand Down
4 changes: 2 additions & 2 deletions internal/terminal/pluginversion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestPluginVersionsRaisesOncePerSessionAndRelease(t *testing.T) {
raised := incompatibleRecorder(&p)

p.note("a", "0.13.0") // compatible
p.note("a", "") // a plugin older than the header
p.note("a", "") // a plugin older than the header, so below the floor
p.note("a", "9.0.0")
p.note("a", "9.0.0") // repeat report, same release
p.note("b", "9.0.0") // another session
Expand All @@ -39,7 +39,7 @@ func TestPluginVersionsRaisesOncePerSessionAndRelease(t *testing.T) {
p.forget("b")
p.note("b", "9.0.0") // a closed session's id reused

want := []string{"a@9.0.0", "b@9.0.0", "a@9.0.0", "b@9.0.0"}
want := []string{"a@", "a@9.0.0", "b@9.0.0", "a@9.0.0", "b@9.0.0"}
if got := raised(); !slices.Equal(got, want) {
t.Fatalf("raised %v, want %v", got, want)
}
Expand Down
15 changes: 4 additions & 11 deletions internal/terminal/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,15 +505,11 @@ func clampRunes(s string, max int) string {
return string(runes[:max])
}

// startRequest is a session-start POST body. LegacyClaudeSessionID accepts the
// pre-multi-provider field name plugin releases before v0.3.0 send; parsing
// folds it into ProviderSessionID, so nothing downstream sees two names. Drop it
// once the install gate can no longer meet an older plugin.
// startRequest is a session-start POST body.
type startRequest struct {
SessionID string `json:"session_id"`
ProviderSessionID string `json:"provider_session_id"`
Provider string `json:"provider"`
LegacyClaudeSessionID string `json:"claude_session_id"`
SessionID string `json:"session_id"`
ProviderSessionID string `json:"provider_session_id"`
Provider string `json:"provider"`
}

// sessionStart receives the session-start POST from a provider's hook running
Expand Down Expand Up @@ -541,9 +537,6 @@ func parseSessionStart(body []byte) (startRequest, error) {
if err := json.Unmarshal(body, &req); err != nil {
return startRequest{}, fmt.Errorf("invalid session-start body: %w", err)
}
if req.ProviderSessionID == "" {
req.ProviderSessionID = req.LegacyClaudeSessionID
}
if req.Provider == "" {
req.Provider = providers.Claude
}
Expand Down
9 changes: 2 additions & 7 deletions internal/terminal/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,10 +407,8 @@ func TestParseSessionStart(t *testing.T) {
wantErr bool
}{
{"ok", `{"session_id":"s1","provider_session_id":"uuid-1"}`, "s1", "uuid-1", "claude", false},
{"legacy claude field", `{"session_id":"s1","claude_session_id":"uuid-2"}`, "s1", "uuid-2", "claude", false},
{"new field wins over legacy",
`{"session_id":"s1","provider_session_id":"uuid-1","claude_session_id":"uuid-2"}`,
"s1", "uuid-1", "claude", false},
// The pre-0.3.0 alias is gone: the floor is past every plugin that sent it.
{"legacy claude field is not read", `{"session_id":"s1","claude_session_id":"uuid-2"}`, "", "", "", true},
{"reported provider",
`{"session_id":"s1","provider_session_id":"uuid-1","provider":"codex"}`,
"s1", "uuid-1", "codex", false},
Expand Down Expand Up @@ -439,9 +437,6 @@ func TestParseSessionStart(t *testing.T) {
{"question mark provider id",
`{"session_id":"s1","provider_session_id":"uuid-?"}`,
"", "", "", true},
{"legacy field is checked too",
`{"session_id":"s1","claude_session_id":"../../etc/passwd"}`,
"", "", "", true},
{"a dot inside an id is not a traversal",
`{"session_id":"s1","provider_session_id":"ses.2026-08-17.a1b2"}`,
"s1", "ses.2026-08-17.a1b2", "claude", false},
Expand Down
Loading