Claude Code gives a session a new session_id at /clear, with nothing naming the earlier one, so keying claude_code_wait's state by session id lost a session's waits the moment it cleared. ADR 0490 changes ADR 0489 §1: waits are now kept under the caller's name and node (by, who@node); every op also takes session, the caller's current id, and the module keeps a binding of the name to the session that last called for it. An unbound name is claimed by the first call for it; a session whose own id no longer matches the binding is refused on every op, naming the session that holds it, until it calls the new op adopt, which always takes the name over at once (no bound protects a recent call) and tells the earlier session so on its own next call. The mirrored file a hook reads stays keyed by session id (a hook only ever has that), holding whichever name's waits are currently bound to it; adopting rewrites both the old id's file (emptied) and the new id's (filled) in the same call. A second mirrored file, the node-wide index, lists every name on the node with an open wait; the SessionStart hook now also reads it, on every start, and prints the exact adopt call for each name, so a session with a brand new id can find and reclaim its own waits without calling back into the module.
152 lines
6.1 KiB
Go
152 lines
6.1 KiB
Go
package main
|
|
|
|
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744).
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// 1744/AC1: the hook prints the session's id and how to give it to claude_code_wait.
|
|
func TestSessionStartPrintsTheSessionsIDAndHowToGiveIt(t *testing.T) {
|
|
waitsDir := t.TempDir()
|
|
stdin := strings.NewReader(`{"session_id":"sess-9","hook_event_name":"SessionStart","source":"startup"}`)
|
|
var out bytes.Buffer
|
|
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
|
|
t.Fatalf("code %d, stdout %s", code, out.String())
|
|
}
|
|
if !strings.Contains(out.String(), "sess-9") || !strings.Contains(out.String(), "claude_code_wait") {
|
|
t.Fatalf("stdout does not say how to name the session: %s", out.String())
|
|
}
|
|
}
|
|
|
|
// 1744/AC2: when the waits file holds open waits, it prints each (what, age, past bound or not).
|
|
func TestSessionStartListsOpenWaitsAfterAClear(t *testing.T) {
|
|
waitsDir := t.TempDir()
|
|
at := time.Date(2026, 10, 21, 10, 0, 0, 0, time.UTC)
|
|
f := WaitsFile{Session: "sess-10", At: at, Waits: []Wait{
|
|
{ID: "w-open", What: "the operator's Approve", Bound: 10, At: at},
|
|
{ID: "w-past", What: "a delivery to finish", Bound: 1, At: at},
|
|
}}
|
|
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-10.json"), f)
|
|
|
|
now := at.Add(5 * time.Minute)
|
|
msg := SessionStartMessage("sess-10", waitsDir, now)
|
|
if !strings.Contains(msg, "w-open") || !strings.Contains(msg, "the operator's Approve") || !strings.Contains(msg, "no watcher") {
|
|
t.Fatalf("the open, unwatched wait is not described:\n%s", msg)
|
|
}
|
|
if !strings.Contains(msg, "w-past") || !strings.Contains(msg, "past its bound") {
|
|
t.Fatalf("the past-bound wait is not described:\n%s", msg)
|
|
}
|
|
if !strings.Contains(msg, "5m0s ago") {
|
|
t.Fatalf("the age is not said:\n%s", msg)
|
|
}
|
|
}
|
|
|
|
// 1744/AC3: a missing or malformed file prints only the id line, and the hook never fails the session start.
|
|
func TestSessionStartNeverFailsOnAMissingOrBadFile(t *testing.T) {
|
|
waitsDir := t.TempDir()
|
|
if msg := SessionStartMessage("sess-11", waitsDir, time.Now()); !strings.Contains(msg, "sess-11") || strings.Contains(msg, "open") {
|
|
t.Fatalf("a missing file printed more than the id line: %q", msg)
|
|
}
|
|
writeFile(t, filepath.Join(waitsDir, "sess-12.json"), "{not json")
|
|
if msg := SessionStartMessage("sess-12", waitsDir, time.Now()); !strings.Contains(msg, "sess-12") || strings.Contains(msg, "open") {
|
|
t.Fatalf("a malformed file printed more than the id line: %q", msg)
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
if code := runSessionStart(waitsDir, strings.NewReader("not json at all"), &out); code != 0 {
|
|
t.Fatalf("unreadable stdin failed the session start: code %d", code)
|
|
}
|
|
var out2 bytes.Buffer
|
|
if code := runSessionStart(waitsDir, panicReader{}, &out2); code != 0 {
|
|
t.Fatalf("a panic was not recovered: code %d", code)
|
|
}
|
|
}
|
|
|
|
// panicReader panics on Read, as a stuck pipe or a bug reading stdin might.
|
|
type panicReader struct{}
|
|
|
|
func (panicReader) Read([]byte) (int, error) { panic("boom") }
|
|
|
|
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one is never used to
|
|
// build a file path — reading outside the waits dir never happens — and the hook prints only a line saying
|
|
// the id could not be used.
|
|
func TestSessionStartRejectsAnUnsafeSessionID(t *testing.T) {
|
|
root := t.TempDir()
|
|
waitsDir := filepath.Join(root, "agent", "waits")
|
|
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// What "../../x" resolves to from waitsDir: root/x.json, outside waitsDir, with content that must never be
|
|
// read through it.
|
|
outside := filepath.Join(waitsDir, "../../x.json")
|
|
writeWaitsFixture(t, outside, WaitsFile{Session: "x", At: time.Now(),
|
|
Waits: []Wait{{ID: "w-secret", What: "SECRET-DATA", Bound: 10, At: time.Now()}}})
|
|
t.Cleanup(func() { _ = os.Remove(outside) })
|
|
|
|
for _, id := range []string{"../../x", "a/b"} {
|
|
var out bytes.Buffer
|
|
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q}`, id))
|
|
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
|
|
t.Fatalf("id %q: code %d", id, code)
|
|
}
|
|
if strings.Contains(out.String(), "SECRET-DATA") {
|
|
t.Fatalf("id %q: read a file outside the waits dir: %s", id, out.String())
|
|
}
|
|
if !strings.Contains(out.String(), "could not be used") {
|
|
t.Fatalf("id %q: did not say the id could not be used: %s", id, out.String())
|
|
}
|
|
// Item (a): the notice reaches the screen as the one JSON object Claude Code shows.
|
|
if !strings.Contains(out.String(), "systemMessage") {
|
|
t.Fatalf("id %q: the notice is not a systemMessage: %s", id, out.String())
|
|
}
|
|
}
|
|
var out bytes.Buffer
|
|
if code := runSessionStart(waitsDir, strings.NewReader(`{"session_id":""}`), &out); code != 0 || out.Len() != 0 {
|
|
t.Fatalf("blank id: code %d, out %q", code, out.String())
|
|
}
|
|
}
|
|
|
|
// 1744/AC4 is checked together with 1743/AC7 in terminal_only_test.go's
|
|
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook.
|
|
|
|
// ADR 0490 §2: every session start lists every name on this node with an open wait, from the node-wide
|
|
// index, with the exact adopt call to make if it is yours — read directly, never by calling back into this
|
|
// module.
|
|
func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
|
|
waitsDir := t.TempDir()
|
|
lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC)
|
|
idx := NodeIndex{Node: "g14", At: lastCall, Names: []NodeIndexEntry{
|
|
{Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall},
|
|
}}
|
|
raw, err := indented(idx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, filepath.Join(waitsDir, "_index.json"), string(raw))
|
|
|
|
msg := SessionStartMessage("sess-brand-new", waitsDir, lastCall.Add(5*time.Minute))
|
|
if !strings.Contains(msg, "fix-1629") || !strings.Contains(msg, "2 open wait") {
|
|
t.Fatalf("the name and its count are not listed:\n%s", msg)
|
|
}
|
|
want := `claude_code_wait {"op":"adopt","by":"fix-1629@g14","session":"sess-brand-new"}`
|
|
if !strings.Contains(msg, want) {
|
|
t.Fatalf("the exact adopt call is not given:\n%s\nwant it to contain:\n%s", msg, want)
|
|
}
|
|
}
|
|
|
|
func writeWaitsFixture(t *testing.T, path string, f WaitsFile) {
|
|
t.Helper()
|
|
raw, err := indented(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, path, string(raw))
|
|
}
|