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.
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
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)
|
||||
}
|
||||
}},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user