package main // The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): `claude-code stop `, run by the // agent's own account before a session's turn is let end. It reads the session's mirrored waits file // directly (readWaitsFile, waits.go) — never the bus, never calling back into this module — so the decision // is local, under 5 s, and needs no network. // // Within its bound, a wait needs a watcher: refused while one is open and nothing follows it yet. Past its // bound, a wait needs a word to the operator, watched or not: refused until it is marked said. Refused at // most three times running for the same wait (a counter file beside the waits file, reset once the blocking // wait changes or clears); the fourth time it is let through, saying the wait was left unwatched. A missing, // unreadable or malformed waits file, or a panic, always allows. import ( "encoding/json" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "strings" "time" ) // StopInput is what the hook is given on stdin. type StopInput struct { SessionID string `json:"session_id"` TranscriptPath string `json:"transcript_path"` StopHookActive bool `json:"stop_hook_active"` } // MostStopRefusals is how many times in a row the Stop hook refuses for the same wait before it lets the // turn end anyway (1743/AC5). const MostStopRefusals = 3 // StopDecision is the Stop hook's verdict, and the refusal counters it leaves behind for the next call. type StopDecision struct { Refuse bool Reason string Counters map[string]int } // waitBlocksStop says whether w, on its own, keeps the turn from ending, and why (ADR 0489 §3-§4). func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) { if w.Said { return false, "" } if !w.PastBound(now) { if w.Watcher != "" { return false, "" } return true, fmt.Sprintf("wait %s (%s) is open and nothing follows it yet. Read where it stands with %s, then "+ "%s, and mark it watched: claude_code_wait "+ "{\"op\":\"watched\",\"session\":%q,\"by\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address, watcherAdvice(w), session, w.By, w.ID) } return true, fmt.Sprintf("wait %s (%s) is past its bound and the operator has not been told. Tell the operator what "+ "waits, since when, and what you are doing about it, then mark it: claude_code_wait "+ "{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID) } // watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a // family is a candidate for mesh_wait only when the tool runner is granted it (ADR 0367 §2), which this hook // cannot know, so it is offered conditionally; a watcher that rereads the read address always works. func watcherAdvice(w Wait) string { if w.Family != "" { return fmt.Sprintf("mesh_wait on %s if this session's tool runner is granted it, otherwise start a watcher "+ "that rereads %s", w.Family, w.Read.Address) } return fmt.Sprintf("start a watcher that rereads %s", w.Read.Address) } // DecideStop is the Stop hook's decision, pure of IO: f and readErr are what reading the session's waits // file gave, previous the refusal counters read beside it, and now the time. Only the first wait that still // blocks the stop is named; the others are read again next time. // // A missing file (review round 2, item 1) is the normal state for a session with no waits — every stop of // every such session, not an exceptional one — so it says nothing and simply allows, the same as a file that // reads back with no open waits. Only a file that exists but cannot be read, or does not parse, says so. func DecideStop(session string, f WaitsFile, readErr error, previous map[string]int, now time.Time) StopDecision { if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) { return StopDecision{Reason: fmt.Sprintf("the wait list could not be read (%v); allowed", readErr), Counters: map[string]int{}} } for _, w := range f.Waits { blocks, reason := waitBlocksStop(session, w, now) if !blocks { continue } count := previous[w.ID] + 1 if count > MostStopRefusals { return StopDecision{Reason: fmt.Sprintf("wait %s was refused %d times running and is let through: it was left "+ "unwatched (%s)", w.ID, MostStopRefusals, reason), Counters: map[string]int{}} } return StopDecision{Refuse: true, Reason: reason, Counters: map[string]int{w.ID: count}} } return StopDecision{Counters: map[string]int{}} } func refusalCountersPath(waitsDir, session string) string { return filepath.Join(waitsDir, session+".refusals.json") } func readRefusalCounters(waitsDir, session string) map[string]int { raw, err := os.ReadFile(refusalCountersPath(waitsDir, session)) if err != nil { return map[string]int{} } var m map[string]int if json.Unmarshal(raw, &m) != nil { return map[string]int{} } return m } // writeRefusalCounters keeps the counter file beside the waits file in step with m, through fsys like every // other write into the agent's home (novox/hq ADR 0266, issue 1749): called from the Stop hook, which // already runs as the agent's own account (fsys is then its zero value, a plain write or remove, as // before), and from the tool's AdoptName, which runs as the operator's account and must reach a confined // node's agent home through the sudo helper fsys carries such a write through. Its caller decides what an // error here means: harmless when the stop is already allowed, but when the stop was about to be refused, a // cap that cannot be written is a cap that cannot take effect (ADR 0489 §5 "never loops", review round 1 // item 2). // // Empty, m is removed rather than written as "{}" (issue 1749, follow-up): a stop with nothing to record — // every stop of every session with no waits, the ordinary case — must leave neither the file nor, the first // time, the waits folder itself; removing a file that was never there is not an error. func writeRefusalCounters(waitsDir, session string, m map[string]int) error { path := refusalCountersPath(waitsDir, session) if len(m) == 0 { if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { return err } return nil } raw, err := json.Marshal(m) if err != nil { return err } if err := fsys.MkdirAll(waitsDir, 0o700); err != nil { return err } return fsys.WriteFile(path, raw, 0o600) } // notice says a fail-open or let-through reason on both the log (stderr) and the screen: Claude Code shows a // hook's "systemMessage" on stdout, but not stderr, when the hook allows (review round 1, item a). func notice(stdout, stderr io.Writer, reason string) { fmt.Fprintln(stderr, reason) writeSystemMessage(stdout, reason) } // runStop is the hook: exit 2 with the reason on stderr refuses the stop, exit 0 allows (1743). func runStop(waitsDir string, stdin io.Reader, stdout, stderr io.Writer) (code int) { defer func() { if recover() != nil { code = 0 // a panic always allows (1743/AC6) } }() var in StopInput 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) { notice(stdout, stderr, fmt.Sprintf("the session id %q could not be used to find its waits (letters, digits, "+ "'.', '_' and '-' only); this stop is allowed", sessionID)) return 0 } f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID)) counters := readRefusalCounters(waitsDir, sessionID) d := DecideStop(sessionID, f, err, counters, time.Now()) if werr := writeRefusalCounters(waitsDir, sessionID, d.Counters); werr != nil && d.Refuse { // Item 2: writing the counter failed on the very call that would have incremented it toward the cap; // refusing anyway could hold the turn forever, so this call is let through instead. notice(stdout, stderr, fmt.Sprintf("this wait's refusal counter could not be written (%v); allowed so it "+ "cannot hold the turn forever", werr)) return 0 } if d.Reason != "" { fmt.Fprintln(stderr, d.Reason) if !d.Refuse { writeSystemMessage(stdout, d.Reason) // item a: a fail-open or let-through notice also reaches the screen } } if d.Refuse { return 2 } return 0 }