diff --git a/CHANGELOG.md b/CHANGELOG.md index ce7e5216..e128cc66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/ceilings.md b/docs/ceilings.md index 2baec3fa..64ebe571 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -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 diff --git a/docs/hooks/README.md b/docs/hooks/README.md index 8ee35b24..2bb7c83d 100644 --- a/docs/hooks/README.md +++ b/docs/hooks/README.md @@ -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) diff --git a/docs/hooks/fixtures/README.md b/docs/hooks/fixtures/README.md index 86564700..b00f5821 100644 --- a/docs/hooks/fixtures/README.md +++ b/docs/hooks/fixtures/README.md @@ -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. diff --git a/docs/hooks/fixtures/session-start.jsonl b/docs/hooks/fixtures/session-start.jsonl index 2b5d6889..e513a73d 100644 --- a/docs/hooks/fixtures/session-start.jsonl +++ b/docs/hooks/fixtures/session-start.jsonl @@ -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"} diff --git a/docs/hooks/session-start.md b/docs/hooks/session-start.md index 89e334cc..d467de80 100644 --- a/docs/hooks/session-start.md +++ b/docs/hooks/session-start.md @@ -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. @@ -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`: @@ -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. diff --git a/internal/agentplugin/agentplugin_test.go b/internal/agentplugin/agentplugin_test.go index 20f15888..55cf1f43 100644 --- a/internal/agentplugin/agentplugin_test.go +++ b/internal/agentplugin/agentplugin_test.go @@ -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) { @@ -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) { @@ -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 { @@ -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 @@ -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") } } diff --git a/internal/agentplugin/compat.go b/internal/agentplugin/compat.go index a7e421cf..52a229e6 100644 --- a/internal/agentplugin/compat.go +++ b/internal/agentplugin/compat.go @@ -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" diff --git a/internal/agentplugin/compat_test.go b/internal/agentplugin/compat_test.go index 5281323a..c6875b5c 100644 --- a/internal/agentplugin/compat_test.go +++ b/internal/agentplugin/compat_test.go @@ -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, @@ -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 { diff --git a/internal/agentplugin/files_test.go b/internal/agentplugin/files_test.go index 8891ebc8..6f78dc30 100644 --- a/internal/agentplugin/files_test.go +++ b/internal/agentplugin/files_test.go @@ -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 diff --git a/internal/terminal/pluginversion.go b/internal/terminal/pluginversion.go index 4300e47c..55dbdff1 100644 --- a/internal/terminal/pluginversion.go +++ b/internal/terminal/pluginversion.go @@ -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 { @@ -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) diff --git a/internal/terminal/pluginversion_test.go b/internal/terminal/pluginversion_test.go index 485897e2..2661cabe 100644 --- a/internal/terminal/pluginversion_test.go +++ b/internal/terminal/pluginversion_test.go @@ -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 @@ -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) } diff --git a/internal/terminal/transport.go b/internal/terminal/transport.go index 4713cf9a..bba62fd5 100644 --- a/internal/terminal/transport.go +++ b/internal/terminal/transport.go @@ -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 @@ -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 } diff --git a/internal/terminal/transport_test.go b/internal/terminal/transport_test.go index 6eef004e..08745a89 100644 --- a/internal/terminal/transport_test.go +++ b/internal/terminal/transport_test.go @@ -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}, @@ -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},