claude-code: carry each project's memory folder between nodes (issue 367, ADR 0343)
mesh/merge-gate head 6aa30eae66be merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 6aa30eae66be merged into trunk a84d0094a0a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request

A session started on another node began without what the sessions elsewhere had
learned, because Claude Code keeps project memory on one node. The module's own
process now carries the memory folders through its kept memory state: a change
is written elsewhere only over the version it replaced, a file changed on two
nodes keeps both (newer first) with a warning, and every replaced or removed
version is kept. No tool writes memory.
This commit is contained in:
2026-10-13 15:57:44 +02:00
parent a84d0094a0
commit 6aa30eae66
5 changed files with 1494 additions and 3 deletions
@@ -152,6 +152,36 @@ func (s stateOf) Create(key string, value any) error {
return err
}
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
// memoryStateOf adapts the SDK's state to the memory carrier: every write at the revision read (ADR 0343).
type memoryStateOf struct{ s stdio.KeptState }
func (s memoryStateOf) Get(key string) (json.RawMessage, uint64, bool, error) {
e, err := s.s.Get(key)
if err != nil || e == nil {
return nil, 0, false, err
}
return e.Value, e.Revision, true, nil
}
func (s memoryStateOf) Create(key string, value any) error {
_, err := s.s.Create(key, value)
return err
}
func (s memoryStateOf) Update(key string, value any, revision uint64) error {
_, err := s.s.Update(key, value, revision)
return err
}
func (s memoryStateOf) Keys() ([]string, error) { return s.s.Keys() }
func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
// memoryCarrier carries this node's memory folders (ADR 0343); nil until the long-running half starts it.
var memoryCarrier *MemoryCarrier
// notifyOperator tells the operator through the operator channel's notify verb (ADR 0253).
func notifyOperator(args map[string]any) error {
_, err := ask("seat:operator-channel.notify", args)
return err
}
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
e, err := s.s.Get(key)
if err != nil || e == nil {
@@ -354,6 +384,9 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
out = append(out, stdio.Tool{Name: "claude_code_registrations",
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
out = append(out, stdio.Tool{Name: "claude_code_memory",
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
out = append(out, proposalTools(p)...)
judge := NewJudge(p)
out = append(out, judgeTool(judge), judgedTool(judge))
@@ -430,6 +463,12 @@ func main() {
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
Now: time.Now, Write: writeManaged}
go proposing.watch(p)
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
return handle(c.Key, c.Op, c.Value, c.Revision)
})
})
go run(p, view, configView)
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
say("%v", err)