claude-code: set shared agent text at the controller's terminal, for now

Instructions, output styles, skills, commands and subagents for every node
or some reach every session as the mesh's own word, and the tools cannot
tell an agent from the operator; the operator decided they are set at the
terminal until a surface of their own updates them (hq issue 340). They
are settings keys, each text a list of lines; the home scope stays free.
This commit is contained in:
jochen
2026-10-09 09:41:52 +02:00
parent c613efe189
commit e139fb4dd9
9 changed files with 267 additions and 23 deletions
+12 -3
View File
@@ -112,9 +112,18 @@ them. `claude_code_registrations` lists everything registered, with who register
rendered, beside what the operator set for this node at the terminal. Its `by` is the node whose tool runner served
the call, never the caller, whom the runner is not told.
The agent's configuration that is text a session reads (hq ADR 0216), each registered at a **scope** — `mesh` (the
default), `node` (`nodes`: a list, or `"all"` for every node running claude-code; absent is this node) or `home` (the
operator account's own `~/.claude` on those nodes):
**Text for every node or some is set at the controller's terminal too, for now** (hq issue 340, the operator's
decision of 2026-10-09; a surface of the operator's own may update it later). Instruction sections, output styles,
skills, commands and subagents at the `mesh` or `node` scope are refused through the tools, and the import tool is
refused. They are keys of this module's settings, each text a list of its lines, because a setting holds no line
break: `instructions`, `output_styles`, `commands` and `agents` map a name to its text; `skills` maps a name to its
files, each a list of lines. For example `mesh-controller settings set claude-code '{"instructions": {"conventions":
["Commit in the imperative.", "", "Test before pushing."]}}'`. They land where a registration did, laid over what was
registered before; what the operator writes there is not judged, but an item that is not well formed is left out and
said in the render's answer. What was registered before is still rendered and may be unregistered.
The home scope, the operator account's own `~/.claude` (`nodes`: a list, or `"all"` for every node running
claude-code; absent is this node), is still registered through the tools:
- for each kind — `skill`, `agent`, `command`, `output_style`, `instructions` — `claude_code_<kind>_list`,
`_register`, `_unregister`. A skill is its files (`files`, or `content` for a lone SKILL.md);
+40 -7
View File
@@ -32,6 +32,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path"
@@ -264,6 +265,15 @@ func knownKind(k string) bool {
// Problem says why an item cannot be registered, or "".
func (it Item) Problem() string {
if problem := it.shapeProblem(); problem != "" {
return problem
}
return runsProblem(it)
}
// shapeProblem says why an item is not well formed, or "": its kind, scope, name, paths, files and size, without
// judging what its text asks of a session — which the operator's own text, set at the terminal, may.
func (it Item) shapeProblem() string {
if !knownKind(it.Kind) {
return fmt.Sprintf("%q is not a kind: %s", it.Kind, strings.Join(Kinds, ", "))
}
@@ -299,7 +309,7 @@ func (it Item) Problem() string {
if n := it.size(); n > MaxItemBytes {
return fmt.Sprintf("%d bytes: more than the %d one registration may carry", n, MaxItemBytes)
}
return runsProblem(it)
return ""
}
// pathProblem says why a file's path is not one inside the item, or "": relative, slash-separated, every
@@ -420,6 +430,9 @@ func (v *ConfigView) writeThroughLocked() bool {
// Config is what applies to this node, sorted for rendering.
type Config struct {
Mesh, Node, Home []Item
// Terminal is the shared text the operator set at the controller's terminal, in this module's settings
// (novox/hq issue 340): laid over what was registered for the mesh and the node, for every session here.
Terminal []Item
}
// ConfigOf sorts the items that apply here by scope, each scope by kind and name.
@@ -498,7 +511,7 @@ func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile {
})},
}
chosen := map[string]Item{}
for _, layer := range [][]Item{c.Mesh, c.Node} {
for _, layer := range [][]Item{c.Mesh, c.Node, c.Terminal} {
for _, it := range layer {
chosen[it.Kind+"/"+it.Name] = it
}
@@ -593,10 +606,12 @@ func mergeSettings(a, b map[string]any) map[string]any {
// registered for the mesh, then those for this node.
func InstructionSections(c Config) string {
var b strings.Builder
registered := "Registered through the `claude-code` module before that moved to the controller's terminal; change them there, in the module's `instructions` setting."
for _, part := range []struct {
title string
items []Item
}{{"Instructions for every node", c.Mesh}, {"Instructions for this node", c.Node}} {
title, note string
items []Item
}{{"Instructions for every node", registered, c.Mesh}, {"Instructions for this node", registered, c.Node},
{"Instructions set at the controller's terminal", "Set in the `claude-code` module's `instructions` setting, at the controller's terminal.", c.Terminal}} {
var sections []Item
for _, it := range part.items {
if it.Kind == KindInstructions {
@@ -606,7 +621,7 @@ func InstructionSections(c Config) string {
if len(sections) == 0 {
continue
}
fmt.Fprintf(&b, "\n## %s\n\nRegistered through the `claude-code` module's instruction tools; change them there.\n", part.title)
fmt.Fprintf(&b, "\n## %s\n\n%s\n", part.title, part.note)
for _, it := range sections {
fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"]))
}
@@ -807,7 +822,7 @@ func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
var placed Placed
_ = readJSON(p.placed(), &placed)
inPlugin := map[string]bool{}
for _, layer := range [][]Item{c.Mesh, c.Node} {
for _, layer := range [][]Item{c.Mesh, c.Node, c.Terminal} {
for _, it := range layer {
inPlugin[it.Kind+"/"+it.Name] = true
}
@@ -910,10 +925,28 @@ type ConfigState interface {
Get(key string) (json.RawMessage, bool, error)
}
// SharedTextAtTheTerminal is why text for every node or some is not registered through this module (novox/hq issue
// 340, the operator's decision of 2026-10-09): an instruction section, an output style, a skill, a command or a
// subagent at the mesh or node scope reaches every session on those nodes as the mesh's own word, and the tool
// runner cannot tell an agent from the operator. Until a surface of the operator's own updates it, it is set at
// the controller's terminal, in this module's settings; a person's own home stays theirs to register into.
func SharedTextAtTheTerminal(kind string) string {
key := map[string]string{KindSkill: "skills", KindAgent: "agents", KindCommand: "commands",
KindOutputStyle: "output_styles", KindInstructions: "instructions"}[kind]
return "a " + kind + " for every node or some reaches every session there as the mesh's own word, and this tool " +
"cannot tell an agent from the operator. It is set at the controller's terminal only, under `" + key +
"` in this module's settings, its text as a list of lines: mesh-controller settings set claude-code " +
"'{\"" + key + "\": {\"<name>\": [\"line\", …]}}' [--node <node>] (novox/hq issue 340). The home scope, " +
"one account's own directory, is still registered here. Nothing was registered"
}
// Register puts an item (or, with unregister, removes it) at its scope: at the mesh scope one key, at the
// node and home scopes one key per node — this node when none is given. Taken into this node's view at once,
// so the answer says what it did here.
func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigState, v *ConfigView, write WriteManaged) (map[string]any, error) {
if !unregister && it.Scope != ScopeHome && it.Kind != KindHook && it.Kind != KindSettings && knownKind(it.Kind) {
return nil, errors.New(SharedTextAtTheTerminal(it.Kind))
}
if !unregister {
it.By, it.At = p.Node, time.Now().UTC().Format(time.RFC3339)
if problem := it.Problem(); problem != "" {
@@ -41,6 +41,26 @@ func deliver(m memConfig, v *ConfigView) {
}
}
// registeredBefore puts an item on the bus as a registration made before mesh- and node-scope text moved to the
// controller's terminal (novox/hq issue 340), for each node given (none at the mesh scope), as every node's watch
// would hand it over to this one, and renders.
func registeredBefore(t *testing.T, p Paths, w map[string]string, state memConfig, view *ConfigView, it Item, nodes ...string) {
t.Helper()
if it.Scope == ScopeMesh {
nodes = []string{""}
}
for _, n := range nodes {
item := it
if err := state.Put(it.Key(n), item); err != nil {
t.Fatal(err)
}
view.Take(it.Key(n), "put", &item)
}
if _, err := RenderNow(p, writer(w)); err != nil {
t.Fatal(err)
}
}
func one(name, content string) map[string]string { return map[string]string{name + ".md": content} }
func pluginOf(t *testing.T, w map[string]string) map[string]PluginFile {
@@ -57,10 +77,10 @@ func TestEachKindLandsInItsOnePlace(t *testing.T) {
state, view := memConfig{}, NewConfigView(p)
register := func(it Item) {
t.Helper()
answer, err := Register(p, it, nil, false, state, view, writer(w))
if err != nil || answer["registered"] == false {
t.Fatalf("%s %s: %v %v", it.Kind, it.Name, answer, err)
if problem := it.Problem(); problem != "" {
t.Fatalf("%s %s: %s", it.Kind, it.Name, problem)
}
registeredBefore(t, p, w, state, view, it)
}
register(Item{Kind: KindSkill, Name: "review", Scope: ScopeMesh,
Files: map[string]string{"SKILL.md": "---\nname: review\ndescription: d\n---\nbody", "scripts/run.sh": "#!/bin/sh\necho hi\n"}})
@@ -107,9 +127,13 @@ func TestAMeshItemReachesEveryNodeANodeItemOneAndAHomeItemOneHome(t *testing.T)
{Item{Kind: KindCommand, Name: "server-only", Scope: ScopeNode, Files: one("server-only", "x")}, []string{"server"}},
{Item{Kind: KindCommand, Name: "at-home", Scope: ScopeHome, Files: one("at-home", "x")}, []string{"laptop"}},
} {
if _, err := Register(a, r.it, r.nodes, false, state, va, writer(wa)); err != nil {
t.Fatal(err)
if r.it.Scope == ScopeHome {
if _, err := Register(a, r.it, r.nodes, false, state, va, writer(wa)); err != nil {
t.Fatal(err)
}
continue
}
registeredBefore(t, a, wa, state, va, r.it, r.nodes...)
}
deliver(state, vb)
if _, err := RenderNow(b, writer(wb)); err != nil {
@@ -289,9 +313,7 @@ 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)
}
registeredBefore(t, p, w, state, view, Item{Kind: KindCommand, Name: name, Scope: ScopeMesh, Files: one(name, "x")})
}
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
@@ -338,7 +360,7 @@ func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) {
t.Fatalf("%v %v", nodes, err)
}
p, w := node(t, "laptop")
answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeNode, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w))
answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeHome, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w))
if answer["registered"] != false {
t.Fatalf("a node name with a dot was used in a key: %v", answer)
}
@@ -104,7 +104,7 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
Description: "Every " + k.kind + " registered for Claude Code on the mesh, by key (`mesh.…` every node, `node.<node>.…` one node, `home.<node>.…` an account's own directory), with who registered it and its files. Lands in " + k.lands + ".",
Run: func(map[string]any) (any, error) { return List(state, k.kind) }},
stdio.Tool{Name: "claude_code_" + k.tool + "_register",
Description: "Register " + k.what + ", for every node (scope mesh, the default), some nodes (node) or an account's own directory (home). Kept on the bus: a node that joins later takes it too. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, at home where the person already has one of that name, and whenever it would run something by itself in a session: a shell line (!`…` or ```!), or hooks, mcpServers, permissionMode or allowed-tools in its front matter (novox/hq issue 340).",
Description: "Register " + k.what + " in an account's own directory (scope home) on this node or the nodes given. For every node (mesh, the default) or some (node) it is refused and says why (novox/hq issue 340): shared text is set at the controller's terminal, in this module's settings, until a surface of the operator's own updates it. Kept on the bus. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, at home where the person already has one of that name, and whenever it would run something by itself in a session: a shell line (!`…` or ```!), or hooks, mcpServers, permissionMode or allowed-tools in its front matter (novox/hq issue 340).",
Input: input,
Run: func(a map[string]any) (any, error) {
if k.kind == KindHook {
@@ -232,7 +232,7 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
}, nil
}},
stdio.Tool{Name: "claude_code_config_import",
Description: "Register an item found in this node's own ~/.claude — a skill, subagent, command, output style, or a rule file as instructions — at the scope given, so something written by hand on one machine reaches every node, some, or stays at home under the mesh's care. The original is left where it is: removing it is the person's act.",
Description: "Refused for now, and says why (novox/hq issue 340): it registered an item of this node's own ~/.claude for every node or some, and text for every node or some is set at the controller's terminal, in this module's settings. Read the item with claude_code_home_show and set it there.",
Input: map[string]any{
"kind": str("skill, agent, command, output-style or instructions (a rule file)"),
"name": str("its name in the home: the skill's folder, or the file without .md"),
@@ -127,6 +127,8 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
var items map[string]Item
_ = readJSON(p.config(), &items)
config := ConfigOf(Taken(items))
var leftOut []string
config.Terminal, leftOut = terminalText(p)
mesh := ReadMeshTools(p)
if mesh != nil {
mesh.Refused = RefusedCounts(GuardDataOf(p, mesh).Log)
@@ -165,6 +167,9 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
for _, line := range left {
out = append(out, "home "+line)
}
for _, line := range leftOut {
out = append(out, "left out of the terminal's shared text: "+line)
}
return out, errors.Join(failed...)
}
@@ -91,9 +91,15 @@ func Registrations(p Paths, servers ServerState, config ConfigState) (map[string
var settings Settings
_ = readJSON(p.Settings, &settings)
text, leftOut := terminalText(p)
shared := []string{}
for _, it := range text {
shared = append(shared, it.Kind+" "+it.Name)
}
return map[string]any{
"set at the controller's terminal, as this node composes it": map[string]any{
"managed_settings": settings.ManagedSettings, "mcp_servers": settings.MCPServers},
"managed_settings": settings.ManagedSettings, "mcp_servers": settings.MCPServers,
"shared text": shared, "shared text left out": leftOut},
"tool servers registered through this module": registered,
"configuration registered through this module": items,
"by": ByIs,
@@ -339,3 +339,83 @@ func TestAFileReferenceOutsideTheItemIsRefused(t *testing.T) {
t.Errorf("a reference to its own file or a mail address was refused: %s", problem)
}
}
// Text for every node or some is set at the controller's terminal for now (novox/hq issue 340, the operator's
// decision of 2026-10-09): through the tools, an instruction section, an output style, a skill, a command or a
// subagent at the mesh or node scope is refused, saying where it is set, and so is an import to those scopes. The
// home scope, one account's own directory, is still registered through them.
func TestSharedTextIsRefusedThroughTheToolsAndHomeIsNot(t *testing.T) {
p, _ := node(t, "laptop")
servers, config := &bus{kept: map[string]map[string]any{}}, memConfig{}
s := serve(t, p, servers, config)
writeFile(t, filepath.Join(p.Home, ".claude", "x.md"), "") // the home exists
_ = os.MkdirAll(filepath.Join(p.Home, ".claude", "commands"), 0o755)
writeFile(t, filepath.Join(p.Home, ".claude", "commands", "mine.md"), "Ship it.")
for _, tool := range []string{"instructions", "output_style", "skill", "command", "agent"} {
for _, scope := range []map[string]any{{}, {"scope": "mesh"}, {"scope": "node"}, {"scope": "node", "nodes": "laptop,server"}} {
args := map[string]any{"name": "x", "content": "---\nname: x\ndescription: d\n---\nRead the mesh's rules.\n"}
for k, v := range scope {
args[k] = v
}
text, isError := s.call(t, "claude_code_"+tool+"_register", args)
if !isError || !strings.Contains(text, "controller's terminal") || !strings.Contains(text, "issue 340") {
t.Errorf("%s %v was not refused, or did not say where it is set: %s", tool, scope, text)
}
}
}
if text, isError := s.call(t, "claude_code_config_import", map[string]any{"kind": "command", "name": "mine", "scope": "mesh"}); !isError ||
!strings.Contains(text, "issue 340") {
t.Errorf("an import to the mesh was not refused: %s", text)
}
if len(config) != 0 {
t.Fatalf("a refused call kept something on the bus: %v", config)
}
text, isError := s.call(t, "claude_code_command_register", map[string]any{"name": "ship", "scope": "home",
"content": "---\ndescription: ship it\n---\nShip it."})
if isError || config["home.laptop.command.ship"] == nil {
t.Fatalf("a command for this home was refused: %s", text)
}
}
// What the operator sets at the terminal lands where a registration did: each text a list of its lines, laid over
// what was registered before. It is the operator's word, so what it asks of a session is not judged; an item that
// is not well formed is left out, and said.
func TestSharedTextSetAtTheTerminalLands(t *testing.T) {
p, w := node(t, "laptop")
writeFile(t, p.Settings, `{"role":"","mcp_servers":{},
"instructions":{"conventions":["Commit in the imperative.","","Test before pushing."]},
"output_styles":{"terse":["---","name: terse","---","Short."]},
"skills":{"review":{"SKILL.md":["---","name: review","description: d","allowed-tools: Read","---","Review it."],"scripts/run.sh":["#!/bin/sh","echo hi"]},
"broken":{"notes.md":"no SKILL.md"}},
"commands":{"ship":"Ship it."},
"agents":{"reviewer":["---","name: reviewer","---","x"],"Bad Name":"x"}}`)
state, view := memConfig{}, NewConfigView(p)
registeredBefore(t, p, w, state, view, Item{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh,
Files: one("conventions", "The registration from before.")})
out, err := RenderNow(p, writer(w))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(w["CLAUDE.md"], "## Instructions set at the controller's terminal") ||
!strings.Contains(w["CLAUDE.md"], "### conventions\n\nCommit in the imperative.\n\nTest before pushing.") {
t.Errorf("the instructions:\n%s", w["CLAUDE.md"])
}
plugin := pluginOf(t, w)
root := "plugins/" + Plugin + "/"
for _, want := range []string{"skills/review/SKILL.md", "skills/review/scripts/run.sh", "output-styles/terse.md",
"commands/ship.md", "agents/reviewer.md"} {
if _, ok := plugin[root+want]; !ok {
t.Errorf("the plugin lacks %s; %v", want, out)
}
}
if !plugin[root+"skills/review/scripts/run.sh"].Executable || !strings.Contains(plugin[root+"skills/review/SKILL.md"].Content, "allowed-tools: Read") {
t.Error("the operator's skill was not taken as written")
}
if _, ok := plugin[root+"skills/broken/notes.md"]; ok {
t.Error("a skill without its SKILL.md was placed")
}
said := strings.Join(out, "\n")
if !strings.Contains(said, "skill broken") || !strings.Contains(said, "agent Bad Name") {
t.Errorf("what was left out was not said:\n%s", said)
}
}
@@ -0,0 +1,89 @@
package main
// The shared text the operator sets at the controller's terminal (novox/hq issue 340, the operator's decision of
// 2026-10-09): instruction sections, output styles, skills, commands and subagents for every node or some. They are
// keys of this module's settings, which the controller takes at its terminal alone. A setting holds no line break
// (issue 339), so each text is a list of its lines; a one-line text may be a string.
import (
"encoding/json"
"errors"
"os"
"sort"
"strings"
)
// Lines is a text as a setting holds it: a list of its lines, or one line as a string.
type Lines string
// UnmarshalJSON takes a string or a list of strings, joined by line breaks.
func (l *Lines) UnmarshalJSON(raw []byte) error {
var one string
if json.Unmarshal(raw, &one) == nil {
*l = Lines(one)
return nil
}
var many []string
if err := json.Unmarshal(raw, &many); err != nil {
return errors.New("a text is a list of its lines, or one line")
}
*l = Lines(strings.Join(many, "\n") + "\n")
return nil
}
// SharedText is the text keys of this module's settings.
type SharedText struct {
Instructions map[string]Lines `json:"instructions"`
OutputStyles map[string]Lines `json:"output_styles"`
Agents map[string]Lines `json:"agents"`
Commands map[string]Lines `json:"commands"`
Skills map[string]map[string]Lines `json:"skills"`
}
// TerminalItems are the items the operator's settings hold, as the plugin and the instruction file take them, and
// what was left out and why: an item that is not well formed (a name, a path, a skill without its SKILL.md). What
// the text asks of a session is the operator's to say there, so it is not judged.
func TerminalItems(text SharedText) ([]Item, []string) {
var out []Item
var left []string
add := func(it Item) {
it.Scope, it.By = ScopeMesh, "the controller's terminal"
if problem := it.shapeProblem(); problem != "" {
left = append(left, it.Kind+" "+it.Name+": "+problem)
return
}
out = append(out, it)
}
for kind, texts := range map[string]map[string]Lines{KindInstructions: text.Instructions,
KindOutputStyle: text.OutputStyles, KindAgent: text.Agents, KindCommand: text.Commands} {
for name, body := range texts {
add(Item{Kind: kind, Name: name, Files: map[string]string{name + ".md": string(body)}})
}
}
for name, files := range text.Skills {
it := Item{Kind: KindSkill, Name: name, Files: map[string]string{}}
for rel, body := range files {
it.Files[rel] = string(body)
}
add(it)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Kind != out[j].Kind {
return out[i].Kind < out[j].Kind
}
return out[i].Name < out[j].Name
})
sort.Strings(left)
return out, left
}
// terminalText reads the shared text from this module's settings file.
func terminalText(p Paths) ([]Item, []string) {
var text SharedText
if raw, err := os.ReadFile(p.Settings); err == nil {
if err := json.Unmarshal(raw, &text); err != nil {
return nil, []string{"the shared text in this module's settings could not be read: " + err.Error()}
}
}
return TerminalItems(text)
}
+1 -1
View File
@@ -113,7 +113,7 @@
"mode": "0600",
"owner": "${machine:account}",
"merge": "json",
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {}\n}\n"
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n"
}
],
"build": {