diff --git a/modules/claude-code/cmd/claude-code/config.go b/modules/claude-code/cmd/claude-code/config.go index 5c5e7721..a2993df5 100644 --- a/modules/claude-code/cmd/claude-code/config.go +++ b/modules/claude-code/cmd/claude-code/config.go @@ -476,10 +476,13 @@ type PluginFile struct { // what it is given. Outside hooks/, so no registered hook's name can be it. const GuardDir = "guard" -// GuardFiles are the guard as the plugin carries it: the binary to copy and the data it judges with. +// GuardFiles are the guard as the plugin carries it: the binary to copy, what it judges with, and — the same +// binary again — the waits directory the SessionStart and Stop hooks are given (novox/hq ADR 0489, issues +// 1744, 1743). Empty WaitsDir places no such hook. type GuardFiles struct { - Exe string - Data GuardData + Exe string + Data GuardData + WaitsDir string } // GuardMatcher is what the guard is asked about: every tool. Its own rules judge the shell and the agent's file @@ -495,6 +498,20 @@ func GuardCommand() string { return `"` + filepath.Join(dir, "guard") + `" guard "` + filepath.Join(dir, "guard.json") + `"` } +// SessionStartCommand is the plugin's SessionStart hook: the guard's own binary, told the session's open +// waits live under waitsDir (novox/hq ADR 0489 §3, issue 1744). +func SessionStartCommand(waitsDir string) string { + dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir) + return `"` + filepath.Join(dir, "guard") + `" session-start "` + waitsDir + `"` +} + +// StopCommand is the plugin's Stop hook: the guard's own binary, refusing to end the turn while a wait under +// waitsDir is open and unwatched, or past its bound and untold (novox/hq ADR 0489 §3-§6, issue 1743). +func StopCommand(waitsDir string) string { + dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir) + return `"` + filepath.Join(dir, "guard") + `" stop "` + waitsDir + `"` +} + // Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid // over a mesh item of the same kind and name: the node's wins. func Marketplace(c Config) map[string]PluginFile { return MarketplaceWith(c, nil) } @@ -533,6 +550,14 @@ func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile { out[root+GuardDir+"/guard.json"] = PluginFile{Content: jsonFile(guard.Data)} hooks["PreToolUse"] = append(hooks["PreToolUse"], map[string]any{"matcher": GuardMatcher, "hooks": []any{map[string]any{"type": "command", "command": GuardCommand(), "timeout": 15}}}) + if guard.WaitsDir != "" { + // A session's wait list (novox/hq ADR 0489): the SessionStart hook names the session's id and its open + // waits (issue 1744); the Stop hook keeps the turn open while one is unwatched or untold (issue 1743). + hooks["SessionStart"] = append(hooks["SessionStart"], map[string]any{"matcher": "startup|resume|clear|compact", + "hooks": []any{map[string]any{"type": "command", "command": SessionStartCommand(guard.WaitsDir), "timeout": 10}}}) + hooks["Stop"] = append(hooks["Stop"], map[string]any{ + "hooks": []any{map[string]any{"type": "command", "command": StopCommand(guard.WaitsDir), "timeout": 5}}}) + } } for _, k := range keys { it := chosen[k] diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index fe0b702e..04e81ff8 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -417,6 +417,10 @@ func main() { if len(os.Args) == 3 && os.Args[1] == "guard" { os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr)) } + // The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744): names the session's id and its open waits. + if len(os.Args) == 3 && os.Args[1] == "session-start" { + os.Exit(runSessionStart(os.Args[2], os.Stdin, os.Stdout)) + } // The helper that touches the agent's home as the agent's account (novox/hq ADR 0266), started by this // module through sudo: one answer per request on stdin, until it ends. if len(os.Args) == 2 && os.Args[1] == HelperArg { diff --git a/modules/claude-code/cmd/claude-code/mesh.go b/modules/claude-code/cmd/claude-code/mesh.go index 71b01b93..91229d23 100644 --- a/modules/claude-code/cmd/claude-code/mesh.go +++ b/modules/claude-code/cmd/claude-code/mesh.go @@ -278,7 +278,7 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles { if resolved, err := filepath.EvalSymlinks(exe); err == nil { exe = resolved } - return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m)} + return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)} } // MeshToolsEvery is how often the controller is asked again. diff --git a/modules/claude-code/cmd/claude-code/session_start.go b/modules/claude-code/cmd/claude-code/session_start.go new file mode 100644 index 00000000..2fbee58b --- /dev/null +++ b/modules/claude-code/cmd/claude-code/session_start.go @@ -0,0 +1,68 @@ +package main + +// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744): `claude-code session-start `, +// run by the agent's own account on every start, resume, /clear and /compact. The tool runner never tells a +// session its own id (only a hook's stdin carries session_id), so a session cannot name itself to +// claude_code_wait until this hook has told it. It also lists the session's still-open waits, so a session +// that /clear-ed sees what it was watching before. +// +// It reads the session's mirrored waits file directly — the same local read the Stop hook makes +// (readWaitsFile, waits.go) — never calling back into this module: this hook runs on every start, and must +// not wait on the bus to do it. A missing or malformed file is not an error here: there is simply nothing +// open to report. + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" +) + +// SessionStartInput is what the hook is given on stdin. +type SessionStartInput struct { + SessionID string `json:"session_id"` +} + +// SessionStartMessage is the line the hook prints: the session's id and how to give it to claude_code_wait, +// then each still-open wait from before (1744/AC1, AC2). Pure of IO beyond the one file read. +func SessionStartMessage(sessionID, waitsDir string, now time.Time) string { + var b strings.Builder + fmt.Fprintf(&b, "This session's id is %s; give it as `session` to claude_code_wait.\n", sessionID) + f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID)) + + if err != nil || len(f.Waits) == 0 { + return b.String() + } + b.WriteString("Waits still open from before this session's last start, resume, /clear or /compact:\n") + for _, w := range f.Waits { + age := now.Sub(w.At).Round(time.Second) + state := "no watcher yet" + if w.Watcher != "" { + state = "watched" + } + if w.PastBound(now) { + state += ", past its bound" + if w.Said { + state += ", said" + } + } + fmt.Fprintf(&b, " %s — %s (%s ago, %s)\n", w.ID, w.What, age, state) + } + return b.String() +} + +// runSessionStart is the hook: the session's id and its open waits out, exit 0 always (1744/AC3). +func runSessionStart(waitsDir string, stdin io.Reader, stdout io.Writer) (code int) { + defer func() { + if recover() != nil { + code = 0 // never fails the session's start + } + }() + var in SessionStartInput + if err := json.NewDecoder(stdin).Decode(&in); err != nil || strings.TrimSpace(in.SessionID) == "" { + return 0 + } + fmt.Fprint(stdout, SessionStartMessage(in.SessionID, waitsDir, time.Now())) + return 0 +} diff --git a/modules/claude-code/cmd/claude-code/session_start_test.go b/modules/claude-code/cmd/claude-code/session_start_test.go new file mode 100644 index 00000000..a6289166 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/session_start_test.go @@ -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)) +} diff --git a/modules/claude-code/cmd/claude-code/waits.go b/modules/claude-code/cmd/claude-code/waits.go index 3c7e7639..23db64da 100644 --- a/modules/claude-code/cmd/claude-code/waits.go +++ b/modules/claude-code/cmd/claude-code/waits.go @@ -130,9 +130,15 @@ var waiting *Waiting // nodes never collide over the same session id. func waitsKey(p Paths, session string) string { return p.Node + "/" + session } -// waitsFilePath is where one session's waits are mirrored, under the agent's home. +// WaitsDirPath is where this node's sessions' waits are mirrored, under the agent's home: the directory the +// plugin's SessionStart and Stop hooks (1744, 1743) are given on their command line. +func WaitsDirPath(p Paths) string { + return filepath.Join(p.Home, ".claude", filepath.FromSlash(WaitsDir)) +} + +// waitsFilePath is where one session's waits are mirrored. func waitsFilePath(p Paths, session string) string { - return filepath.Join(p.Home, ".claude", filepath.FromSlash(WaitsDir), session+".json") + return filepath.Join(WaitsDirPath(p), session+".json") } func newWaitID() string { @@ -396,6 +402,10 @@ func ListWaits(p Paths, session string, store WaitStore, now time.Time) (map[str return map[string]any{"session": session, "open": out}, nil } +// waitsFileIn is where one session's mirrored waits are, given a hook's own waits-dir argument: the hooks +// have no launch environment, only their command line, so they never recompute this from Paths. +func waitsFileIn(waitsDir, session string) string { return filepath.Join(waitsDir, session+".json") } + // readWaitsFile reads one session's mirrored file, as the hooks do: a plain local read, as the session's own // account (never through fsys or the module's state), and never calling back into this module. func readWaitsFile(path string) (WaitsFile, error) {