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:
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744): `claude-code session-start <waits-dir>`,
|
||||
// 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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user