Files
mesh-catalog/modules/claude-code/cmd/claude-code/waits.go
T
jochen 56c68be7fa Keep a session's wait list in the claude-code module (issue 1629)
A session can end its turn while an Approve it expects is still open and
nothing then watches it. claude_code_wait (add/watched/done/said/list)
keeps each session's waits in the module's waits state, keyed by its
session id and node, and mirrors them whole and atomically to a file
under the agent's home that the plugin's coming hooks (1743, 1744) will
read without calling back into this module.
2026-10-21 17:59:10 +02:00

484 lines
18 KiB
Go

package main
// A session's wait list (novox/hq ADR 0489): what a session still expects an answer to — an Approve, a
// delivery's end, anything it started and must act on when it ends (follow-what-you-start) — so the
// plugin's Stop hook can refuse to end its turn while one is open and nothing watches it.
//
// Kept in the module's `waits` state under the session's id and this node (so the same session reads its
// waits again after a /clear, and after the module restarts), and mirrored whole, atomically, to a file the
// hooks read without calling back into this module: <agent home>/.claude/nox-mesh/waits/<session>.json. That
// path is under the agent's home (the manifest's `agent-home` resource), which this process — launched as
// the operator's account (novox/hq ADR 0193) — cannot itself write when the node names an agent account of
// its own (novox/hq ADR 0266): every write below goes through `fsys` (homefs.go), which already carries such
// a write through the sudo helper the guard's binary also serves, exactly as grant.go's WriteCredentials
// does for the agent's own credentials file. Where the node has no agent account of its own, fsys is the
// plain filesystem and the write reaches the operator's own home directly.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// WaitsState is the module's state the wait lists are kept in.
const WaitsState = "waits"
// WaitsDir is where the mirrored files live under the agent's home.
const WaitsDir = "nox-mesh/waits"
// WaitPrefix starts every wait's id.
const WaitPrefix = "w-"
// MostWaits is how many open waits one session may hold at once (novox/hq ADR 0489, 1629/AC5).
const MostWaits = 50
// The bounds of a wait's bound, in minutes (1629/AC5).
const (
ShortestWaitBound = 1
LongestWaitBound = 1440
)
// mostWaitAttempts bounds the compare-and-set retry loop over the `waits` state.
const mostWaitAttempts = 20
var waitSessionID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
// WaitRead is the verb a session reads to see where one wait stands.
type WaitRead struct {
Address string `json:"address"`
Arguments map[string]any `json:"arguments,omitempty"`
}
// Wait is one answer a session wrote down that it expects (novox/hq ADR 0489 §1-§2).
type Wait struct {
ID string `json:"id"`
By string `json:"by"`
What string `json:"what"`
Read WaitRead `json:"read"`
Family string `json:"family,omitempty"`
Bound int `json:"bound"` // minutes
At time.Time `json:"at"`
Watcher string `json:"watcher,omitempty"`
Said bool `json:"said,omitempty"`
}
// PastBound says whether now is past this wait's bound.
func (w Wait) PastBound(now time.Time) bool {
return now.Sub(w.At) > time.Duration(w.Bound)*time.Minute
}
// SessionWaits is what the `waits` state keeps for one session: its open waits.
type SessionWaits struct {
Session string `json:"session"`
Node string `json:"node"`
Waits []Wait `json:"waits"`
}
// WaitsFile is what the mirrored file holds, whole, on every change (1629/AC3): the session's open waits and
// the time they were written.
type WaitsFile struct {
Session string `json:"session"`
At time.Time `json:"at"`
Waits []Wait `json:"waits"`
}
// WaitStore is the `waits` state: a value with its revision, created only when absent, updated only at the
// revision read.
type WaitStore interface {
Get(key string) (json.RawMessage, uint64, bool, error)
Create(key string, value any) error
Update(key string, value any, revision uint64) error
}
// keptWaits is the `waits` state on the bus.
type keptWaits struct{ s stdio.KeptState }
func (k keptWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
e, err := k.s.Get(key)
if err != nil || e == nil {
return nil, 0, false, err
}
return e.Value, e.Revision, true, nil
}
func (k keptWaits) Create(key string, value any) error {
_, err := k.s.Create(key, value)
return err
}
func (k keptWaits) Update(key string, value any, revision uint64) error {
_, err := k.s.Update(key, value, revision)
return err
}
// Waiting is what claude_code_wait reaches: the `waits` state and the clock. Nil outside a launch.
type Waiting struct {
Store WaitStore
Now func() time.Time
}
var waiting *Waiting
// waitsKey is the state's key for one session's waits: its id and this node (novox/hq ADR 0489 §1), so two
// 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.
func waitsFilePath(p Paths, session string) string {
return filepath.Join(p.Home, ".claude", filepath.FromSlash(WaitsDir), session+".json")
}
func newWaitID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return WaitPrefix + hex.EncodeToString(b[:])
}
// addressShaped says whether s could be an address `mesh_call` takes: not blank, no whitespace, and a dot
// between two names (`<seat>.<verb>`, `<node>/<seat>.<verb>`, `<node>/<module>.<tool>`).
func addressShaped(s string) bool {
s = strings.TrimSpace(s)
if s == "" || strings.ContainsAny(s, " \t\n\r") {
return false
}
dot := strings.LastIndex(s, ".")
return dot > 0 && dot < len(s)-1
}
func readSessionWaits(store WaitStore, key string) (SessionWaits, uint64, bool, error) {
raw, rev, found, err := store.Get(key)
if err != nil || !found {
return SessionWaits{}, 0, found, err
}
var sw SessionWaits
_ = json.Unmarshal(raw, &sw)
return sw, rev, true, nil
}
// updateWaits reads one session's waits, lets mutate change them, and writes the result back at the
// revision read — created when there was none yet — retried when another write won the race.
func updateWaits(store WaitStore, key string, mutate func(SessionWaits) (SessionWaits, error)) (SessionWaits, error) {
for i := 0; i < mostWaitAttempts; i++ {
sw, rev, found, err := readSessionWaits(store, key)
if err != nil {
return SessionWaits{}, fmt.Errorf("the wait list could not be read: %w", err)
}
next, err := mutate(sw)
if err != nil {
return SessionWaits{}, err
}
if !found {
err = store.Create(key, next)
} else {
err = store.Update(key, next, rev)
}
if err == nil {
return next, nil
}
if errors.Is(err, stdio.ErrChanged) {
continue
}
return SessionWaits{}, fmt.Errorf("the wait list could not be written: %w", err)
}
return SessionWaits{}, errors.New("the wait list kept changing elsewhere; try again")
}
// writeWaitsFile rewrites the session's mirrored file whole, atomically (temp file, then rename): it holds
// exactly the open waits and the time of this write (1629/AC3).
func writeWaitsFile(p Paths, session string, sw SessionWaits, now time.Time) error {
path := waitsFilePath(p, session)
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
waits := sw.Waits
if waits == nil {
waits = []Wait{}
}
raw, err := indented(WaitsFile{Session: session, At: now.UTC(), Waits: waits})
if err != nil {
return err
}
tmp := path + ".mesh-tmp"
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return fsys.Rename(tmp, path)
}
// sessionAndNode reads `by` as claude_code_ask does: who@node, where node is this one.
func sessionAndNode(p Paths, by string) (string, error) {
by = strings.TrimSpace(by)
if who, node, found := strings.Cut(by, "@"); found {
if node != p.Node {
return "", fmt.Errorf("by names the node %q, and this is %s: add the wait on the node the session runs on", node, p.Node)
}
return who, nil
}
if by == "" {
return "", errors.New("by is required: your session's name, as who@node or who")
}
return by, nil
}
// WaitInput is what claude_code_wait's add takes.
type WaitInput struct {
Session string
By string
What string
Read WaitRead
Family string
Bound int
}
// AddWait keeps one more wait for a session, refusing outside ADR 0489's bounds (1629/AC5), and rewrites the
// session's mirrored file (1629/AC3).
func AddWait(p Paths, in WaitInput, store WaitStore, now time.Time) (map[string]any, error) {
session := strings.TrimSpace(in.Session)
if !waitSessionID.MatchString(session) {
return nil, errors.New("session is required: the id the SessionStart hook named")
}
by, err := sessionAndNode(p, in.By)
if err != nil {
return nil, err
}
what := strings.TrimSpace(in.What)
if what == "" {
return nil, errors.New("what is required: say what you are waiting for")
}
if in.Bound < ShortestWaitBound || in.Bound > LongestWaitBound {
return nil, fmt.Errorf("bound_minutes is %d; it is %d to %d", in.Bound, ShortestWaitBound, LongestWaitBound)
}
address := strings.TrimSpace(in.Read.Address)
if !addressShaped(address) {
return nil, fmt.Errorf("read.address %q is not an address: the verb that reads where this wait stands, as mesh_call takes it", in.Read.Address)
}
w := Wait{ID: newWaitID(), By: by, What: what, Read: WaitRead{Address: address, Arguments: in.Read.Arguments},
Family: strings.TrimSpace(in.Family), Bound: in.Bound, At: now.UTC()}
key := waitsKey(p, session)
sw, err := updateWaits(store, key, func(sw SessionWaits) (SessionWaits, error) {
if len(sw.Waits) >= MostWaits {
return sw, fmt.Errorf("%d waits already wait for session %s; at most %d open waits per session", len(sw.Waits), session, MostWaits)
}
sw.Session, sw.Node = session, p.Node
sw.Waits = append(append([]Wait(nil), sw.Waits...), w)
return sw, nil
})
if err != nil {
return nil, err
}
if err := writeWaitsFile(p, session, sw, now); err != nil {
return nil, fmt.Errorf("the wait was kept, but its file could not be written: %w", err)
}
say("wait %s added for session %s: %s", w.ID, session, what)
return map[string]any{"id": w.ID, "session": session, "open": len(sw.Waits)}, nil
}
// findWait is the index of id in sw.Waits, or -1.
func findWait(sw SessionWaits, id string) int {
for i, w := range sw.Waits {
if w.ID == id {
return i
}
}
return -1
}
// WatchedWait marks a wait as followed: by mesh_wait on its family, or a watcher started for it (1629/AC2).
func WatchedWait(p Paths, session, id, watcher string, store WaitStore, now time.Time) (map[string]any, error) {
session, id, watcher = strings.TrimSpace(session), strings.TrimSpace(id), strings.TrimSpace(watcher)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
if watcher == "" {
return nil, errors.New("watcher is required: what follows this wait (mesh_wait on its family, or a watcher agent)")
}
key := waitsKey(p, session)
sw, err := updateWaits(store, key, func(sw SessionWaits) (SessionWaits, error) {
i := findWait(sw, id)
if i < 0 {
return sw, fmt.Errorf("no open wait %s for session %s", id, session)
}
sw.Waits[i].Watcher = watcher
return sw, nil
})
if err != nil {
return nil, err
}
if err := writeWaitsFile(p, session, sw, now); err != nil {
return nil, fmt.Errorf("the wait was watched, but its file could not be written: %w", err)
}
return map[string]any{"id": id, "watcher": watcher}, nil
}
// DoneWait removes a wait from the open list: it ended, with outcome (1629/AC2).
func DoneWait(p Paths, session, id, outcome string, store WaitStore, now time.Time) (map[string]any, error) {
session, id, outcome = strings.TrimSpace(session), strings.TrimSpace(id), strings.TrimSpace(outcome)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
key := waitsKey(p, session)
sw, err := updateWaits(store, key, func(sw SessionWaits) (SessionWaits, error) {
i := findWait(sw, id)
if i < 0 {
return sw, fmt.Errorf("no open wait %s for session %s", id, session)
}
sw.Waits = append(append([]Wait(nil), sw.Waits[:i]...), sw.Waits[i+1:]...)
return sw, nil
})
if err != nil {
return nil, err
}
if err := writeWaitsFile(p, session, sw, now); err != nil {
return nil, fmt.Errorf("the wait was done, but its file could not be written: %w", err)
}
say("wait %s for session %s done: %s", id, session, outcome)
return map[string]any{"id": id, "done": true, "outcome": outcome}, nil
}
// SaidWait marks a wait past its bound as told to the operator: refused while it is still within its bound
// (1629/AC2).
func SaidWait(p Paths, session, id string, store WaitStore, now time.Time) (map[string]any, error) {
session, id = strings.TrimSpace(session), strings.TrimSpace(id)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
key := waitsKey(p, session)
sw, err := updateWaits(store, key, func(sw SessionWaits) (SessionWaits, error) {
i := findWait(sw, id)
if i < 0 {
return sw, fmt.Errorf("no open wait %s for session %s", id, session)
}
if !sw.Waits[i].PastBound(now) {
return sw, fmt.Errorf("wait %s is not past its bound yet: said is for telling the operator about one that is", id)
}
sw.Waits[i].Said = true
return sw, nil
})
if err != nil {
return nil, err
}
if err := writeWaitsFile(p, session, sw, now); err != nil {
return nil, fmt.Errorf("the wait was said, but its file could not be written: %w", err)
}
return map[string]any{"id": id, "said": true}, nil
}
// ListWaits answers a session's open waits, with their age and whether they are past their bound (1629/AC1).
func ListWaits(p Paths, session string, store WaitStore, now time.Time) (map[string]any, error) {
session = strings.TrimSpace(session)
if session == "" {
return nil, errors.New("session is required: the id the SessionStart hook named")
}
sw, _, _, err := readSessionWaits(store, waitsKey(p, session))
if err != nil {
return nil, fmt.Errorf("the wait list could not be read: %w", err)
}
out := []map[string]any{}
for _, w := range sw.Waits {
entry := map[string]any{"id": w.ID, "what": w.What, "by": w.By, "address": w.Read.Address,
"bound_minutes": w.Bound, "at": w.At.Format(time.RFC3339), "age_seconds": int(now.Sub(w.At).Seconds()),
"past_bound": w.PastBound(now), "watcher": w.Watcher, "said": w.Said}
if len(w.Read.Arguments) > 0 {
entry["arguments"] = w.Read.Arguments
}
if w.Family != "" {
entry["family"] = w.Family
}
out = append(out, entry)
}
return map[string]any{"session": session, "open": out}, nil
}
// 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) {
raw, err := os.ReadFile(path)
if err != nil {
return WaitsFile{}, err
}
var f WaitsFile
if err := json.Unmarshal(raw, &f); err != nil {
return WaitsFile{}, err
}
return f, nil
}
// waitTools is claude_code_wait: add, watched, done, said and list, over one session's wait list (novox/hq
// ADR 0489).
func waitTools(p Paths) []stdio.Tool {
notLaunched := errors.New("this module was not launched with the bus; no wait can be kept")
return []stdio.Tool{
{Name: "claude_code_wait",
Description: "Your session's wait list (novox/hq ADR 0489): what you still expect an answer to — an Approve, a " +
"delivery's end, anything you started and must act on when it ends. The plugin's Stop hook refuses to end " +
"your turn while a wait here is open and nothing follows it. op is add, watched, done, said or list; session " +
"is always required (the id the SessionStart hook named, or printed again after a /clear). add takes by " +
"(your session's name, as who@node or who), what (what you are waiting for), read (the verb that answers " +
"where it stands: {address, arguments}), family (optional, the event mesh_wait can wait on) and bound_minutes " +
"(1 to 1440) and answers an id; at most 50 waits are open per session. watched takes id and watcher (what " +
"follows it: mesh_wait on its family, or a watcher agent you started). done takes id and outcome, and removes " +
"it from the open list. said takes id and marks a wait past its bound as told to the operator — refused " +
"while it is still within its bound. list answers the open waits, with their age and whether each is past " +
"its bound.",
Input: map[string]any{
"op": str("add, watched, done, said or list"),
"session": str("required: the session's id"),
"by": str("add: your session's name, as who@node or who"),
"what": str("add: what you are waiting for, in plain words"),
"read": map[string]any{"type": "object", "description": "add: the verb that reads where this wait stands",
"properties": map[string]any{
"address": str("the address, as mesh_call takes it"),
"arguments": map[string]any{"type": "object", "description": "its arguments"},
}},
"family": str("add: the event family mesh_wait can wait on, when there is one"),
"bound_minutes": map[string]any{"type": "integer", "description": "add: minutes until this wait's bound, 1 to 1440"},
"id": str("watched, done or said: the id add answered"),
"watcher": str("watched: what follows this wait"),
"outcome": str("done: what the wait ended with"),
},
Run: func(a map[string]any) (any, error) {
if waiting == nil {
return nil, notLaunched
}
session := strArg(a, "session")
now := time.Now()
if waiting.Now != nil {
now = waiting.Now()
}
switch op := strArg(a, "op"); op {
case "add":
read := WaitRead{}
if m, ok := a["read"].(map[string]any); ok {
read.Address = strArg(m, "address")
if args, ok := m["arguments"].(map[string]any); ok {
read.Arguments = args
}
}
bound := 0
if n, ok := a["bound_minutes"].(float64); ok {
bound = int(n)
}
return AddWait(p, WaitInput{Session: session, By: strArg(a, "by"), What: strArg(a, "what"), Read: read,
Family: strArg(a, "family"), Bound: bound}, waiting.Store, now)
case "watched":
return WatchedWait(p, session, strArg(a, "id"), strArg(a, "watcher"), waiting.Store, now)
case "done":
return DoneWait(p, session, strArg(a, "id"), strArg(a, "outcome"), waiting.Store, now)
case "said":
return SaidWait(p, session, strArg(a, "id"), waiting.Store, now)
case "list":
return ListWaits(p, session, waiting.Store, now)
default:
return nil, fmt.Errorf("op is add, watched, done, said or list, not %q", op)
}
}},
}
}