Add the plugin's SessionStart hook so a session learns its own id (issue 1744)

A session is never told its session id by any tool, only by a hook's
stdin, so it cannot name itself to claude_code_wait. The plugin's
SessionStart hook (claude-code session-start <waits-dir>) prints the id
and how to give it, plus the session's still-open waits after a /clear,
reading the waits file claude_code_wait mirrors directly rather than
calling back into this module. Carries the Stop hook's command and the
GuardFiles.WaitsDir plumbing MarketplaceWith needs for both (issue 1743
wires the Stop hook's dispatch next).
This commit is contained in:
jochen
2026-10-21 18:07:36 +02:00
parent 56c68be7fa
commit 37628f4bce
6 changed files with 198 additions and 6 deletions
@@ -0,0 +1,85 @@
package main
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744).
import (
"bytes"
"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") }
// 1744/AC4 is checked together with 1743/AC7 in terminal_only_test.go's
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook.
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))
}