diff --git a/cmd/contr/hook.go b/cmd/contr/hook.go index 705db1f9..2586ff6d 100644 --- a/cmd/contr/hook.go +++ b/cmd/contr/hook.go @@ -118,7 +118,7 @@ type hook struct { // Claude Code shows the error and the turn ends. func runHook(args []string, stdin io.Reader, stdout, stderr io.Writer, dial func(context.Context, func(string) string) (agentsCore, error), getenv func(string) string) int { if len(args) == 1 && (args[0] == "post-tool" || args[0] == "prompt") { - return runSessionHook(args[0], stdin, stdout, stderr, getenv, sessionStateDir(getenv), time.Now()) + return runSessionHook(args[0], stdin, stdout, stderr, dial, getenv, sessionStateDir(getenv), time.Now()) } if len(args) != 1 || (args[0] != "stop" && args[0] != "session-start") { _, _ = io.WriteString(stderr, hookUsage) @@ -141,6 +141,10 @@ func runHook(args []string, stdin io.Reader, stdout, stderr io.Writer, dial func if getenv(statedir.EnvTerminal) != "" { _, _ = io.WriteString(stdout, chatSkillLine) } + // The session starts with the flow it has now (D293). + if err := recordFlow(getenv, sessionStateDir(getenv), getenv(statedir.EnvTerminal)); err != nil { + fmt.Fprintf(stderr, "contr hook: record flow: %v\n", err) + } err = h.sessionStart() case getenv(agents.EnvCoordinator) != "": err = h.workerStop() diff --git a/cmd/contr/hookflow.go b/cmd/contr/hookflow.go new file mode 100644 index 00000000..0d313f3a --- /dev/null +++ b/cmd/contr/hookflow.go @@ -0,0 +1,132 @@ +// The flow pickup: a coordinator gets the flow and the profiles' models again +// when the owner changes them in agents.json during the session (D293). + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/contursofran/contr/internal/agents" +) + +// flowChangedLead starts the context of a changed flow. +const flowChangedLead = "contr: the owner changed the flow and models in agents.json; follow these from now on.\n" + +// flowSeenDir is the folder in the state folder that holds the last-seen +// flow hash of each terminal. +const flowSeenDir = "flow-seen" + +// flowState is the flow text and one line per profile with its model and +// effort, in name order. +func flowState(getenv func(string) string) (string, error) { + cfg, err := agents.LoadConfig(configPath(getenv)) + if err != nil { + return "", err + } + names := make([]string, 0, len(cfg.Profiles)) + for n := range cfg.Profiles { + names = append(names, n) + } + sort.Strings(names) + var b strings.Builder + b.WriteString(cfg.Flow.Text()) + b.WriteString("\nProfiles:\n") + for _, n := range names { + p := cfg.Profiles[n] + fmt.Fprintf(&b, "- %s: model %s, effort %s\n", n, orText(p.Model(), "default"), orText(p.Effort(), "default")) + } + return b.String(), nil +} + +func flowHash(text string) string { + sum := sha256.Sum256([]byte(text)) + return hex.EncodeToString(sum[:]) +} + +func flowSeenPath(stateDir, id string) string { + return filepath.Join(stateDir, flowSeenDir, filepath.Base(id)) +} + +// recordFlow saves the hash of the current flow as seen by terminal id. +func recordFlow(getenv func(string) string, stateDir, id string) error { + text, err := flowState(getenv) + if err != nil { + return err + } + return saveFlowSeen(stateDir, id, flowHash(text)) +} + +func saveFlowSeen(stateDir, id, hash string) error { + if stateDir == "" || id == "" { + return nil + } + p := flowSeenPath(stateDir, id) + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + return os.WriteFile(p, []byte(hash), 0o600) +} + +// flowChange is the context for a coordinator when the flow or the models +// changed since terminal id last saw them, else "". With no saved hash it +// only saves one: the session start missed it, so there is nothing to compare. +// hc is asked for the role only after a change, so most calls do no I/O but +// two small file reads. +func flowChange(getenv func(string) string, dial func(context.Context, func(string) string) (agentsCore, error), stateDir, id string) (string, error) { + if stateDir == "" { + return "", nil + } + text, err := flowState(getenv) + if err != nil { + return "", err + } + hash := flowHash(text) + seen, err := os.ReadFile(flowSeenPath(stateDir, id)) + if errors.Is(err, os.ErrNotExist) { + return "", saveFlowSeen(stateDir, id, hash) + } + if err != nil { + return "", err + } + if string(seen) == hash { + return "", nil + } + // Save first: a failed role check does not ask hc again on each call. + if err := saveFlowSeen(stateDir, id, hash); err != nil { + return "", err + } + coord, err := isCoordinatorTerminal(getenv, dial, id) + if err != nil || !coord { + return "", err + } + return flowChangedLead + text, nil +} + +// isCoordinatorTerminal tells whether hc terminal id orchestrates: it has the +// label contr.orchestrating or role orchestrator. +func isCoordinatorTerminal(getenv func(string) string, dial func(context.Context, func(string) string) (agentsCore, error), id string) (bool, error) { + if dial == nil { + return false, errors.New("no hc connection") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + core, err := dial(ctx, getenv) + if err != nil { + return false, err + } + defer func() { _ = core.Close() }() + w, err := agents.New(core, agents.Config{}).Get(ctx, id) + if err != nil { + return false, err + } + return w.Orchestrating || w.Orchestrator(), nil +} diff --git a/cmd/contr/hookflow_test.go b/cmd/contr/hookflow_test.go new file mode 100644 index 00000000..3f8e2f4e --- /dev/null +++ b/cmd/contr/hookflow_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// flowRig runs the session hooks of terminal t1 with a scratch agents.json, +// state folder and a fake core. +type flowRig struct { + t *testing.T + cfg string + core *cliCore + env map[string]string +} + +func newFlowRig(t *testing.T, coordinator bool) *flowRig { + t.Helper() + dir := t.TempDir() + w := worker("t1", "orch", "live", nil) + if coordinator { + w["labels"] = map[string]any{"contr": "agent", "contr.orchestrating": "1"} + } + r := &flowRig{t: t, cfg: filepath.Join(dir, "agents.json"), core: &cliCore{reply: map[string]any{"terminal.get": w}}} + r.env = map[string]string{"HC_TERMINAL_ID": "t1", "CONTR_AGENTS_CONFIG": r.cfg, "CONTR_STATE_DIR": filepath.Join(dir, "state"), "CONTR_OWNER_FILE": filepath.Join(dir, "owner.json")} + r.write(`{"profiles":{"builder":{"argv":["claude","--model","opus","--effort","low"]}}}`) + return r +} + +func (r *flowRig) write(s string) { + r.t.Helper() + if err := os.WriteFile(r.cfg, []byte(s), 0o600); err != nil { + r.t.Fatal(err) + } +} + +func (r *flowRig) run(event, input string) (string, string) { + r.t.Helper() + dial := func(context.Context, func(string) string) (agentsCore, error) { return r.core, nil } + var out, errb bytes.Buffer + if code := runHook([]string{event}, strings.NewReader(input), &out, &errb, dial, func(k string) string { return r.env[k] }); code != 0 { + r.t.Fatalf("%s: code %d err %q", event, code, errb.String()) + } + return out.String(), errb.String() +} + +func (r *flowRig) start() { + r.t.Helper() + if _, errs := r.run("session-start", `{"source":"startup","session_id":"s1"}`); errs != "" { + r.t.Fatalf("start: %q", errs) + } + r.core.calls = nil +} + +const flowPost = `{"tool_name":"Bash"}` +const flowPrompt = `{"prompt":"go on","session_id":"s1"}` + +func TestFlowNoChangeSaysNothing(t *testing.T) { + r := newFlowRig(t, true) + r.start() + if out, _ := r.run("post-tool", flowPost); out != "" { + t.Errorf("post-tool: %q, want nothing", out) + } + if out, _ := r.run("prompt", flowPrompt); strings.Contains(out, "changed the flow") { + t.Errorf("prompt: %q, want no flow", out) + } + if len(r.core.calls) != 0 { + t.Errorf("calls %v, want none", r.core.calls) + } +} + +func TestFlowChangeReachesCoordinator(t *testing.T) { + r := newFlowRig(t, true) + r.start() + r.write(`{"profiles":{"builder":{"argv":["claude","--model","opus","--effort","low"]}},"flow":{"workers":7}}`) + out, errs := r.run("post-tool", flowPost) + if !strings.Contains(out, `"hookEventName":"PostToolUse"`) || !strings.Contains(out, "contr: the owner changed the flow and models in agents.json; follow these from now on.") || !strings.Contains(out, "builder: model opus, effort low") { + t.Fatalf("post-tool: %q err %q", out, errs) + } + // Once: the next call says nothing. + if out, _ := r.run("post-tool", flowPost); out != "" { + t.Errorf("second post-tool: %q", out) + } +} + +func TestFlowModelChangeReachesPrompt(t *testing.T) { + r := newFlowRig(t, true) + r.start() + r.write(`{"profiles":{"builder":{"argv":["claude","--model","sonnet","--effort","high"]}}}`) + out, _ := r.run("prompt", flowPrompt) + if !strings.Contains(out, `"hookEventName":"UserPromptSubmit"`) || !strings.Contains(out, "builder: model sonnet, effort high") || !strings.Contains(out, "todo list") { + t.Fatalf("prompt: %q", out) + } +} + +func TestFlowChangeSkipsOtherTerminals(t *testing.T) { + r := newFlowRig(t, false) + r.start() + r.write(`{"profiles":{"builder":{"argv":["claude","--model","sonnet"]}}}`) + if out, _ := r.run("post-tool", flowPost); out != "" { + t.Errorf("post-tool: %q, want nothing", out) + } + r.env["CONTR_COORDINATOR"] = "t0" + r.write(`{"profiles":{"builder":{"argv":["claude","--model","haiku"]}}}`) + r.core.calls = nil + if out, _ := r.run("post-tool", flowPost); out != "" || len(r.core.calls) != 0 { + t.Errorf("worker: %q calls %v, want nothing", out, r.core.calls) + } +} + +func TestFlowBadFileIsLogged(t *testing.T) { + r := newFlowRig(t, true) + r.start() + r.write(`{`) + out, errs := r.run("post-tool", flowPost) + if out != "" || !strings.Contains(errs, "contr hook post-tool: flow:") { + t.Errorf("bad file: out %q err %q", out, errs) + } + // The prompt context still comes. + if out, _ := r.run("prompt", flowPrompt); !strings.Contains(out, "todo list") { + t.Errorf("prompt: %q", out) + } +} diff --git a/cmd/contr/hooksession.go b/cmd/contr/hooksession.go index d773f25b..67e6df9c 100644 --- a/cmd/contr/hooksession.go +++ b/cmd/contr/hooksession.go @@ -5,6 +5,7 @@ package main import ( + "context" "encoding/json" "fmt" "io" @@ -40,18 +41,30 @@ type toolInput struct { // runSessionHook is `contr hook post-tool` and `contr hook prompt`. It always // returns 0: these hooks never block Claude Code. Errors go to stderr. -func runSessionHook(name string, stdin io.Reader, stdout, stderr io.Writer, getenv func(string) string, stateDir string, now time.Time) int { +func runSessionHook(name string, stdin io.Reader, stdout, stderr io.Writer, dial func(context.Context, func(string) string) (agentsCore, error), getenv func(string) string, stateDir string, now time.Time) int { id := getenv(statedir.EnvTerminal) if id == "" { return 0 } + // A worker is never a coordinator: it gets no flow context. + worker := getenv("CONTR_COORDINATOR") != "" + flow := "" + if !worker { + var err error + if flow, err = flowChange(getenv, dial, stateDir, id); err != nil { + fmt.Fprintf(stderr, "contr hook %s: flow: %v\n", name, err) + } + } var err error if name == "prompt" { // A worker's prompts come from the coordinator, not the owner: it gets // no context text, but its transcript path is saved. - err = promptHook(stdin, stdout, stderr, stateDir, id, getenv("CONTR_COORDINATOR") != "") + err = promptHook(stdin, stdout, stderr, stateDir, id, worker, flow) } else { err = postToolHook(stdin, stateDir, id, uint64(now.Unix())) + if err == nil && flow != "" { + err = writeContext(stdout, "PostToolUse", flow) + } } if err != nil { fmt.Fprintf(stderr, "contr hook %s: %v\n", name, err) @@ -59,7 +72,16 @@ func runSessionHook(name string, stdin io.Reader, stdout, stderr io.Writer, gete return 0 } -func promptHook(stdin io.Reader, stdout, stderr io.Writer, stateDir, id string, worker bool) error { +// writeContext prints additionalContext for the hook event. +func writeContext(stdout io.Writer, event, text string) error { + out := map[string]any{"hookSpecificOutput": map[string]string{ + "hookEventName": event, + "additionalContext": text, + }} + return json.NewEncoder(stdout).Encode(out) +} + +func promptHook(stdin io.Reader, stdout, stderr io.Writer, stateDir, id string, worker bool, flow string) error { var in struct { Prompt string `json:"prompt"` SessionID string `json:"session_id"` @@ -72,14 +94,17 @@ func promptHook(stdin io.Reader, stdout, stderr io.Writer, stateDir, id string, if worker { return nil } + text := youContext if strings.HasPrefix(strings.TrimSpace(in.Prompt), " closed" with Undo, nothing stops, Open projects brings it back). The projects modal says "Close" and "⌘⌫ close project" for the same action. A click on an empty project selects it; the empty terminal then shows "New orchestrator" and "New terminal ⌘T" for that project, and ⌘T opens there too. Before, "New orchestrator in " showed only for a stored project with no terminal at all; a project whose terminals held no agent got only a disabled "Close all agents". It replaces those parts of D219, D207, D171 and D174. | | D292 | 2026-11-11 | Chat links are ink with an underline (#747; ChatView.md, Claude's text and Chips). PR, issue, worker and file chips and Markdown links draw their text in `ink-88`, the ink of the text around them, with an underline, so the state icon is the one color and an answer has one kind of link. The worker chip has no dot: it shows the mark of its model family (D285) in `WorkerInk` of its state, the size of the Octicon; another program has no icon. The chip's family travels in its address (`worker.claude`). The underline is in the text's ink: MyGo's markdown has no decoration color for links, so a lower step (`ink-48`) is not possible without a MyGo change. The pre-pass wraps the text in ``; a bare URL is not wrapped (tags would end it) and shows its underline on hover only. Replaces the `content-link` of D275, D286 and D288 for text. | +| D293 | 2026-11-12 | A coordinator picks up flow and model edits in agents.json during a session (#753, owner). The `contr hook prompt` and `contr hook post-tool` hooks hash the flow text and each profile's `--model` and `--effort`, and compare the hash with the last one that terminal saw (`flow-seen/` in the state folder). The session-start hook saves the hash, so the first prompt says nothing. After a change, only a terminal with the label `contr.orchestrating` or role orchestrator gets the context "contr: the owner changed the flow and models in agents.json; follow these from now on." with the flow and one line per profile. hc is asked for the role only after a change, so the hooks do no hc call in the usual case. Errors go to stderr and never block the hook. | Profiles are read at each spawn, but the coordinator only knew the flow from session start and compaction. | diff --git a/internal/agents/profilemodel.go b/internal/agents/profilemodel.go new file mode 100644 index 00000000..005f3c2c --- /dev/null +++ b/internal/agents/profilemodel.go @@ -0,0 +1,7 @@ +package agents + +// Model is the --model value of the profile's command line, or "". +func (p Profile) Model() string { return flagValue(p.Argv, "--model") } + +// Effort is the --effort value of the profile's command line, or "". +func (p Profile) Effort() string { return flagValue(p.Argv, "--effort") }