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.
87 lines
3.6 KiB
Go
87 lines
3.6 KiB
Go
package main
|
|
|
|
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, ADR 0490 §2, 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 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
|
|
}
|