package main // The plugin's SessionStart hook (novox/hq ADR 0489 §1, ADR 0490 §2, 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 this session id's own still-open waits (if // any name is already bound to it) and, always, every name on this node that still holds an open wait, so a // session with a brand new id (a /clear gives one) can see a name it may want to adopt. // // It reads the session's mirrored waits file and the node-wide index directly — the same local reads the // Stop hook and claude_code_wait make (readWaitsFile, readNodeIndex, 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 already bound to this session id (1744/AC1, AC2), then every name on this node // that still holds an open wait, with how to adopt it (ADR 0490 §2). Pure of IO beyond the two file reads. 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) if f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID)); err == nil && len(f.Waits) > 0 { b.WriteString("Waits already bound to this session's own id:\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) } } if idx, err := readNodeIndex(nodeIndexPathIn(waitsDir)); err == nil && len(idx.Names) > 0 { b.WriteString("Open waits on this node, by name:\n") for _, n := range idx.Names { fmt.Fprintf(&b, " %s: %d open wait(s), last called %s. If this is you: claude_code_wait "+ "{\"op\":\"adopt\",\"by\":%q,\"session\":%q}\n", n.Name, n.Open, n.LastCall.Round(time.Second).Format(time.RFC3339), n.Name+"@"+idx.Node, sessionID) } } 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 { return 0 } sessionID := strings.TrimSpace(in.SessionID) if sessionID == "" { return 0 } // Review round 1, item 1: a session id is never used to build a path until it is checked against // waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir. if !waitSessionID.MatchString(sessionID) { writeSystemMessage(stdout, fmt.Sprintf("this session's id (%q) could not be used to find its waits "+ "(letters, digits, '.', '_' and '-' only)", sessionID)) return 0 } fmt.Fprint(stdout, SessionStartMessage(sessionID, waitsDir, time.Now())) return 0 }