From 1d8f1ceff8cfc8553c7d64f0c1856c123ec23880 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 14:25:10 +0200 Subject: [PATCH] claude-code: drop what was removed while away, render one at a time, refuse an empty settings set Review of the nox-mesh plugin: a watch hands over what is there, not what went, so the view is pruned to the state's keys before it starts; the watches and the tools render one at a time, as the home's record is read and written whole; the register answer names how an item is offered. --- modules/claude-code/cmd/claude-code/config.go | 30 +++++++++++++++++-- .../cmd/claude-code/config_test.go | 21 +++++++++++++ .../cmd/claude-code/config_tools.go | 6 ++++ modules/claude-code/cmd/claude-code/main.go | 9 ++++++ modules/claude-code/cmd/claude-code/node.go | 6 ++++ 5 files changed, 70 insertions(+), 2 deletions(-) diff --git a/modules/claude-code/cmd/claude-code/config.go b/modules/claude-code/cmd/claude-code/config.go index 680fce7..632e128 100644 --- a/modules/claude-code/cmd/claude-code/config.go +++ b/modules/claude-code/cmd/claude-code/config.go @@ -223,6 +223,23 @@ func (v *ConfigView) Take(key, op string, item *Item) bool { return v.writeThrough() } +// Prune drops what the view holds and the state no longer does: a registration removed while this node +// was away is never handed over by the watch, which hands over what is there, not what went. +func (v *ConfigView) Prune(present []string) bool { + keep := map[string]bool{} + for _, k := range present { + keep[k] = true + } + v.mu.Lock() + for k := range v.items { + if !keep[k] { + delete(v.items, k) + } + } + v.mu.Unlock() + return v.writeThrough() +} + // Items is what applies here, by key. func (v *ConfigView) Items() map[string]Item { v.mu.Lock() @@ -730,8 +747,17 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta } verb := map[bool]string{false: "registered", true: "unregistered"}[unregister] answer := map[string]any{verb: it.Kind + " " + it.Name, "keys": keys} - if it.Kind != KindSettings && it.Scope != ScopeHome && !unregister { - answer["offered as"] = Plugin + ":" + it.Name + if !unregister { + switch { + case it.Scope == ScopeHome && it.Kind == KindCommand: + answer["offered as"] = "/" + it.Name + case it.Scope == ScopeHome: + answer["offered as"] = it.Name + ", from the home" + case it.Kind == KindSkill || it.Kind == KindAgent || it.Kind == KindOutputStyle: + answer["offered as"] = Plugin + ":" + it.Name + case it.Kind == KindCommand: + answer["offered as"] = "/" + Plugin + ":" + it.Name + } } if changed { rendered, err := RenderNow(p, write) diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index 9dcefa1..d457839 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -318,3 +318,24 @@ func TestATreeIsTheSameOnlyWhenEveryFileAndModeIs(t *testing.T) { t.Fatal("an extra file counted as the same") } } + +// A registration removed while a node was away is dropped when it is back: the watch hands over only what is +// there, so the view is pruned to the keys the state still holds. +func TestWhatWasRemovedWhileANodeWasAwayIsDropped(t *testing.T) { + p, w := node(t, "laptop") + state, view := memConfig{}, NewConfigView(p) + for _, name := range []string{"kept", "gone"} { + if _, err := Register(p, Item{Kind: KindCommand, Name: name, Scope: ScopeMesh, Files: one(name, "x")}, nil, false, state, view, writer(w)); err != nil { + t.Fatal(err) + } + } + delete(state, "mesh.command.gone") // removed from another node while this one was off + back := NewConfigView(p) // the node starts again from what it last wrote + if len(back.Items()) != 2 { + t.Fatalf("the view did not start from what was last written: %v", back.Items()) + } + keys, _ := state.Keys() + if !back.Prune(keys) || len(back.Items()) != 1 { + t.Fatalf("after pruning: %v", back.Items()) + } +} diff --git a/modules/claude-code/cmd/claude-code/config_tools.go b/modules/claude-code/cmd/claude-code/config_tools.go index 71ac125..153122d 100644 --- a/modules/claude-code/cmd/claude-code/config_tools.go +++ b/modules/claude-code/cmd/claude-code/config_tools.go @@ -157,6 +157,9 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool { }, Run: func(a map[string]any) (any, error) { given, _ := a["settings"].(map[string]any) + if len(given) == 0 { + return nil, errors.New("no settings given; to remove a scope's settings, claude_code_settings_clear") + } return SetSettings(p, scopeOf(a), nodesOf(a["nodes"]), func(held map[string]any) map[string]any { if boolArg(a, "replace") { return given @@ -191,6 +194,9 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool { return SetSettings(p, scopeOf(a), nodesOf(a["nodes"]), func(held map[string]any) map[string]any { perms, _ := held["permissions"].(map[string]any) rules, _ := perms[list].([]any) + if len(rules) == 0 { + return held // nothing to remove: what the scope holds stays as it is + } kept := []any{} for _, r := range rules { if r != rule { diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 8927739..81d2965 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -375,6 +375,15 @@ func main() { func run(p Paths, view *ServerView, configView *ConfigView) { // The agent's configuration, at every scope (ADR 0216): the whole current set first, then each change. go persist("watching the agent's configuration", func() error { + keys, err := stdio.State("config").Keys() + if err != nil { + return err + } + if configView.Prune(keys) { + if _, err := RenderNow(p, writeManaged); err != nil { + say("rendering after what was removed while away: %v", err) + } + } return stdio.State("config").Watch("", func(c stdio.StateChange) error { var item *Item if c.Op == "put" { diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 45bda9e..c1e1e71 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -104,9 +104,15 @@ func Registered(p Paths) Servers { return s } +var renderMu sync.Mutex + // RenderNow writes the managed directory from the facts, the settings, the licence held and the servers // registered here. func RenderNow(p Paths, write WriteManaged) ([]string, error) { + // One at a time: the watches and the tools all render, and the home's record of what was placed is + // read and written whole. + renderMu.Lock() + defer renderMu.Unlock() var facts Facts if !readJSON(p.Facts, &facts) || facts.Console == "" { return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)