From 04d0d315610d42289e40de30adb0f7e004d97881 Mon Sep 17 00:00:00 2001 From: Atlas from Plumb Date: Thu, 1 Oct 2026 07:36:21 +1000 Subject: [PATCH 1/2] fix(session_start): point an installed hook at `plumb hooks`, not at installing it When a call arrived without a per-call identity, session_start's notices said only "`plumb hooks install claude-code` stamps every call". With the hook already installed, the usual cause is a daemon too old to accept the key Claude desktop's connector passes through (found by dogfooding on a pre-#531 build). The advice sent the caller round in a circle. Both notices now add that `plumb hooks` says why the call was not stamped. Co-Authored-By: Claude Opus 5.5 Plumb-Session: scarlet-viper --- CHANGELOG.md | 8 ++++++++ internal/tools/session_start_stamp.go | 7 ++++--- internal/tools/session_start_stamp_test.go | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec9920aa..e75746a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,14 @@ ### Fixed +- **`session_start` no longer tells you to install a hook you already have.** + When a call arrived without a per-call identity, its notice only said + "`plumb hooks install claude-code` stamps every call". With the hook + installed, the usual cause is a daemon too old to accept the key Claude + desktop's connector passes through, so the advice sent people round in a + circle. The notice now adds that `plumb hooks` says why the call was not + stamped. + - **The identity hook re-asks a daemon that was swapped within the minute.** The Claude Code identity hook caches, for a minute, the daemon's version and whether it accepts `plumb_agent`. If the daemon was replaced inside diff --git a/internal/tools/session_start_stamp.go b/internal/tools/session_start_stamp.go index 742eb436..964a3db0 100644 --- a/internal/tools/session_start_stamp.go +++ b/internal/tools/session_start_stamp.go @@ -70,8 +70,9 @@ const stampChannelRefusedNotice = "NOTE: state-changing calls from this session "This connection serves more than one logical agent and this call carried no per-call identity, " + "so plumb cannot tell which agent's workspace a write belongs to and will not guess. Your session_id " + "declaration IS recorded, but it identifies this call only. Stamp every call: on Claude Code, " + - "`plumb hooks install claude-code` (on Claude desktop, restart the app after upgrading plumb); a client " + - "whose transport can set it sends a per-call _meta identity; otherwise run one plumb serve per agent.\n" + "`plumb hooks install claude-code` (on Claude desktop, restart the app after upgrading plumb); if it is " + + "already installed, `plumb hooks` says why this call was not stamped. A client whose transport can set " + + "it sends a per-call _meta identity; otherwise run one plumb serve per agent.\n" // stampChannelDormantNotice is emitted when this call carried no per-call // identity but the connection is still single-agent. Nothing is refused yet, @@ -79,7 +80,7 @@ const stampChannelRefusedNotice = "NOTE: state-changing calls from this session const stampChannelDormantNotice = "NOTE: this call carried no per-call logical-agent identity. Nothing is " + "refused while you are the only agent on this connection, but once a second agent attaches, your " + "unstamped state-changing calls are refused. On Claude Code, `plumb hooks install claude-code` stamps " + - "every call.\n" + "every call; if it is already installed, `plumb hooks` says why this call was not stamped.\n" // stampChannelNote renders the disclosure, or "" when there is nothing to say: // the accessor is unwired, or the channel is live. Rendered alongside diff --git a/internal/tools/session_start_stamp_test.go b/internal/tools/session_start_stamp_test.go index 6de360f9..b574c4cc 100644 --- a/internal/tools/session_start_stamp_test.go +++ b/internal/tools/session_start_stamp_test.go @@ -165,3 +165,18 @@ func TestStampChannelNoteSilentForNonHookClientUntilShared(t *testing.T) { t.Errorf("a refused non-hook client must be told: %q", got) } } + +// TestStampChannelNotices_PointAnInstalledHookAtItsDiagnosis: a caller whose +// hook IS installed but whose call still arrived unstamped (a daemon too old to +// accept the key Claude desktop's connector passes through) must not be sent to +// install it again. Both notices name `plumb hooks`, which reports the reason. +func TestStampChannelNotices_PointAnInstalledHookAtItsDiagnosis(t *testing.T) { + for name, notice := range map[string]string{"refused": stampChannelRefusedNotice, "dormant": stampChannelDormantNotice} { + if !strings.Contains(notice, "plumb hooks install claude-code") { + t.Errorf("%s notice lost the install remedy: %q", name, notice) + } + if !strings.Contains(notice, "if it is already installed, `plumb hooks` says why this call was not stamped") { + t.Errorf("%s notice does not point an installed hook at `plumb hooks`: %q", name, notice) + } + } +} From 0cda749cc64cfc0cd86f19788373f7d3fc8107cb Mon Sep 17 00:00:00 2001 From: Atlas from Plumb Date: Thu, 1 Oct 2026 18:31:36 +1000 Subject: [PATCH 2/2] fix(session_start): say what bare `plumb hooks` checks, not that it explains the call The stamp notices told a caller with an installed hook that `plumb hooks` says why the call was not stamped. It does not always: for a missing or stale hook, or a daemon that is old or does not list plumb_agent, it reports the fault, but with a current hook and a daemon that accepts the stamp it has nothing to report, so the promise sent that reader round in a circle again. Both notices now say that, if the hook is installed, `plumb hooks` checks it and the daemon. The clause is also twelve bytes shorter than the one it replaces, which matters: in a loaded workspace the brief packet carrying the dormant notice is within a few bytes of its 1536-byte bound, and the unchanged byte budget test never renders either notice. The test pins the new clause and rejects "says why". Mutants run through go test -overlay were all killed: the pointer dropped from the refused notice, "says why" restored in either notice, and the install remedy dropped from the dormant notice. The CHANGELOG entry now describes what the command reports. Co-Authored-By: Claude Sonnet 5.5 Plumb-Session: giant-bison --- CHANGELOG.md | 5 +++-- internal/tools/session_start_stamp.go | 11 ++++++++--- internal/tools/session_start_stamp_test.go | 13 +++++++++++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ed7a90f..8efe6b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,8 +62,9 @@ "`plumb hooks install claude-code` stamps every call". With the hook installed, the usual cause is a daemon too old to accept the key Claude desktop's connector passes through, so the advice sent people round in a - circle. The notice now adds that `plumb hooks` says why the call was not - stamped. + circle. The notice now adds that, if the hook is installed, `plumb hooks` + checks it and the daemon: it reports a missing or stale hook, and a daemon + that cannot take the stamp. - **`read_file` and `read_symbol` keep blank lines at the edges of what they return.** A `read_file` window whose first line was blank dropped it, so diff --git a/internal/tools/session_start_stamp.go b/internal/tools/session_start_stamp.go index 964a3db0..5c9750c4 100644 --- a/internal/tools/session_start_stamp.go +++ b/internal/tools/session_start_stamp.go @@ -66,12 +66,17 @@ func (t *SessionStart) WithStampChannel(fn func(ctx context.Context) StampChanne // plumb_agent, the hook works there too, so it is the remedy most callers can // apply. The transport remedy (one plumb serve per agent) follows, for a client // that cannot stamp at all. +// +// For a hook that is already installed it names bare `plumb hooks`, which +// reports a missing or stale hook and a daemon that cannot take the stamp. It +// says "checks", not "says why": with a current hook and a daemon that accepts +// the stamp it has nothing to report, so it cannot promise a diagnosis. const stampChannelRefusedNotice = "NOTE: state-changing calls from this session are being refused. " + "This connection serves more than one logical agent and this call carried no per-call identity, " + "so plumb cannot tell which agent's workspace a write belongs to and will not guess. Your session_id " + "declaration IS recorded, but it identifies this call only. Stamp every call: on Claude Code, " + - "`plumb hooks install claude-code` (on Claude desktop, restart the app after upgrading plumb); if it is " + - "already installed, `plumb hooks` says why this call was not stamped. A client whose transport can set " + + "`plumb hooks install claude-code` (on Claude desktop, restart the app after upgrading plumb); if the hook is " + + "installed, `plumb hooks` checks it and the daemon. A client whose transport can set " + "it sends a per-call _meta identity; otherwise run one plumb serve per agent.\n" // stampChannelDormantNotice is emitted when this call carried no per-call @@ -80,7 +85,7 @@ const stampChannelRefusedNotice = "NOTE: state-changing calls from this session const stampChannelDormantNotice = "NOTE: this call carried no per-call logical-agent identity. Nothing is " + "refused while you are the only agent on this connection, but once a second agent attaches, your " + "unstamped state-changing calls are refused. On Claude Code, `plumb hooks install claude-code` stamps " + - "every call; if it is already installed, `plumb hooks` says why this call was not stamped.\n" + "every call; if the hook is installed, `plumb hooks` checks it and the daemon.\n" // stampChannelNote renders the disclosure, or "" when there is nothing to say: // the accessor is unwired, or the channel is live. Rendered alongside diff --git a/internal/tools/session_start_stamp_test.go b/internal/tools/session_start_stamp_test.go index b574c4cc..9d9d9808 100644 --- a/internal/tools/session_start_stamp_test.go +++ b/internal/tools/session_start_stamp_test.go @@ -169,14 +169,23 @@ func TestStampChannelNoteSilentForNonHookClientUntilShared(t *testing.T) { // TestStampChannelNotices_PointAnInstalledHookAtItsDiagnosis: a caller whose // hook IS installed but whose call still arrived unstamped (a daemon too old to // accept the key Claude desktop's connector passes through) must not be sent to -// install it again. Both notices name `plumb hooks`, which reports the reason. +// install it again. Both notices name `plumb hooks`, which checks the hook and +// the daemon it talks to. +// +// They must not promise it says WHY: with a current hook and a daemon that +// accepts its stamp, bare `plumb hooks` has nothing to report (see +// TestIdentityHookSkewNote in internal/cli), and a promised diagnosis that does +// not come sends the reader round in a circle again. func TestStampChannelNotices_PointAnInstalledHookAtItsDiagnosis(t *testing.T) { for name, notice := range map[string]string{"refused": stampChannelRefusedNotice, "dormant": stampChannelDormantNotice} { if !strings.Contains(notice, "plumb hooks install claude-code") { t.Errorf("%s notice lost the install remedy: %q", name, notice) } - if !strings.Contains(notice, "if it is already installed, `plumb hooks` says why this call was not stamped") { + if !strings.Contains(notice, "if the hook is installed, `plumb hooks` checks it and the daemon.") { t.Errorf("%s notice does not point an installed hook at `plumb hooks`: %q", name, notice) } + if strings.Contains(notice, "says why") { + t.Errorf("%s notice promises a diagnosis bare `plumb hooks` does not always give: %q", name, notice) + } } }