mesh/merge-gate head e3a714d5f0f0 merged into trunk c3564c1accf3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head e3a714d5f0f0 merged into trunk c3564c1accf3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
writeRefusalCounters wrote "{}" and, since it now routes through fsys
(this branch's earlier commit), created the waits folder on every single
stop, even for a session that never had a wait: one counter file per
session id, piling up on a node that uses the wait list at all. It now
removes the file instead of writing empty counters, a no-op when there
was never one to begin with, so a stop with nothing to record leaves
neither the file nor the folder; a refusal still writes it, and the
stop right after the wait is done (or let through) removes it again.
196 lines
8.3 KiB
Go
196 lines
8.3 KiB
Go
package main
|
|
|
|
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): `claude-code stop <waits-dir>`, 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
|
|
}
|