From 504ee341498f94cd3fbf39f598e2aacd2205480e Mon Sep 17 00:00:00 2001 From: Mano Sriram Date: Fri, 25 Sep 2026 02:27:44 +0530 Subject: [PATCH 1/8] Confirm deletes in a styled dialog, show agent worktree path, drop dirty version label - Ask before removing an agent or deleting an agent type, in a styled dialog instead of the browser's - Show the agent's worktree path next to its name in the inspector; click to copy, hover for its branch - Build versions without the "-dirty" / "+dirty" suffix --- Makefile | 2 +- internal/server/handlers.go | 12 +++++++++++- internal/version/version.go | 3 --- web/app.js | 32 +++++++++++++++++++++++++++++++- web/arrange.html | 15 +++++++++++++-- 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index c0464f5..4ffc7c9 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ # make dist release archives for macOS and Linux in dist/ # make clean remove build output -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null) LDFLAGS := -s -w -X arranger/internal/version.Version=$(VERSION) \ -X arranger/internal/version.Commit=$(COMMIT) \ diff --git a/internal/server/handlers.go b/internal/server/handlers.go index b6e326a..15a09cb 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -114,7 +114,17 @@ func (s *Server) getAgent(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusInternalServerError, err) return } - writeJSON(w, map[string]any{"agent": a, "goal": g}) + // dir is where the agent works, "" until its first run made the worktree + dir, shown := s.o.Dir(a.ID), "" + if _, err := os.Stat(dir); err == nil { + shown = dir + if home, err := os.UserHomeDir(); err == nil { + if rest, ok := strings.CutPrefix(dir, home+string(filepath.Separator)); ok { + shown = "~/" + rest + } + } + } + writeJSON(w, map[string]any{"agent": a, "goal": g, "dir": shown, "branch": git.BranchOf(a.ID)}) } func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) { diff --git a/internal/version/version.go b/internal/version/version.go index 8408545..6c8e251 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -80,9 +80,6 @@ func (i Info) Short() string { func (i Info) String() string { var parts []string if c := i.Short(); c != "" { - if i.Dirty { - c += "+dirty" - } parts = append(parts, c) } if w := i.When(); w != "" { diff --git a/web/app.js b/web/app.js index 16f9978..dde69cf 100644 --- a/web/app.js +++ b/web/app.js @@ -233,10 +233,28 @@ main.addEventListener("pointerdown", e => { main.classList.remove("panning"); }; }); -canvas.addEventListener("click", e => { +// ask shows the styled confirm dialog and resolves true when the user presses the action button. +function ask(title, text, action) { + const d = $("#confirm-dialog"); + $("h3", d).textContent = title; + $("#c-text").textContent = text; + $("#c-text").hidden = !text; + $("#c-ok").textContent = action; + d.returnValue = ""; + d.showModal(); + $("#c-cancel").focus(); // Enter shouldn't delete by accident + return new Promise(r => d.addEventListener("close", () => r(d.returnValue === "ok"), { once: true })); +} + +canvas.addEventListener("click", async e => { if (!e.target.classList.contains("x")) return; const id = e.target.closest(".card").dataset.id, a = agents.get(id); if (BUSY.includes(a.el.dataset.status)) { say(`${a.name} is running; stop it before removing it`, true); return; } + const n = kidsOf(id).length; + if (!await ask(`Remove ${a.name}?`, n + ? `Its ${n === 1 ? "report moves" : `${n} reports move`} up to ${a.parent ? nameOf(a.parent) : "the top level"}.` + : "", "Remove")) return; + if (!agents.has(id)) return; // removed some other way while the dialog was open kidsOf(id).forEach(k => k.parent = a.parent); // reports move up a level if (selected === id) closeInspector(); a.el.remove(); @@ -306,6 +324,7 @@ tform.onsubmit = async e => { e.preventDefault(); try { if (action === "delete") { + if (!await ask(`Delete the ${editingType.name} type?`, "You can't drag new agents from it anymore. Agents already on the canvas keep their settings.", "Delete")) return; await api("DELETE", `/api/types/${editingType.id}`); TYPES.delete(editingType.name); } else { @@ -430,6 +449,13 @@ function fillParents(id) { sel.value = parentOf(id) ?? ""; } +$("#i-dir").onclick = async e => { + const path = e.currentTarget.dataset.path; + if (!path) return; + try { await navigator.clipboard.writeText(path); say("Copied " + path); } + catch { say("Couldn't copy the path", true); } +}; + async function select(id) { let data; try { data = await api("GET", `/api/agents/${encodeURIComponent(id)}`); } @@ -440,6 +466,10 @@ async function select(id) { insp.hidden = false; const { agent: a, goal: g } = data; $("#i-name").textContent = a.name; + const d = $("#i-dir"); + d.textContent = data.dir ? `(${data.dir})` : ""; + d.dataset.path = data.dir; + d.title = data.dir ? `Worktree on branch ${data.branch}. Click to copy the path.` : ""; for (const k of ["title", "body", "criteria", "checks"]) gform.elements[k].value = g[k]; for (const k of ["name", "runtime", "model", "args", "prompt"]) aform.elements[k].value = a[k]; aform.elements.tokenSoft.value = a.tokenSoft || ""; diff --git a/web/arrange.html b/web/arrange.html index f53af0a..84ec019 100644 --- a/web/arrange.html +++ b/web/arrange.html @@ -34,6 +34,7 @@ button:hover { border-color: var(--muted); } button.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); font-weight: 600; } button.danger { color: var(--failed); } +button.primary.danger { background: var(--failed); border-color: var(--failed); color: #fff; } input, select, textarea { background: var(--panel); border: 1px solid var(--line); padding: 6px 8px; width: 100%; } textarea { resize: vertical; min-height: 60px; } .mono, textarea.mono { font-family: var(--mono); font-size: 12px; } @@ -133,7 +134,8 @@ .istats .warn { color: var(--verifying); } .istats .over { color: var(--failed); } #inspector[hidden] { display: none; } .ihead { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--line); } -.ihead .name { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ihead .name { font-weight: 600; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#i-dir { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px var(--mono); color: var(--muted); cursor: copy; } .tabs { display: flex; border-bottom: 1px solid var(--line); } .tabs button { border: 0; border-bottom: 2px solid transparent; background: none; padding: 8px 14px; color: var(--muted); } .tabs button.on { color: var(--text); border-bottom-color: var(--accent); } @@ -255,7 +257,7 @@

Agent types

{{with .Version}}

arranger {{if .ReleaseURL}}{{.Version}}{{else}}{{.Version}}{{end}} - {{if .Short}}
commit {{if .CommitURL}}{{.Short}}{{else}}{{.Short}}{{end}}{{if .Dirty}}+dirty{{end}}{{end}} + {{if .Short}}
commit {{if .CommitURL}}{{.Short}}{{else}}{{.Short}}{{end}}{{end}} {{if .When}}
{{.When}}{{end}}
{{.Go}} · all releases

{{end}} @@ -268,6 +270,7 @@

Agent types

+ @@ -379,6 +382,14 @@

Merge 's work

+ +
+

+

+
+
+
+

Project stats

From e6e9c75bac1485c70535b0a4d9362f8c15f213bf Mon Sep 17 00:00:00 2001 From: Mano Sriram Date: Fri, 25 Sep 2026 12:26:38 +0530 Subject: [PATCH 2/8] Approve a manager's plan before its team runs; tidy the header - Add opt-in plan approval: a manager stops after planning until the user approves, edits, sends back or cancels its plan - Review the plan in the manager's Goal tab: edit each report's goal and checks, skip or add reports, warnings for checks that can't fail - Apply the same review to Request changes routed by a manager - Save plans as drafts (plans table); report goals change only on approval, and drafts expire after 24h or on restart - Add GET/POST /api/agents/{id}/plan, and refuse moving or removing a report while its manager's plan waits - Show "Let me approve the plan before the team runs" in the Goal tab and Settings for managers - Show a pulsing banner in the Goal tab while an agent works, with what it's doing and for how long - Show proposed goals on report boxes and an amber "awaiting" status on the canvas - Regroup the header into project, status strip and actions; pulsing "Needs you (N)", cost opens Stats, drop the done counter - Mention plan approval in the README --- README.md | 4 +- internal/orch/approve.go | 128 ++++++++++++++++++ internal/orch/approve_test.go | 220 ++++++++++++++++++++++++++++++ internal/orch/manager.go | 139 ++++++++++++++----- internal/orch/orch.go | 9 +- internal/server/handlers.go | 45 +++++++ internal/server/server.go | 2 + internal/server/server_test.go | 51 +++++++ internal/store/store.go | 73 +++++++++- internal/store/store_test.go | 33 +++++ web/app.js | 238 ++++++++++++++++++++++++++++++--- web/arrange.html | 104 ++++++++++---- 12 files changed, 959 insertions(+), 87 deletions(-) create mode 100644 internal/orch/approve.go create mode 100644 internal/orch/approve_test.go diff --git a/README.md b/README.md index 5a9a738..9937a25 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ One agent with one huge prompt drifts, forgets, and claims success. A team with - **Checks decide "done", not the agent.** Arranger runs your checks itself after every attempt and sends failures back with the exact output, up to three tries. - **Parallel and isolated.** Every agent works in its own git worktree and branch, so agents on the same level run at the same time without touching each other's files. Your branches are never changed until you merge. - **Review before anything merges.** Managers read each report's diff and accept it or send it back with feedback. -- **You stay in control.** Live logs and status for every agent, a diff per agent, remove or promote single changes, revert any checkpoint, token limits per agent, and one-click merge into the branch you pick. +- **You stay in control.** Approve or edit a manager's plan before its team runs, live logs and status for every agent, a diff per agent, remove or promote single changes, revert any checkpoint, token limits per agent, and one-click merge into the branch you pick. ## Install @@ -56,6 +56,8 @@ You also need `git`, and at least one agent CLI on your `PATH` (for example `cla 5. **Run.** Press **Run**. A manager plans one subgoal (with its own checks) for each report, runs them in parallel, reviews and merges their work into its own branch, then runs its own checks. Links on the canvas animate while agents hand work down and report back. The **Logs** tab streams what each agent is doing. + Want to check the plan first? Tick **Let me approve the plan before the team runs** under the manager's goal. The manager then stops after planning and shows you each report's goal and checks: edit them, skip or add reports, send the plan back with feedback, or approve it to start the team. + 6. **Iterate.** Not quite right? Select the agent and write what should change in **Request changes**. Ask the top manager and it passes each part to the agents it concerns and re-runs only them; everyone keeps their work, and the changes are reviewed, merged and checked like any run. (**Run** plans again from the goal instead.) 7. **Review and merge.** Open the **Diff** tab to see every change, remove or promote single hunks, or revert a checkpoint. When you're happy, press **Merge…** to merge the agent's branch into `main` (or any branch, created if needed) as a merge commit, a squash, or a fast-forward. diff --git a/internal/orch/approve.go b/internal/orch/approve.go new file mode 100644 index 0000000..ce6aedb --- /dev/null +++ b/internal/orch/approve.go @@ -0,0 +1,128 @@ +package orch + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "arranger/internal/store" +) + +// planTimeout is how long a plan waits for the user's decision before the run stops. +var planTimeout = 24 * time.Hour + +// ErrStale means there's no plan waiting under that draft id: it was decided (maybe in another +// tab), replaced by a new plan, or its run ended. +var ErrStale = errors.New("this plan isn't waiting for a decision anymore; reload it") + +// Decision is the user's answer to a manager's draft plan. +type Decision struct { + Draft int64 `json:"draft"` + Action string `json:"action"` // approve | replan | cancel + Subgoals []subgoal `json:"subgoals"` // approve, kind "plan": the plan as the user left it + Changes []revision `json:"changes"` // approve, kind "revision" + Feedback string `json:"feedback"` // replan: what the new plan should do differently +} + +// Awaiting reports whether the agent's run is waiting for the user to decide on its plan. +func (o *Orchestrator) Awaiting(agentID string) bool { + o.mu.Lock() + defer o.mu.Unlock() + _, ok := o.approvals[agentID] + return ok +} + +// Decide hands the user's decision to the run waiting on the draft. An approved plan is checked +// the same way the manager's own plan is. +func (o *Orchestrator) Decide(agentID string, d Decision) error { + o.mu.Lock() + ch := o.approvals[agentID] + o.mu.Unlock() + p, err := o.Store.PendingPlan(agentID) + if ch == nil || err != nil || p.ID != d.Draft { + return ErrStale + } + var status string + var final any + switch d.Action { + case "approve": + kids, err := o.Store.Children(agentID) + if err != nil { + return err + } + byID, goals := map[string]store.Agent{}, map[string]store.Goal{} + for _, k := range kids { + byID[k.ID] = k + goals[k.ID], _ = o.Store.Goal(k.ID) + } + if p.Kind == "revision" { + if err := validateRevision(d.Changes, byID, goals); err != nil { + return err + } + d.Subgoals, final = nil, revisionDraft{Changes: d.Changes} + } else { + if err := validatePlan(d.Subgoals, byID); err != nil { + return err + } + d.Changes, final = nil, planDraft{Subgoals: d.Subgoals} + } + status = "approved" + case "replan": + status = "replanned" + case "cancel": + status = "cancelled" + default: + return fmt.Errorf("action must be approve, replan or cancel") + } + if !o.Store.DecidePlan(p.ID, status, d.Feedback, final) { + return ErrStale + } + ch <- d // buffered, and DecidePlan lets only one decision through + return nil +} + +// await saves the proposed plan as a draft and blocks until the user decides on it. It returns +// the decision, or the run's final status when the run ended instead (stopped, or no decision +// in time). Waiting holds no parallel slot: those are only taken while an agent CLI runs. +func (j *job) await(kind string, proposed any) (Decision, string) { + st := j.o.Store + id, err := st.SavePlan(j.a.ID, kind, proposed) + if err != nil { + return Decision{}, j.fail("failed", "couldn't save the plan: "+err.Error()) + } + ch := make(chan Decision, 1) + j.o.mu.Lock() + j.o.approvals[j.a.ID] = ch + j.o.mu.Unlock() + defer func() { + j.o.mu.Lock() + delete(j.o.approvals, j.a.ID) + j.o.mu.Unlock() + }() + j.status("awaiting", nil) + j.emit("plan", "plan ready: waiting for your approval", "") + j.o.Hub.Publish(j.p.ID, map[string]any{"type": "plan", "agent": j.a.ID}) + + timer := time.NewTimer(planTimeout) + defer timer.Stop() + select { + case d := <-ch: + return d, "" + case <-j.ctx.Done(): + st.DecidePlan(id, "cancelled", "", nil) + return Decision{}, j.fail("stopped", "stopped by user") + case <-timer.C: + if st.DecidePlan(id, "expired", "", nil) { + return Decision{}, j.fail("stopped", "the plan wasn't approved in time") + } + return <-ch, "" // the user decided just as time ran out + } +} + +// sameJSON reports whether a and b encode the same, e.g. a plan and the plan the user approved. +func sameJSON(a, b any) bool { + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return string(x) == string(y) +} diff --git a/internal/orch/approve_test.go b/internal/orch/approve_test.go new file mode 100644 index 0000000..e6b4abd --- /dev/null +++ b/internal/orch/approve_test.go @@ -0,0 +1,220 @@ +package orch + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "arranger/internal/agents" + "arranger/internal/store" +) + +// approvalTeam sets up a manager "m" that asks for approval, with reports "a" and "b". The fake +// manager plans both reports (a new plan when it gets feedback), routes changes to "a", and +// accepts everything in review. It records each prompt it gets in prompts. +func approvalTeam(t *testing.T) (o *Orchestrator, prompts string) { + t.Helper() + o = setup(t) + prompts = filepath.Join(t.TempDir(), "prompts") + plain := func(l []byte) []agents.Event { return []agents.Event{{Kind: "msg", Text: string(l)}} } + agents.Runtimes["apmgr"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string { + return []string{"-c", `p=$(cat); printf '%s\n=====\n' "$p" >> ` + prompts + ` +case "$p" in +*"WHAT EACH ONE ALREADY DID"*) echo '{"changes":[{"agent":"a","change":"write A","checks":["grep -q A a.txt"]}]}' ;; +*"sent back your plan"*) echo '{"subgoals":[{"agent":"b","title":"only b","checks":["test -f b.txt"]}]}' ;; +*"YOUR TEAM:"*) echo '{"subgoals":[{"agent":"a","title":"write a","checks":["test -f a.txt"]},{"agent":"b","title":"write b","checks":["test -f b.txt"]}]}' ;; +*) echo '{"verdicts":[]}' ;; +esac`} + }} + agents.Runtimes["apworker"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string { + return []string{"-c", `p=$(cat); case "$p" in *"write A"*) echo A > a.txt ;; *"You are Alpha"*) echo a > a.txt ;; *) echo b > b.txt ;; esac`} + }} + t.Cleanup(func() { delete(agents.Runtimes, "apmgr"); delete(agents.Runtimes, "apworker") }) + + p, _ := o.Store.CreateProject(store.Project{Name: "t", Repo: newRepo(t), Base: "main"}) + if err := o.Store.SaveArrangement(p.ID, []store.Agent{ + {ID: "m", Name: "Lead", Role: "manager", Runtime: "apmgr", ApprovePlan: true}, + {ID: "a", Name: "Alpha", Role: "coder", Parent: "m", Runtime: "apworker"}, + {ID: "b", Name: "Beta", Role: "coder", Parent: "m", Runtime: "apworker"}, + }); err != nil { + t.Fatal(err) + } + o.Store.SaveGoal("b", store.Goal{Title: "b's old goal", Checks: "true"}) + o.Store.SaveGoal("m", store.Goal{Title: "files", Checks: "true"}) + return o, prompts +} + +// waitPlan waits until m's plan waits for a decision and returns the draft. +func waitPlan(t *testing.T, o *Orchestrator) store.Plan { + t.Helper() + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { + if p, err := o.Store.PendingPlan("m"); err == nil && o.Awaiting("m") { + if g, _ := o.Store.Goal("m"); g.Status != "awaiting" { + t.Fatalf("status while waiting: %q", g.Status) + } + return p + } + } + t.Fatal("no plan to approve") + return store.Plan{} +} + +func runs(o *Orchestrator, id string) (n int) { + o.Store.SQL().QueryRow(`SELECT count(*) FROM runs WHERE agent_id=?`, id).Scan(&n) + return +} + +// Nothing is saved or run until the user approves; the edited plan is what runs. +func TestApproveEditedPlan(t *testing.T) { + o, _ := approvalTeam(t) + if err := o.Start("m"); err != nil { + t.Fatal(err) + } + p := waitPlan(t, o) + var draft planDraft + json.Unmarshal(p.JSON, &draft) + if p.Kind != "plan" || len(draft.Subgoals) != 2 { + t.Fatalf("draft: %+v", p) + } + if g, _ := o.Store.Goal("a"); g.Title != "" { + t.Fatal("a report's goal changed before approval") + } + if g, _ := o.Store.Goal("b"); g.Title != "b's old goal" || runs(o, "a")+runs(o, "b") != 0 { + t.Fatal("reports touched before approval") + } + + // the user edits a's goal and skips b + edited := []subgoal{{Agent: "a", Title: "write a, edited", Checks: []string{"test -f a.txt"}}} + if err := o.Decide("m", Decision{Draft: p.ID + 1, Action: "approve", Subgoals: edited}); !errors.Is(err, ErrStale) { + t.Fatalf("a wrong draft id should be stale, got %v", err) + } + if err := o.Decide("m", Decision{Draft: p.ID, Action: "approve", Subgoals: []subgoal{{Agent: "a", Title: "no checks"}}}); err == nil { + t.Fatal("a plan without checks should be refused") + } + if err := o.Decide("m", Decision{Draft: p.ID, Action: "approve", Subgoals: edited}); err != nil { + t.Fatal(err) + } + if err := o.Decide("m", Decision{Draft: p.ID, Action: "cancel"}); !errors.Is(err, ErrStale) { + t.Fatal("a second decision should be stale") + } + if g := waitDone(t, o, "m"); g.Status != "done" { + es, _ := o.Store.Events("m", 50) + t.Fatalf("manager: %+v\n%+v", g, es) + } + if g, _ := o.Store.Goal("a"); g.Title != "write a, edited" || g.Status != "done" { + t.Fatalf("a: %+v", g) + } + if g, _ := o.Store.Goal("b"); g.Title != "b's old goal" || runs(o, "b") != 0 { + t.Fatalf("skipped b should keep its goal and not run: %+v", g) + } + var status, saved string + o.Store.SQL().QueryRow(`SELECT status, json FROM plans WHERE id=?`, p.ID).Scan(&status, &saved) + if status != "approved" || !strings.Contains(saved, "edited") { + t.Fatalf("draft after approval: %s %s", status, saved) + } +} + +// Sending a plan back with feedback plans again with that feedback; nothing ran in between. +func TestReplanWithFeedback(t *testing.T) { + o, prompts := approvalTeam(t) + o.Start("m") + first := waitPlan(t, o) + if err := o.Decide("m", Decision{Draft: first.ID, Action: "replan", Feedback: "Beta can do it alone"}); err != nil { + t.Fatal(err) + } + var second store.Plan + for deadline := time.Now().Add(20 * time.Second); second.ID <= first.ID; time.Sleep(20 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("no second plan") + } + second, _ = o.Store.PendingPlan("m") + } + b, _ := os.ReadFile(prompts) + if !strings.Contains(string(b), "Beta can do it alone") || !strings.Contains(string(b), `"title":"write a"`) { + t.Fatalf("the new plan should get the feedback and the plan sent back:\n%s", b) + } + if runs(o, "a")+runs(o, "b") != 0 { + t.Fatal("reports ran before approval") + } + if !strings.Contains(string(second.JSON), "only b") { + t.Fatalf("second plan: %s", second.JSON) + } + o.Stop("m") + waitDone(t, o, "m") +} + +// Cancel, Stop and running out of time all end the run as stopped without touching the reports. +func TestPlanCancelStopExpire(t *testing.T) { + defer func(d time.Duration) { planTimeout = d }(planTimeout) + for _, how := range []string{"cancel", "stop", "expire"} { + t.Run(how, func(t *testing.T) { + o, _ := approvalTeam(t) + if how == "expire" { + planTimeout = 300 * time.Millisecond + } else { + planTimeout = time.Hour + } + o.Start("m") + p := waitPlan(t, o) + switch how { + case "cancel": + if err := o.Decide("m", Decision{Draft: p.ID, Action: "cancel"}); err != nil { + t.Fatal(err) + } + case "stop": + o.Stop("m") + } + if g := waitDone(t, o, "m"); g.Status != "stopped" { + t.Fatalf("manager: %+v", g) + } + want := map[string]string{"cancel": "cancelled", "stop": "cancelled", "expire": "expired"}[how] + var status string + o.Store.SQL().QueryRow(`SELECT status FROM plans WHERE id=?`, p.ID).Scan(&status) + if status != want { + t.Fatalf("draft status %q, want %q", status, want) + } + if g, _ := o.Store.Goal("b"); g.Title != "b's old goal" || runs(o, "a")+runs(o, "b") != 0 { + t.Fatal("reports touched") + } + if o.Awaiting("m") { + t.Fatal("still marked as waiting") + } + }) + } +} + +// A requested change is routed as a draft too; approving it runs only the reports it names. +func TestApproveRevision(t *testing.T) { + o, _ := approvalTeam(t) + o.Start("m") + p := waitPlan(t, o) + var draft planDraft + json.Unmarshal(p.JSON, &draft) + o.Decide("m", Decision{Draft: p.ID, Action: "approve", Subgoals: draft.Subgoals}) + if g := waitDone(t, o, "m"); g.Status != "done" { + t.Fatalf("first run: %+v", g) + } + + if err := o.Revise("m", "capital A please"); err != nil { + t.Fatal(err) + } + p = waitPlan(t, o) + if p.Kind != "revision" || !strings.Contains(string(p.JSON), "write A") { + t.Fatalf("revision draft: %+v %s", p, p.JSON) + } + var rev revisionDraft + json.Unmarshal(p.JSON, &rev) + if err := o.Decide("m", Decision{Draft: p.ID, Action: "approve", Changes: rev.Changes}); err != nil { + t.Fatal(err) + } + if g := waitDone(t, o, "m"); g.Status != "done" { + t.Fatalf("revision: %+v", g) + } + if runs(o, "a") != 2 || runs(o, "b") != 1 { + t.Fatalf("only a should re-run: a=%d b=%d", runs(o, "a"), runs(o, "b")) + } +} diff --git a/internal/orch/manager.go b/internal/orch/manager.go index 6deed18..42bb8c8 100644 --- a/internal/orch/manager.go +++ b/internal/orch/manager.go @@ -49,24 +49,72 @@ func runManager(j *job, g store.Goal, dir string, kids []store.Agent, feedback s planned = planned || goals[k.ID].Title != "" } - // 1. plan, or route the requested change to the reports it concerns - j.newRun(1) + // 1. plan, or route the requested change to the reports it concerns; with approval on, the + // user approves, edits, sends back or cancels it before anything is saved or run var team []store.Agent - var err error changes := map[string]string{} // report id -> its part of the requested change - if j.change != "" && planned { - team, err = j.revise(g, dir, kids, byID, goals, feedback, changes) - } else { - team, err = j.plan(g, dir, kids, byID, feedback) - } - if j.ctx.Err() != nil { - return j.fail("stopped", "stopped by user") - } - if errors.Is(err, errTokenLimit) { - return j.fail("failed", j.limitMsg()) - } - if err != nil { - return j.fail("failed", "no valid plan: "+err.Error()) + revising := j.change != "" && planned + planFeedback := feedback + for { + j.newRun(1) + var sgs []subgoal + var cs []revision + var err error + if revising { + cs, err = j.proposeRevision(g, dir, kids, byID, goals, planFeedback) + } else { + sgs, err = j.proposePlan(g, dir, kids, byID, planFeedback) + } + if j.ctx.Err() != nil { + return j.fail("stopped", "stopped by user") + } + if errors.Is(err, errTokenLimit) { + return j.fail("failed", j.limitMsg()) + } + if err != nil { + return j.fail("failed", "no valid plan: "+err.Error()) + } + if !j.a.ApprovePlan { + team = j.apply(sgs, cs, byID, goals, changes) + break + } + kind, proposed := "plan", any(planDraft{Subgoals: sgs}) + if revising { + kind, proposed = "revision", revisionDraft{Changes: cs} + } + d, ended := j.await(kind, proposed) + if ended != "" { + return ended + } + if d.Action == "cancel" { + return j.fail("stopped", "you cancelled the plan") + } + if d.Action == "replan" { + b, _ := json.Marshal(proposed) + note := "The user sent back your plan without running it" + if d.Feedback != "" { + note += " and said: " + d.Feedback + } + j.emit("request", "you asked for a new plan"+strings.TrimPrefix(note, "The user sent back your plan without running it"), "") + planFeedback = strings.TrimSpace(feedback + "\n\n" + note + ".\nThe plan they sent back: " + string(b)) + j.status("planning", nil) + continue + } + edited := false + if revising { + edited = !sameJSON(cs, d.Changes) + sgs, cs = nil, d.Changes + } else { + edited = !sameJSON(sgs, d.Subgoals) + sgs, cs = d.Subgoals, nil + } + if edited { + j.emit("request", "you approved the plan with edits", "") + } else { + j.emit("request", "you approved the plan", "") + } + team = j.apply(sgs, cs, byID, goals, changes) + break } // 2. run children, review, merge; rejected children re-run with the review feedback @@ -139,38 +187,59 @@ func runManager(j *job, g store.Goal, dir string, kids []store.Agent, feedback s return j.status("done", map[string]any{"feedback": ""}) } -// plan splits the manager's goal into a subgoal per report and saves them as their goals. -func (j *job) plan(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, feedback string) ([]store.Agent, error) { - var plan struct{ Subgoals []subgoal } +// planDraft and revisionDraft are what a manager proposes, as stored and shown for approval. +type planDraft struct { + Subgoals []subgoal `json:"subgoals"` +} + +type revisionDraft struct { + Changes []revision `json:"changes"` +} + +// proposePlan asks the manager to split its goal into a subgoal per report. +func (j *job) proposePlan(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, feedback string) ([]subgoal, error) { + var plan planDraft err := j.decide(planPrompt(j.a, g, kids, feedback, j.change), dir, &plan, func() error { return validatePlan(plan.Subgoals, byID) }) - if err != nil { - return nil, err + return plan.Subgoals, err +} + +// apply saves a plan (sgs) or a routed change (cs) to the reports and returns who runs. +func (j *job) apply(sgs []subgoal, cs []revision, byID map[string]store.Agent, goals map[string]store.Goal, changes map[string]string) []store.Agent { + if cs != nil { + return j.applyRevision(cs, byID, goals, changes) } + return j.applyPlan(sgs, byID) +} + +// applyPlan saves each subgoal as its report's goal. +func (j *job) applyPlan(sgs []subgoal, byID map[string]store.Agent) []store.Agent { st := j.o.Store - st.AddDecision(j.a.ID, "plan", plan) + st.AddDecision(j.a.ID, "plan", planDraft{Subgoals: sgs}) var team []store.Agent - for _, s := range plan.Subgoals { + for _, s := range sgs { st.SaveGoal(s.Agent, store.Goal{Title: s.Title, Body: s.Body, Criteria: s.Criteria, Checks: strings.Join(s.Checks, "\n")}) st.SetGoal(s.Agent, map[string]any{"status": "idle", "attempts": 0, "feedback": "", "passed": 0, "total": 0}) team = append(team, byID[s.Agent]) j.emit("plan", fmt.Sprintf("%s → %s (checks: %s)", byID[s.Agent].Name, s.Title, strings.Join(s.Checks, "; ")), "") } - return team, nil + return team } -// revise asks the manager which reports the user's change concerns and what each must do. Those -// reports keep their goals (plus any check that proves the change) and are the only ones re-run. -func (j *job) revise(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, goals map[string]store.Goal, - feedback string, changes map[string]string) ([]store.Agent, error) { - var rev struct{ Changes []revision } +// proposeRevision asks the manager which reports the user's change concerns and what each must do. +func (j *job) proposeRevision(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, goals map[string]store.Goal, + feedback string) ([]revision, error) { + var rev revisionDraft err := j.decide(revisePrompt(j.a, g, kids, goals, j.change, feedback), dir, &rev, func() error { return validateRevision(rev.Changes, byID, goals) }) - if err != nil { - return nil, err - } + return rev.Changes, err +} + +// applyRevision gives each concerned report its part of the change. They keep their goals (plus +// any check that proves the change) and are the only ones re-run. +func (j *job) applyRevision(cs []revision, byID map[string]store.Agent, goals map[string]store.Goal, changes map[string]string) []store.Agent { st := j.o.Store - st.AddDecision(j.a.ID, "revision", rev) + st.AddDecision(j.a.ID, "revision", revisionDraft{Changes: cs}) var team []store.Agent - for _, c := range rev.Changes { + for _, c := range cs { kg := goals[c.Agent] if added := addChecks(&kg, c.Checks); len(added) > 0 { st.SaveGoal(c.Agent, kg) @@ -184,7 +253,7 @@ func (j *job) revise(g store.Goal, dir string, kids []store.Agent, byID map[stri } j.emit("plan", msg, "") } - return team, nil + return team } // addChecks appends the checks g doesn't have yet and returns them. diff --git a/internal/orch/orch.go b/internal/orch/orch.go index ab359cf..20c77ba 100644 --- a/internal/orch/orch.go +++ b/internal/orch/orch.go @@ -36,15 +36,16 @@ type Orchestrator struct { Hub *Hub Root string // one worktree per agent lives here - slots chan struct{} // caps concurrent agent processes - mu sync.Mutex - running map[string]context.CancelFunc // agent id -> cancel of its run + slots chan struct{} // caps concurrent agent processes + mu sync.Mutex + running map[string]context.CancelFunc // agent id -> cancel of its run + approvals map[string]chan Decision // manager id -> its run, waiting for the user to decide on its plan } // New returns an orchestrator that runs at most parallel agent processes at once. func New(st *store.Store, root string, parallel int) *Orchestrator { return &Orchestrator{Store: st, Hub: NewHub(), Root: root, slots: make(chan struct{}, max(1, parallel)), - running: map[string]context.CancelFunc{}} + running: map[string]context.CancelFunc{}, approvals: map[string]chan Decision{}} } // Dir is the agent's worktree path (it may not exist yet). diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 15a09cb..cfe026e 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1,6 +1,7 @@ package server import ( + "database/sql" "errors" "fmt" "net/http" @@ -11,6 +12,7 @@ import ( "arranger/internal/agents" "arranger/internal/git" + "arranger/internal/orch" "arranger/internal/store" ) @@ -90,11 +92,24 @@ func (s *Server) saveArrangement(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusInternalServerError, err) return } + parent := map[string]string{} + for _, a := range as { + parent[a.ID] = a.Parent + } + names := map[string]string{} + for _, a := range old { + names[a.ID] = a.Name + } for _, a := range old { if !keep[a.ID] && s.o.IsRunning(a.ID) { fail(w, http.StatusConflict, fmt.Errorf("%s is running; stop it before removing it", a.Name)) return } + // a waiting plan names its manager's reports; they stay put until it's decided + if a.Parent != "" && s.o.Awaiting(a.Parent) && (!keep[a.ID] || parent[a.ID] != a.Parent) { + fail(w, http.StatusConflict, fmt.Errorf("%s's plan is waiting for you; approve or cancel it before moving or removing %s", names[a.Parent], a.Name)) + return + } } if err := s.st.SaveArrangement(r.PathValue("id"), as); err != nil { fail(w, http.StatusBadRequest, err) @@ -184,6 +199,36 @@ func (s *Server) revise(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) } +// getPlan returns the agent's plan waiting for the user's decision. +func (s *Server) getPlan(w http.ResponseWriter, r *http.Request) { + p, err := s.st.PendingPlan(r.PathValue("id")) + if errors.Is(err, sql.ErrNoRows) || err == nil && !s.o.Awaiting(p.Agent) { + http.Error(w, "no plan is waiting for a decision", http.StatusNotFound) + return + } + if err != nil { + fail(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, p) +} + +// decidePlan approves (possibly edited), sends back, or cancels the agent's waiting plan. +func (s *Server) decidePlan(w http.ResponseWriter, r *http.Request) { + var d orch.Decision + if !readJSON(w, r, &d) { + return + } + if err := s.o.Decide(r.PathValue("id"), d); errors.Is(err, orch.ErrStale) { + fail(w, http.StatusConflict, err) + return + } else if err != nil { + fail(w, http.StatusBadRequest, err) + return + } + w.WriteHeader(http.StatusAccepted) +} + func (s *Server) stop(w http.ResponseWriter, r *http.Request) { s.o.Stop(r.PathValue("id")) w.WriteHeader(http.StatusNoContent) diff --git a/internal/server/server.go b/internal/server/server.go index 4f86866..b84c290 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -52,6 +52,8 @@ func New(st *store.Store, o *orch.Orchestrator, loopback bool) http.Handler { mux.HandleFunc("PUT /api/agents/{id}/goal", s.saveGoal) mux.HandleFunc("POST /api/agents/{id}/run", s.run) mux.HandleFunc("POST /api/agents/{id}/revise", s.revise) + mux.HandleFunc("GET /api/agents/{id}/plan", s.getPlan) + mux.HandleFunc("POST /api/agents/{id}/plan", s.decidePlan) mux.HandleFunc("POST /api/agents/{id}/stop", s.stop) mux.HandleFunc("GET /api/agents/{id}/events", s.events) mux.HandleFunc("GET /api/agents/{id}/diff", s.diff) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4296c9a..0b345bb 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" "time" @@ -500,3 +501,53 @@ func TestRequestChangesAPI(t *testing.T) { t.Fatalf("goal kept, run done: %+v", g) } } + +// A manager that asks for approval waits with its plan; the page reads it, can't move its +// reports meanwhile, and approves it through the API. +func TestPlanApprovalAPI(t *testing.T) { + a := newApp(t) + repo := newRepo(t) + os.WriteFile(filepath.Join(repo, "plan.sh"), []byte(`cat >/dev/null; echo '{"subgoals":[{"agent":"k","title":"do it","checks":["true"]}]}'`+"\n"), 0o644) + git.Commit(repo, "add plan.sh") + var p store.Project + json.Unmarshal([]byte(a.must(200, "POST", "/api/projects", `{"name":"r","repo":"`+repo+`"}`)), &p) + arrangement := `[{"id":"m","name":"Lead","role":"manager","approvePlan":true},{"id":"k","name":"Kid","role":"coder","parent":"m"}]` + a.must(204, "POST", "/api/projects/"+p.ID+"/arrangement", arrangement) + if m, _, _ := a.st.Agent("m"); !m.ApprovePlan { + t.Fatal("a new agent should take approvePlan from the arrangement") + } + a.must(204, "PUT", "/api/agents/m", `{"name":"Lead","runtime":"generic","args":"sh plan.sh","approvePlan":true}`) + a.must(204, "PUT", "/api/agents/k", `{"name":"Kid","runtime":"generic","args":"true"}`) + a.must(204, "PUT", "/api/agents/m/goal", `{"title":"lead","checks":"true"}`) + a.must(404, "GET", "/api/agents/m/plan", "") + + a.must(202, "POST", "/api/agents/m/run", "") + var draft store.Plan + for deadline := time.Now().Add(20 * time.Second); draft.ID == 0; time.Sleep(50 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("no plan to approve") + } + if code, body := a.do("GET", "/api/agents/m/plan", ""); code == 200 { + json.Unmarshal([]byte(body), &draft) + } + } + if draft.Kind != "plan" || !strings.Contains(string(draft.JSON), "do it") { + t.Fatalf("draft: %+v", draft) + } + a.must(409, "POST", "/api/projects/"+p.ID+"/arrangement", `[{"id":"m","name":"Lead","role":"manager"},{"id":"k","name":"Kid","role":"coder"}]`) + id := strconv.FormatInt(draft.ID, 10) + a.must(409, "POST", "/api/agents/m/plan", `{"draft":`+id+`1,"action":"approve"}`) + a.must(400, "POST", "/api/agents/m/plan", `{"draft":`+id+`,"action":"approve","subgoals":[{"agent":"k","title":"no checks"}]}`) + a.must(202, "POST", "/api/agents/m/plan", `{"draft":`+id+`,"action":"approve","subgoals":[{"agent":"k","title":"do it now","checks":["true"]}]}`) + for deadline := time.Now().Add(20 * time.Second); a.o.IsRunning("m"); time.Sleep(50 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("run never finished") + } + } + if g, _ := a.st.Goal("m"); g.Status != "done" { + t.Fatalf("manager: %+v", g) + } + if g, _ := a.st.Goal("k"); g.Title != "do it now" || g.Status != "done" { + t.Fatalf("report runs the approved goal: %+v", g) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 87f1296..9551725 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -38,6 +38,9 @@ CREATE TABLE IF NOT EXISTS agent_types ( model TEXT NOT NULL DEFAULT '', args TEXT NOT NULL DEFAULT '', prompt TEXT NOT NULL DEFAULT ''); CREATE TABLE IF NOT EXISTS decisions ( id INTEGER PRIMARY KEY, agent_id TEXT NOT NULL, ts INTEGER NOT NULL, kind TEXT NOT NULL, json TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS plans ( + id INTEGER PRIMARY KEY, agent_id TEXT NOT NULL, kind TEXT NOT NULL, json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', feedback TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL, decided INTEGER NOT NULL DEFAULT 0); ` // migrations add columns to tables created by older versions; "duplicate column" errors are expected. @@ -51,7 +54,10 @@ var migrations = []string{ `ALTER TABLE agents ADD COLUMN x REAL`, // canvas position; NULL = lay out automatically `ALTER TABLE agents ADD COLUMN y REAL`, // a crash mid-run leaves stale statuses behind - `UPDATE goals SET status = 'stopped' WHERE status IN ('starting', 'running', 'verifying', 'planning', 'waiting', 'reviewing')`, + `UPDATE goals SET status = 'stopped' WHERE status IN ('starting', 'running', 'verifying', 'planning', 'waiting', 'reviewing', 'awaiting')`, + `ALTER TABLE agents ADD COLUMN approve_plan INTEGER NOT NULL DEFAULT 0`, // a manager waits for the user to approve its plan (opt-in) + // nobody is waiting on a draft plan after a restart + `UPDATE plans SET status = 'expired', decided = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE status = 'pending'`, } type Project struct { @@ -75,6 +81,8 @@ type Agent struct { Color string `json:"color"` X *float64 `json:"x"` // nil until the user places the box Y *float64 `json:"y"` + + ApprovePlan bool `json:"approvePlan"` // as a manager, wait for the user to approve each plan } // AgentType is a user-defined palette entry: a role with default settings for new agents. @@ -207,11 +215,11 @@ func (s *Store) Projects() ([]Project, error) { return ps, rows.Err() } -const agentCols = `id, name, role, parent, runtime, model, prompt, args, token_soft, token_hard, color, x, y` +const agentCols = `id, name, role, parent, runtime, model, prompt, args, token_soft, token_hard, color, x, y, approve_plan` func scanAgent(sc interface{ Scan(...any) error }, extra ...any) (a Agent, err error) { var x, y sql.NullFloat64 - err = sc.Scan(append([]any{&a.ID, &a.Name, &a.Role, &a.Parent, &a.Runtime, &a.Model, &a.Prompt, &a.Args, &a.Soft, &a.Hard, &a.Color, &x, &y}, extra...)...) + err = sc.Scan(append([]any{&a.ID, &a.Name, &a.Role, &a.Parent, &a.Runtime, &a.Model, &a.Prompt, &a.Args, &a.Soft, &a.Hard, &a.Color, &x, &y, &a.ApprovePlan}, extra...)...) if x.Valid && y.Valid { a.X, a.Y = &x.Float64, &y.Float64 } @@ -252,8 +260,8 @@ func (s *Store) Agent(id string) (a Agent, projectID string, err error) { // UpdateAgent saves an agent's settings (not its place in the tree). func (s *Store) UpdateAgent(a Agent) error { - _, err := s.db.Exec(`UPDATE agents SET name=?, runtime=?, model=?, prompt=?, args=?, token_soft=?, token_hard=?, color=? WHERE id=?`, - a.Name, a.Runtime, a.Model, a.Prompt, a.Args, a.Soft, a.Hard, a.Color, a.ID) + _, err := s.db.Exec(`UPDATE agents SET name=?, runtime=?, model=?, prompt=?, args=?, token_soft=?, token_hard=?, color=?, approve_plan=? WHERE id=?`, + a.Name, a.Runtime, a.Model, a.Prompt, a.Args, a.Soft, a.Hard, a.Color, a.ApprovePlan, a.ID) return err } @@ -277,9 +285,9 @@ func (s *Store) SaveArrangement(projectID string, as []Agent) error { rt = "claude" } // settings only apply to new agents (e.g. from a custom type); existing ones keep theirs - if _, err := tx.Exec(`INSERT INTO agents(id, project_id, name, role, parent, pos, runtime, model, args, prompt, color, token_soft, token_hard, x, y) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + if _, err := tx.Exec(`INSERT INTO agents(id, project_id, name, role, parent, pos, runtime, model, args, prompt, color, token_soft, token_hard, x, y, approve_plan) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, role=excluded.role, parent=excluded.parent, pos=excluded.pos, x=excluded.x, y=excluded.y`, - a.ID, projectID, a.Name, a.Role, a.Parent, i, rt, a.Model, a.Args, a.Prompt, a.Color, a.Soft, a.Hard, a.X, a.Y); err != nil { + a.ID, projectID, a.Name, a.Role, a.Parent, i, rt, a.Model, a.Args, a.Prompt, a.Color, a.Soft, a.Hard, a.X, a.Y, a.ApprovePlan); err != nil { return err } keep[a.ID] = true @@ -373,6 +381,57 @@ func (s *Store) AddDecision(agentID, kind string, v any) { } } +// Plan is a manager's plan (kind "plan": subgoals) or routing of a requested change (kind +// "revision": changes), kept as a draft while the user decides on it. +type Plan struct { + ID int64 `json:"id"` + Agent string `json:"agent"` + Kind string `json:"kind"` + JSON json.RawMessage `json:"plan"` + Status string `json:"status"` // pending | approved | replanned | cancelled | expired + Feedback string `json:"feedback"` + Created int64 `json:"created"` +} + +// SavePlan stores v as the agent's pending draft and returns its id. +func (s *Store) SavePlan(agentID, kind string, v any) (int64, error) { + b, err := json.Marshal(v) + if err != nil { + return 0, err + } + res, err := s.db.Exec(`INSERT INTO plans(agent_id, kind, json, created) VALUES(?,?,?,?)`, agentID, kind, string(b), time.Now().UnixMilli()) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// PendingPlan returns the agent's draft waiting for a decision; sql.ErrNoRows when there is none. +func (s *Store) PendingPlan(agentID string) (p Plan, err error) { + var raw string + err = s.db.QueryRow(`SELECT id, agent_id, kind, json, status, feedback, created FROM plans WHERE agent_id=? AND status='pending' ORDER BY id DESC LIMIT 1`, agentID). + Scan(&p.ID, &p.Agent, &p.Kind, &raw, &p.Status, &p.Feedback, &p.Created) + p.JSON = json.RawMessage(raw) + return +} + +// DecidePlan records the decision on a pending draft, with the plan as the user left it when v +// isn't nil. It reports false when the draft was already decided, so only one decision wins. +func (s *Store) DecidePlan(id int64, status, feedback string, v any) bool { + q, args := `UPDATE plans SET status=?, feedback=?, decided=? WHERE id=? AND status='pending'`, []any{status, feedback, time.Now().UnixMilli(), id} + if v != nil { + b, _ := json.Marshal(v) + q, args = `UPDATE plans SET status=?, feedback=?, decided=?, json=? WHERE id=? AND status='pending'`, []any{status, feedback, time.Now().UnixMilli(), string(b), id} + } + res, err := s.db.Exec(q, args...) + if err != nil { + log.Printf("DecidePlan: %v", err) + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + // NewRun starts a run record for one attempt and returns its id. func (s *Store) NewRun(agentID string, attempt int) int64 { res, err := s.db.Exec(`INSERT INTO runs(agent_id, attempt, started) VALUES(?,?,?)`, agentID, attempt, time.Now().UnixMilli()) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index dfd59fc..cfeab89 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -142,3 +142,36 @@ func TestTypes(t *testing.T) { t.Fatal("type not deleted") } } + +// Only one decision lands on a draft, and a restart expires drafts nobody is waiting on anymore. +func TestPlanDrafts(t *testing.T) { + s, path := open(t) + id, err := s.SavePlan("m", "plan", map[string]any{"subgoals": []any{}}) + if err != nil { + t.Fatal(err) + } + if p, err := s.PendingPlan("m"); err != nil || p.ID != id || p.Kind != "plan" { + t.Fatalf("pending: %+v %v", p, err) + } + if !s.DecidePlan(id, "replanned", "smaller", nil) || s.DecidePlan(id, "approved", "", nil) { + t.Fatal("exactly the first decision should land") + } + if _, err := s.PendingPlan("m"); err == nil { + t.Fatal("a decided draft isn't pending") + } + id2, _ := s.SavePlan("m", "plan", map[string]any{}) + s.Close() + s2, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer s2.Close() + var status string + s2.SQL().QueryRow(`SELECT status FROM plans WHERE id=?`, id2).Scan(&status) + if status != "expired" { + t.Fatalf("draft after restart: %q", status) + } + if as, _ := s2.Agents(func() string { ps, _ := s2.Projects(); return ps[0].ID }()); as[0].ApprovePlan { + t.Fatal("plan approval is opt-in") + } +} diff --git a/web/app.js b/web/app.js index dde69cf..ae00acd 100644 --- a/web/app.js +++ b/web/app.js @@ -234,8 +234,9 @@ main.addEventListener("pointerdown", e => { }; }); // ask shows the styled confirm dialog and resolves true when the user presses the action button. -function ask(title, text, action) { +function ask(title, text, action, keep = "Cancel") { const d = $("#confirm-dialog"); + $("#c-cancel").textContent = keep; $("h3", d).textContent = title; $("#c-text").textContent = text; $("#c-text").hidden = !text; @@ -456,6 +457,32 @@ $("#i-dir").onclick = async e => { catch { say("Couldn't copy the path", true); } }; +// selAgent is the selected agent's settings as last saved. +let selAgent = null; + +// showApprove shows the plan-approval setting, in Settings and next to the goal, for managers only. +function showApprove(on) { + const manager = selected && kidsOf(selected).length > 0; + aform.elements.approvePlan.checked = $("#g-approve").checked = on; + $("#a-approve-row").style.display = $("#g-approve-row").style.display = manager ? "flex" : "none"; +} + +// The Goal tab's checkbox saves the setting right away; the rest of the settings stay as saved. +$("#g-approve").onchange = async e => { + const on = e.target.checked, id = selected; + try { + await api("PUT", `/api/agents/${encodeURIComponent(id)}`, { ...selAgent, approvePlan: on }); + if (id !== selected) return; + selAgent.approvePlan = on; + agents.get(id).approvePlan = on; + aform.elements.approvePlan.checked = on; + status($("#g-msg"), on ? `${nameOf(id)} will show you its plan before its team runs.` : `${nameOf(id)}'s team starts as soon as it has a plan.`); + } catch (err) { + e.target.checked = !on; + status($("#g-msg"), err.message, true); + } +}; + async function select(id) { let data; try { data = await api("GET", `/api/agents/${encodeURIComponent(id)}`); } @@ -465,6 +492,7 @@ async function select(id) { cardOf(id)?.classList.add("sel"); insp.hidden = false; const { agent: a, goal: g } = data; + selAgent = a; $("#i-name").textContent = a.name; const d = $("#i-dir"); d.textContent = data.dir ? `(${data.dir})` : ""; @@ -474,6 +502,7 @@ async function select(id) { for (const k of ["name", "runtime", "model", "args", "prompt"]) aform.elements[k].value = a[k]; aform.elements.tokenSoft.value = a.tokenSoft || ""; aform.elements.tokenHard.value = a.tokenHard || ""; + showApprove(a.approvePlan); colorChoice = a.color || ""; if (agents.has(id)) agents.get(id).color = colorChoice; $("#a-color").value = colorChoice || roleColor(id); @@ -483,6 +512,7 @@ async function select(id) { status($("#g-msg"), ""); if (id !== revisingFor) { $("#r-change").value = ""; status($("#r-msg"), ""); } // a draft belongs to its agent showGoalState(g); + renderPlan(); renderStats(); showTab(tab); } @@ -510,7 +540,7 @@ $("#a-parent").onchange = e => { function showGoalState(g) { const mgr = parentOf(selected), team = kidsOf(selected); // once an agent has run (and isn't running), changes go through Request changes, not goal edits - $("#revise").hidden = !g.title || g.status === "idle" || BUSY.includes(g.status); + $("#revise").hidden = !g.title || g.status === "idle" || g.status === "awaiting" || BUSY.includes(g.status); $("#r-hint").textContent = team.length ? `${nameOf(selected)} passes each part to the ${team.length === 1 ? "agent" : "agents"} it concerns and re-runs only them. Everyone keeps their work; ${nameOf(selected)} reviews and merges the changes and runs its checks again.` : `${nameOf(selected)} keeps its work and changes only this, then its checks run again.`; @@ -528,8 +558,24 @@ function showGoalState(g) { // limitClass says whether a token count is past the agent's soft or hard limit. const limitClass = st => st.tokenHard && st.tokens >= st.tokenHard ? "over" : st.tokenSoft && st.tokens >= st.tokenSoft ? "warn" : ""; +// WORKING says what a busy agent is doing, for the banner at the top of its Goal tab. +const WORKING = { starting: "is starting", running: "is working", verifying: "is running its checks", planning: "is planning for its team", + waiting: "is waiting for its team", reviewing: "is reviewing its team's work" }; + +function renderWorking(st) { + const box = $("#g-working"), busy = st && BUSY.includes(st.status); + box.hidden = !busy; + if (!busy) return; + box.dataset.status = st.status; + $("#g-working-dot").dataset.status = st.status; + $("#g-working-text").textContent = `${nameOf(selected)} ${WORKING[st.status] ?? st.status}` + (st.since ? ` · ${fmtDur(Date.now() - st.since)}` : ""); + $("#g-working-note").textContent = st.status === "planning" && agents.get(selected)?.approvePlan + ? "It's reading the repo and writing the plan. The plan shows up here for your approval; nothing runs until you approve it." : ""; +} + function renderStats() { const st = selected && summary.agents[selected], box = $("#i-stats"); + renderWorking(st); if (!st) { box.replaceChildren(); return; } const parts = [el("span", "", st.status)]; if (BUSY.includes(st.status) && st.since) parts.push(el("span", "", "working " + fmtDur(Date.now() - st.since))); @@ -575,11 +621,14 @@ aform.onsubmit = async e => { e.preventDefault(); const f = aform.elements; const body = { name: f.name.value, runtime: f.runtime.value, model: f.model.value, args: f.args.value, prompt: f.prompt.value, - tokenSoft: Number(f.tokenSoft.value) || 0, tokenHard: Number(f.tokenHard.value) || 0, color: colorChoice }; + tokenSoft: Number(f.tokenSoft.value) || 0, tokenHard: Number(f.tokenHard.value) || 0, color: colorChoice, + approvePlan: f.approvePlan.checked }; try { await api("PUT", `/api/agents/${selected}`, body); const a = agents.get(selected); - Object.assign(a, { name: body.name, runtime: body.runtime, color: body.color }); + Object.assign(a, { name: body.name, runtime: body.runtime, color: body.color, approvePlan: body.approvePlan }); + Object.assign(selAgent, body); + $("#g-approve").checked = body.approvePlan; paintCard(a); $("#i-name").textContent = a.name; status($("#a-msg"), dirty ? "Settings saved. The arrangement still has unsaved changes." : "Saved."); @@ -608,7 +657,9 @@ $("#i-run").onclick = async e => { await api("PUT", `/api/agents/${id}/goal`, goalBody()); // Run uses what's in the form, saved or not await api("POST", `/api/agents/${id}/run`); setStatus(id, "starting"); - runMsg(isManager ? `${nameOf(id)} is planning for its team.` : `${nameOf(id)} is working.`); + runMsg(!isManager ? `${nameOf(id)} is working.` : agents.get(id)?.approvePlan + ? `${nameOf(id)} is planning. You'll review the plan here before anyone on the team starts.` + : `${nameOf(id)} is planning for its team.`); refreshSummary(); showTab("logs"); } catch (err) { runMsg(err.message, true); } @@ -645,7 +696,7 @@ $("#i-clone").onclick = async () => { const { x, y } = parent ? slotUnder(parent) : { x: s.x + GAP_X, y: s.y }; const a = addAgent(s.role, src.name + " copy", null, x, y, parent); Object.assign(a, { runtime: src.runtime, color: src.color, - defaults: { model: src.model, args: src.args, prompt: src.prompt, tokenSoft: src.tokenSoft, tokenHard: src.tokenHard } }); + defaults: { model: src.model, args: src.args, prompt: src.prompt, tokenSoft: src.tokenSoft, tokenHard: src.tokenHard, approvePlan: src.approvePlan } }); paintCard(a); await save(); refreshCards(); @@ -654,6 +705,159 @@ $("#i-clone").onclick = async () => { }; $("#i-stop").onclick = () => api("POST", `/api/agents/${selected}/stop`).then(() => runMsg("Stopping…")).catch(e => runMsg(e.message, true)); +/* ---------- plan approval ---------- */ + +// plans: manager id -> its draft plan waiting for the user's decision +const plans = new Map(); +const baseTitle = document.title; +const planItems = d => d.plan.subgoals || d.plan.changes || []; + +// loadPlan fetches the manager's waiting plan. Only the newest request per manager counts: an older +// one can come back after it, e.g. "no plan" from just before a new plan was made. +const planLoads = new Map(); +async function loadPlan(id) { + const n = (planLoads.get(id) ?? 0) + 1; + planLoads.set(id, n); + let d = null; + try { d = await api("GET", `/api/agents/${encodeURIComponent(id)}/plan`); } catch {} + if (planLoads.get(id) !== n) return; + d ? plans.set(id, d) : plans.delete(id); + planChanged(id); +} + +function planChanged(id) { + document.title = (plans.size ? "Plan ready · " : "") + baseTitle; + refreshCards(); + if (id === selected) renderPlan(); +} + +// syncPlans fetches drafts for managers that wait on the user and drops the ones that don't anymore. +function syncPlans() { + for (const [id, s] of Object.entries(summary.agents)) if (s.status === "awaiting" && !plans.has(id)) loadPlan(id); + for (const id of [...plans.keys()]) if (summary.agents[id]?.status !== "awaiting") { plans.delete(id); planChanged(id); } +} + +// planEdit is the plan as the user is editing it: report id -> {on, f}, for one draft. +let planEdit = null; + +function renderPlan() { + const d = selected && plans.get(selected); + $("#plan-review").hidden = !d; + if (!d) { planEdit = null; return; } + if (planEdit?.draft !== d.id) { + const items = new Map(); + for (const k of kidsOf(selected)) { + const x = planItems(d).find(i => i.agent === k.id); + items.set(k.id, { on: !!x, f: x ? { ...structuredClone(x), checks: x.checks || [] } + : d.kind === "revision" ? { agent: k.id, change: "", checks: [] } : { agent: k.id, title: "", body: "", criteria: "", checks: [] } }); + } + planEdit = { draft: d.id, kind: d.kind, mgr: selected, items, open: planItems(d)[0]?.agent }; + $("#pr-feedback").value = ""; + status($("#pr-msg"), ""); + } + drawPlan(); +} + +const WEAK_CHECK = /^(true|:|exit 0|pwd|ls(\s.*)?|echo(\s.*)?)$/; +function checkWarnings(kind, checks) { + const ws = checks.filter(c => WEAK_CHECK.test(c)).map(c => `\`${c}\` can't fail, so it proves nothing.`); + if (kind === "plan" && !checks.length) ws.unshift("No checks: nothing can prove this is done."); + return ws; +} +const checkLines = v => v.split("\n").map(l => l.trim()).filter(Boolean); + +function drawPlan() { + const { kind, mgr, items } = planEdit, on = [...items.values()].filter(i => i.on).length; + const st = summary.agents[mgr]; + $("#pr-title").textContent = kind === "revision" ? `${nameOf(mgr)}'s plan for your change` : `${nameOf(mgr)}'s plan`; + $("#pr-sub").textContent = `${on} of ${items.size} reports get work` + (st?.cost ? ` · planning cost $${st.cost.toFixed(2)}` : "") + + ". Nothing runs until you approve."; + $("#pr-approve").disabled = on === 0; + $("#pr-approve").title = on ? "" : "Give at least one report work, or cancel."; + $("#pr-items").replaceChildren(...[...items].map(([id, it]) => it.on ? planCard(id, it) : leftOut(id, it))); +} + +// planCard is one report's part of the plan, editable in place. +function planCard(id, it) { + const det = el("details", "pr-item"), sum = el("summary"), f = it.f, team = kidsOf(id).length; + det.open = planEdit.open === id; + det.ontoggle = () => { if (det.open) planEdit.open = id; }; + const what = el("span", "what"); + const showWhat = () => what.textContent = (team ? `manager, ${team} reports · ` : "") + (planEdit.kind === "revision" ? f.change : f.title); + showWhat(); + const skip = el("button", "", "Skip"); + skip.type = "button"; + skip.title = "Leave this report out of this run; its current goal stays"; + skip.onclick = e => { e.preventDefault(); it.on = false; drawPlan(); }; + sum.append(el("span", "who", nameOf(id)), what, skip); + + const fields = el("div", "fields"), warn = el("div"); + const showWarn = () => warn.replaceChildren(...checkWarnings(planEdit.kind, f.checks).map(w => el("p", "warn", "⚠ " + w))); + const field = (label, key, tag = "textarea", cls = "") => { + const input = el(tag, cls); + input.value = key === "checks" ? f.checks.join("\n") : f[key] ?? ""; + input.oninput = () => { + if (key === "checks") { f.checks = checkLines(input.value); showWarn(); } else { f[key] = input.value; showWhat(); } + }; + fields.append(el("label", "", label), input); + }; + if (planEdit.kind === "revision") { + field("Change", "change"); + field("New checks (optional), one per line", "checks", "textarea", "mono"); + } else { + field("Goal", "title", "input"); + field("Details", "body"); + field("Acceptance criteria", "criteria"); + field("Checks: one shell command per line, all must exit 0", "checks", "textarea", "mono"); + } + fields.append(warn); + showWarn(); + det.append(sum, fields); + return det; +} + +// leftOut is a report the plan gives no work, with a way to bring it in. +function leftOut(id, it) { + const row = el("div", "pr-item out"), noGoal = planEdit.kind === "revision" && !summary.agents[id]?.title; + const add = el("button", "", "Add"); + add.type = "button"; + add.disabled = noGoal; + add.onclick = () => { it.on = true; planEdit.open = id; drawPlan(); $("#pr-items details[open] input, #pr-items details[open] textarea")?.focus(); }; + row.append(el("span", "who", nameOf(id)), el("span", "what", noGoal ? "no goal yet, so no work to change" : planEdit.kind === "revision" ? "no change" : "not in this plan"), add); + return row; +} + +async function decidePlan(btn, body, working, done) { + const mgr = planEdit.mgr, draft = planEdit.draft; + btn.disabled = true; + status($("#pr-msg"), working); + try { + await api("POST", `/api/agents/${encodeURIComponent(mgr)}/plan`, { draft, ...body }); + if (plans.get(mgr)?.id === draft) { plans.delete(mgr); planChanged(mgr); } // a new plan may already be here + planLoads.set(mgr, (planLoads.get(mgr) ?? 0) + 1); // and a request still in flight must not bring the old one back + runMsg(done); + refreshSummary(); + } catch (err) { + status($("#pr-msg"), err.message, true); + loadPlan(mgr); // it may have been decided elsewhere, or replaced; edits to the same draft are kept + } + btn.disabled = false; +} + +$("#pr-approve").onclick = e => { + const on = [...planEdit.items.values()].filter(i => i.on).map(i => i.f); + const body = planEdit.kind === "revision" ? { action: "approve", changes: on } : { action: "approve", subgoals: on }; + decidePlan(e.currentTarget, body, "Approving…", `${nameOf(planEdit.mgr)} is running its team.`); +}; +$("#pr-replan").onclick = e => { + decidePlan(e.currentTarget, { action: "replan", feedback: $("#pr-feedback").value.trim() }, "Asking for a new plan…", `${nameOf(planEdit.mgr)} is planning again.`); +}; +$("#pr-cancel").onclick = async e => { + const btn = e.currentTarget; + if (!await ask(`Cancel ${nameOf(planEdit.mgr)}'s plan?`, "The run stops and no report's goal changes.", "Cancel plan", "Keep it")) return; + decidePlan(btn, { action: "cancel" }, "Cancelling…", `${nameOf(planEdit.mgr)}'s plan was cancelled.`); +}; + /* ---------- logs ---------- */ const log = $("#log"), logPane = $('[data-pane="logs"]'); @@ -885,6 +1089,10 @@ function refreshCards() { const busy = BUSY.includes(card.dataset.status); card.classList.toggle("busy", busy); card.querySelector(".now").textContent = st?.now || (st?.title ? "goal: " + st.title : "no goal yet"); + const draft = plans.get(parentOf(a.id)), item = draft && planItems(draft).find(x => x.agent === a.id); + card.classList.toggle("proposed", !!item); + card.classList.toggle("left-out", !!draft && !item); + if (item) card.querySelector(".now").textContent = draft.kind === "revision" ? "proposed change: " + item.change : "proposed: " + item.title; const b = card.querySelector(".badges"); b.replaceChildren(); if (!st) continue; @@ -911,21 +1119,19 @@ async function refreshSummary() { refreshCards(); renderStats(); const all = Object.entries(summary.agents); - const goals = all.filter(([, s]) => s.title); - const done = goals.filter(([, s]) => s.status === "done").length; - const needs = all.filter(([, s]) => NEEDS.includes(s.status)); + const needs = all.filter(([, s]) => NEEDS.includes(s.status) || s.status === "awaiting"); + syncPlans(); const running = all.filter(([, s]) => BUSY.includes(s.status)).length; - $("#n-done").textContent = done; - $("#n-goals").textContent = goals.length; $("#n-running").textContent = running; - $("#bar .live").hidden = running === 0; - $("#bar .prog i").style.width = goals.length ? (100 * done / goals.length) + "%" : 0; + $("#running").hidden = running === 0; $("#cost").textContent = summary.cost.toFixed(2); $("#n-needs").textContent = needs.length; $("#needs").dataset.n = needs.length; + $("#needs").dataset.kind = needs.every(([, s]) => s.status === "awaiting") ? "plan" : "fail"; // amber for plans, red for failures + if (!needs.length) $("#needs").open = false; $("#needs ul").replaceChildren(...needs.map(([id, s]) => { - const li = el("li", "", `✗ ${nameOf(id)}: ${s.status} · ${s.title}`); - li.onclick = () => { $("#needs").open = false; select(id); }; + const li = el("li", "", s.status === "awaiting" ? `⏸ ${nameOf(id)}: plan waiting for your approval` : `✗ ${nameOf(id)}: ${s.status} · ${s.title}`); + li.onclick = () => { $("#needs").open = false; if (s.status === "awaiting") tab = "goal"; select(id); }; return li; })); if (selected && summary.agents[selected]) { @@ -979,6 +1185,8 @@ es.onmessage = m => { } if (e.agent === selected && tab === "logs") appendLive(e); if (e.agent === selected && e.kind !== "stderr") scheduleLiveDiff(); // it edited files, ran a tool, checkpointed... + } else if (msg.type === "plan") { + loadPlan(msg.agent); } else if (msg.type === "status" || msg.type === "usage") { if (msg.type === "status") setStatus(msg.agent, msg.status); scheduleSummary(); diff --git a/web/arrange.html b/web/arrange.html index 84ec019..d0ca575 100644 --- a/web/arrange.html +++ b/web/arrange.html @@ -12,7 +12,7 @@ --bg: #f4f5f8; --grid: #0b0d140b; --panel: #fff; --sunk: #f0f1f3; --line: #dcdde1; --text: #1b1c1f; --muted: #6b7079; --accent: #3d6fe0; --on-accent: #fff; --drop: #3d6fe01f; --manager: #8a5cf6; --coder: #3d6fe0; --reviewer: #c98a12; --tester: #2f9e44; - --idle: #a0a4ab; --running: #3d6fe0; --verifying: #c98a12; --done: #2f9e44; --failed: #d64545; --stopped: #6b7079; + --idle: #a0a4ab; --running: #3d6fe0; --verifying: #c98a12; --done: #2f9e44; --failed: #d64545; --stopped: #6b7079; --awaiting: #d9730d; --add-bg: #e6f4ea; --del-bg: #fdecec; --link: #8b919c; font: 14px/1.4 var(--sans); } @@ -20,7 +20,7 @@ --bg: #0e0f12; --grid: #c0caf509; --panel: #16181d; --sunk: #111317; --line: #2a2d35; --text: #e6e6e6; --muted: #8a8f98; --accent: #7aa2f7; --on-accent: #0e0f12; --drop: #7aa2f733; --manager: #bb9af7; --coder: #7aa2f7; --reviewer: #e0af68; --tester: #9ece6a; - --idle: #5c616b; --running: #7aa2f7; --verifying: #e0af68; --done: #9ece6a; --failed: #f7768e; --stopped: #8a8f98; + --idle: #5c616b; --running: #7aa2f7; --verifying: #e0af68; --done: #9ece6a; --failed: #f7768e; --stopped: #8a8f98; --awaiting: #ff9e64; --add-bg: #1f2d22; --del-bg: #331f24; --link: #6b7280; } * { box-sizing: border-box; border-radius: 0; } @@ -40,20 +40,33 @@ .mono, textarea.mono { font-family: var(--mono); font-size: 12px; } label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; } -header { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--panel); flex-wrap: wrap; } +header { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; padding: 7px 14px; border-bottom: 1px solid var(--line); background: var(--panel); flex-wrap: wrap; } +header button.icon { width: 30px; padding: 5px 0; text-align: center; color: var(--muted); } +header button.icon:hover { color: var(--text); } +header .proj { display: flex; } +header .proj > * + * { margin-left: -1px; } +header .proj select { max-width: 200px; } header h1 { font: 600 14px var(--mono); margin: 0 8px 0 0; } header h1 a { color: inherit; text-decoration: none; display: flex; align-items: center; gap: 8px; } header h1 img { width: 22px; height: 22px; display: block; } header select { width: auto; } .sp { flex: 1; } -#bar { display: flex; align-items: center; gap: 14px; font-size: 12px; color: var(--muted); } -#bar .prog { width: 120px; height: 6px; background: var(--sunk); border: 1px solid var(--line); } -#bar .prog i { display: block; height: 100%; background: var(--done); width: 0; } +#bar { display: flex; align-items: stretch; height: 30px; margin-left: 8px; border: 1px solid var(--line); background: var(--sunk); font-size: 12px; color: var(--muted); } +#bar > * { display: flex; align-items: center; gap: 6px; padding: 0 12px; white-space: nowrap; } +#bar > * + * { border-left: 1px solid var(--line); } +#bar > [hidden] { display: none; } +#bar > button { border: 0; border-left: 1px solid var(--line); background: none; font-size: 12px; color: var(--muted); } +#bar > button:hover { background: var(--panel); color: var(--text); } #bar b { color: var(--text); font-weight: 600; } #needs { position: relative; } -#needs summary { cursor: pointer; list-style: none; color: var(--failed); font-weight: 600; } -#needs[data-n="0"] summary { color: var(--muted); font-weight: 400; } -#needs ul { position: absolute; top: 22px; left: 0; z-index: 5; background: var(--panel); border: 1px solid var(--line); margin: 0; padding: 4px 0; list-style: none; min-width: 220px; } +#needs summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px; height: 100%; color: var(--s); font-weight: 600; } +#needs summary::-webkit-details-marker { display: none; } +#needs summary b { color: inherit; } +#needs[data-n="0"] { display: none; } +#needs, #needs[data-kind=fail] { --s: var(--failed); } #needs[data-kind=plan] { --s: var(--awaiting); } +#needs .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--s); box-shadow: 0 0 0 0 var(--s); animation: beacon 1.4s ease-out infinite; } +@keyframes beacon { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--s) 70%, transparent); } 100% { box-shadow: 0 0 0 7px transparent; } } +#needs ul { position: absolute; top: 29px; left: 0; z-index: 5; background: var(--panel); border: 1px solid var(--line); margin: 0; padding: 4px 0; list-style: none; min-width: 220px; } #needs li { padding: 6px 12px; cursor: pointer; color: var(--text); } #needs li:hover { background: var(--sunk); } #msg { font-size: 12px; color: var(--muted); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -103,12 +116,11 @@ .card.busy .now { color: var(--text); } .card .elapsed { color: var(--s); font-weight: 600; } @keyframes sweep { from { background-position: -60% 0; } to { background-position: 160% 0; } } -#bar .live { display: inline-block; width: 8px; height: 8px; background: var(--running); margin-right: 4px; vertical-align: middle; animation: pulse 1s infinite alternate; } -#bar .live[hidden] { display: none; } +#bar .live { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--running); animation: pulse 1s infinite alternate; } @media (prefers-reduced-motion: reduce) { #links path.flow { animation: none; } .card.busy::after { animation: none; background: var(--s); } - .st::before, #bar .live { animation: none !important; } + .st::before, #bar .live, #needs .dot { animation: none !important; } } .st::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 5px; background: var(--s, var(--idle)); } .busy .st::before, .st.busy::before { animation: pulse 1s infinite alternate; } @@ -116,6 +128,11 @@ [data-status=starting], [data-status=running] { --s: var(--running); } [data-status=verifying] { --s: var(--verifying); } [data-status=done] { --s: var(--done); } [data-status=failed], [data-status=blocked] { --s: var(--failed); } [data-status=stopped] { --s: var(--stopped); } [data-status=planning], [data-status=waiting], [data-status=reviewing] { --s: var(--manager); } +[data-status=awaiting] { --s: var(--awaiting); } +.card[data-status=awaiting] { border-color: var(--s); border-left-color: var(--c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--s) 22%, transparent); } +.card[data-status=awaiting] .st::before, .st[data-status=awaiting]::before { animation: pulse 1s infinite alternate; } +.card.proposed .now { font-style: italic; opacity: .75; } +.card.left-out { opacity: .45; } .ok { color: var(--done); } .bad { color: var(--failed); } [data-role=manager] { --c: var(--manager); } [data-role=coder] { --c: var(--coder); } [data-role=reviewer] { --c: var(--reviewer); } [data-role=tester] { --c: var(--tester); } @@ -190,6 +207,27 @@ #log .ev[data-kind=plan] .k, #log .ev[data-kind=verdict] .k { color: var(--manager); } #log .ev[data-kind=request] .k, #log .ev[data-kind=request] .x { color: var(--accent); font-weight: 600; } /* the way to iterate once an agent has run: say what should change, re-run only what it concerns */ +#g-working { display: flex; align-items: center; gap: 2px; margin-top: 14px; padding: 8px 12px; border: 1px solid var(--s); border-left-width: 3px; + background: color-mix(in srgb, var(--s) 8%, var(--panel)); font-weight: 600; } +#g-working[hidden] { display: none; } +label.check { display: flex; gap: 6px; align-items: center; font-weight: 400; color: var(--text); } +label.check input { width: auto; margin: 0; } +#g-approve-row { margin-top: 10px; } +#g-working-note { display: block; font-weight: 400; font-size: 12px; color: var(--muted); } +#g-working-note:empty { display: none; } +#g-working .st::before { width: 9px; height: 9px; margin-right: 8px; border-radius: 50%; } +#plan-review { margin-top: 14px; padding: 4px 12px 12px; background: var(--sunk); border: 1px solid var(--line); border-left: 3px solid var(--awaiting); } +#plan-review h3 { margin: 8px 0 2px; font-size: 14px; } +#plan-review .pr-item { background: var(--panel); border: 1px solid var(--line); margin-top: 8px; } +#plan-review .pr-item > summary { display: flex; gap: 8px; align-items: center; padding: 6px 10px; cursor: pointer; } +#plan-review .pr-item > summary .who { font-weight: 600; white-space: nowrap; } +#plan-review .pr-item > summary .what { flex: 1; min-width: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#plan-review .pr-item .fields { padding: 0 10px 10px; } +#plan-review .pr-item.out { display: flex; gap: 8px; align-items: center; padding: 6px 10px; color: var(--muted); } +#plan-review .pr-item.out .who { font-weight: 600; color: var(--text); } +#plan-review .pr-item.out .what { flex: 1; } +#plan-review .warn { color: var(--awaiting); font-size: 12px; margin: 6px 0 0; } +#plan-review .row { flex-wrap: wrap; } #revise { margin-top: 14px; padding: 4px 12px 10px; background: var(--sunk); border: 1px solid var(--line); border-left: 3px solid var(--accent); } #revise label { color: var(--text); font-weight: 600; font-size: 13px; } #revise .hint { margin-top: 6px; } @@ -224,22 +262,21 @@

arranger

- - - +
+ + + +
- - 0/0 done - 0 running -
Needs you (0)
    - $0.00 + +
    Needs you (0)
      +
      - - +
      @@ -283,6 +320,21 @@

      Agent types

      + +

      - + +
      -

      -

      +
      +

      +

      -
        @@ -570,7 +568,7 @@

        Merge 's work

        - +

        From 50631c61575fe2a0925b5a60534c7ca2d8a3a5d3 Mon Sep 17 00:00:00 2001 From: Mano Sriram Date: Sat, 26 Sep 2026 10:54:08 +0530 Subject: [PATCH 6/8] Styled Reports to dropdown, remove connections from the canvas, Backspace to remove agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Reports to" in Settings uses the app's dropdown instead of the browser's - Hover a connection to see an × at its middle; clicking it makes the agent below report to nobody - Select a box and press Backspace or Delete to remove it, after the same confirmation as its ×; ignored while typing or when a dialog is open - Sidebar help lines for removing connections and agents --- web/app.js | 79 +++++++++++++++++++++++++++++++++++++++++++++--- web/arrange.html | 16 ++++++++++ 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/web/app.js b/web/app.js index 92c3a8f..5f9b832 100644 --- a/web/app.js +++ b/web/app.js @@ -124,7 +124,20 @@ function drawLinks() { path.dataset.status = flow.status; path.classList.add("flow", flow.dir); } - paths.push(path); + // hovering the line shows an x at its middle; clicking it removes the connection + const g = document.createElementNS(SVG, "g"), hit = path.cloneNode(), cut = document.createElementNS(SVG, "g"); + g.setAttribute("class", "link"); + hit.setAttribute("class", "hit"); + cut.setAttribute("class", "cut"); + cut.setAttribute("transform", `translate(${(x1 + x2) / 2},${(y1 + y2) / 2})`); + const ring = document.createElementNS(SVG, "circle"), x = document.createElementNS(SVG, "path"), tip = document.createElementNS(SVG, "title"); + ring.setAttribute("r", 9); + x.setAttribute("d", "M-3.5,-3.5 L3.5,3.5 M3.5,-3.5 L-3.5,3.5"); + tip.textContent = `Remove: ${a.name} stops reporting to ${p.name}`; + cut.append(tip, ring, x); + cut.addEventListener("click", () => unlink(a.id)); + g.append(hit, path, cut); + paths.push(g); } linksSvg.replaceChildren(...paths); // room to spread out: the canvas always reaches well past the last box and the viewport @@ -185,6 +198,18 @@ main.addEventListener("drop", e => { refreshCards(); }); +// unlink removes the connection above an agent: it no longer reports to anyone. +function unlink(id) { + const a = agents.get(id), was = parentOf(id); + if (!a || !was) return; + a.parent = ""; + paintCard(a); + drawLinks(); + say(`${a.name} no longer reports to ${nameOf(was)} · unsaved`); + dirty = true; + if (selected === id) fillParents(id); +} + // linkFrom draws a connection from one of a box's dots, like draw.io. From the bottom dot, the agent // it's released over reports to this one; from a side dot, whichever box is higher on the canvas // manages (level: the one dropped on reports). @@ -272,7 +297,7 @@ canvas.addEventListener("pointerdown", e => { }); // empty space: drag to pan, like draw.io main.addEventListener("pointerdown", e => { - if (e.button !== 0 || e.target.closest(".card, button")) return; + if (e.button !== 0 || e.target.closest(".card, button, #links .link")) return; const sx = e.clientX + main.scrollLeft, sy = e.clientY + main.scrollTop; try { main.setPointerCapture(e.pointerId); } catch {} main.classList.add("panning"); @@ -299,9 +324,10 @@ function ask(title, text, action, keep = "Cancel", danger = true) { return new Promise(r => d.addEventListener("close", () => r(d.returnValue === "ok"), { once: true })); } -canvas.addEventListener("click", async e => { - if (!e.target.classList.contains("x")) return; - const id = e.target.closest(".card").dataset.id, a = agents.get(id); +// removeAgent takes an agent off the canvas after the user confirms; its reports move up a level. +async function removeAgent(id) { + const a = agents.get(id); + if (!a) return; if (BUSY.includes(a.el.dataset.status)) { say(`${a.name} is running; stop it before removing it`, true); return; } const n = kidsOf(id).length, st = summary.agents[id]; const notes = []; @@ -315,6 +341,17 @@ canvas.addEventListener("click", async e => { agents.delete(id); markDirty(); drawLinks(); +} +canvas.addEventListener("click", e => { + if (e.target.classList.contains("x")) removeAgent(e.target.closest(".card").dataset.id); +}); +// Backspace or Delete removes the selected agent, unless the user is typing or a dialog is open +document.addEventListener("keydown", e => { + if (e.key !== "Backspace" && e.key !== "Delete" || !selected || e.metaKey || e.ctrlKey || e.altKey) return; + const t = e.target; + if (t.closest?.("input, textarea, select, [contenteditable], dialog") || document.querySelector("dialog[open]")) return; + e.preventDefault(); + removeAgent(selected); }); // askText is ask with a text field; it resolves to the text, or null when cancelled. async function askText(title, value, action) { @@ -528,6 +565,38 @@ function fillParents(id) { sel.replaceChildren(new Option("nobody (top level)", "")); for (const a of agents.values()) if (a.id !== id && !isUnder(a.id, id)) sel.append(new Option(a.name, a.id)); sel.value = parentOf(id) ?? ""; + drawPick(sel); +} + +// drawPick shows a native select as the app's own dropdown: the select stays (hidden) as the value, +// and choosing an option sets it and fires its change event, so existing handlers keep working. +function drawPick(sel) { + let menu = sel.nextElementSibling; + if (!menu?.classList.contains("pick")) { + menu = el("details", "menu pick"); + menu.append(el("summary"), el("ul")); + sel.after(menu); + sel.hidden = true; + } + const cur = sel.selectedOptions[0]; + const sum = menu.querySelector("summary"); + sum.replaceChildren(el("span", "", cur?.text ?? ""), (() => { + const s = document.createElementNS(SVG, "svg"), p = document.createElementNS(SVG, "path"); + s.setAttribute("viewBox", "0 0 24 24"); p.setAttribute("d", "M6 9l6 6 6-6"); s.append(p); return s; + })()); + menu.querySelector("ul").replaceChildren(...[...sel.options].map(o => { + const li = el("li"), b = el("button", o.selected ? "on" : "", o.text); + b.type = "button"; + b.onclick = () => { + menu.open = false; + if (sel.value === o.value) return; + sel.value = o.value; + drawPick(sel); + sel.dispatchEvent(new Event("change")); + }; + li.append(b); + return li; + })); } $("#i-dir").onclick = async e => { diff --git a/web/arrange.html b/web/arrange.html index 746ed69..bc1ad13 100644 --- a/web/arrange.html +++ b/web/arrange.html @@ -62,6 +62,14 @@ .menu li a.on { font-weight: 600; } .menu li a.on::before { content: "✓"; color: var(--accent); } .menu li a:hover, .menu li a:focus-visible { background: var(--sunk); outline: none; } +.menu.pick > summary { justify-content: space-between; font-weight: 400; padding: 6px 10px; } +.menu.pick > summary span { max-width: none; } +.menu.pick ul { right: 0; min-width: 0; } +.menu.pick li button { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; border: 0; background: none; padding: 7px 12px; } +.menu.pick li button::before { content: ""; width: 14px; flex: none; } +.menu.pick li button.on { font-weight: 600; } +.menu.pick li button.on::before { content: "✓"; color: var(--accent); } +.menu.pick li button:hover { background: var(--sunk); } header h1 { font: 600 14px var(--mono); margin: 0 8px 0 0; } header h1 a { color: inherit; text-decoration: none; display: flex; align-items: center; gap: 8px; } header h1 img { width: 22px; height: 22px; display: block; } @@ -105,6 +113,12 @@ #links { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; } #links path { fill: none; stroke: var(--link); stroke-width: 2.5; stroke-dasharray: 2 7; stroke-linecap: round; } /* live links: up toward the manager while a report works for it, down while the manager hands out work */ +#links .link .hit { stroke: transparent; stroke-width: 16; stroke-dasharray: none; pointer-events: stroke; cursor: pointer; animation: none; } +#links .link .cut { opacity: 0; pointer-events: all; cursor: pointer; transition: opacity .12s; } +#links .link:hover .cut, #links .link .cut:focus { opacity: 1; } +#links .link:hover > path:not(.hit) { stroke: var(--failed); } +#links .cut circle { fill: var(--panel); stroke: var(--failed); stroke-width: 1.5; } +#links .cut path { stroke: var(--failed); stroke-width: 2; stroke-dasharray: none; fill: none; animation: none; } #links path.draft { stroke: var(--accent); stroke-width: 2; stroke-dasharray: 6 5; animation: none; } #canvas .card .port { position: absolute; left: 50%; width: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%; z-index: 3; background: var(--panel); border: 2px solid var(--c, var(--accent)); opacity: 0; cursor: crosshair; transition: opacity .12s, transform .12s; } @@ -413,7 +427,9 @@

        Agent types

      1. Drop it on an agent to put it under that agent
      2. Drop a box on another to change its manager
      3. Drag a box's dots to another agent to connect them
      4. +
      5. Hover a connection and click its × to remove it
      6. Double-click a box to rename it
      7. +
      8. Select a box and press Backspace to remove it
      9. {{if not .Project.Repo}}

        This project has no git repository yet. Set one in Settings to run agents.

        {{end}} From 50626c0619e26d71670bb3dda16e6d702b0ba20d Mon Sep 17 00:00:00 2001 From: Mano Sriram Date: Sat, 26 Sep 2026 11:33:12 +0530 Subject: [PATCH 7/8] Continue blocked runs, Teams & Tools, multi-select on the canvas, tab state - Continue on a blocked or failed manager: reports that finished keep their work, only the others run again, then all are reviewed, merged and checked with no new plan - Teams & Tools: start from a ready team (Feature team, Bug fix, Docs), add it or replace the canvas with it, or save the canvas as a template - Teams & Tools lists agent CLIs on this machine with versions, and install and sign-in commands to copy - Suggest checks from the repo's build files (Go, npm/pnpm/yarn/bun scripts, Cargo, Python, Makefile), as one-click chips under every Checks box - Empty canvas invites starting from a ready team - Drag on empty space draws a selection box; Shift adds; Cmd/Ctrl+A selects all; Esc clears - Backspace removes every selected agent after one confirmation; reports move up to the nearest manager that stays - Pan with Space+drag, the middle mouse button, or scroll - Stop is enabled only while the agent is running or waiting for plan approval; disabled buttons look faded - The browser tab's title and icon show the state: needs you, working or done - Add GET /api/setup, template storage and endpoints, POST /api/agents/{id}/continue, and tests --- README.md | 6 +- internal/orch/manager.go | 194 ++++++++++++------ internal/orch/orch.go | 38 +++- internal/orch/orch_test.go | 54 +++++ internal/orch/queue.go | 2 +- internal/server/handlers.go | 9 + internal/server/server.go | 4 + internal/server/server_test.go | 42 ++++ internal/server/setup.go | 150 ++++++++++++++ internal/store/queue.go | 5 + internal/store/store.go | 12 ++ internal/store/templates.go | 106 ++++++++++ web/app.js | 364 ++++++++++++++++++++++++++++++--- web/arrange.html | 61 +++++- 14 files changed, 950 insertions(+), 97 deletions(-) create mode 100644 internal/server/setup.go create mode 100644 internal/store/templates.go diff --git a/README.md b/README.md index 263883f..438bfb3 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ You also need `git`, and at least one agent CLI on your `PATH` (for example `cla 2. **Point it at a repo.** Click **Settings** in the header and enter the path to a git repository. Leave the base branch blank to use the current branch. The repo needs at least one commit. -3. **Arrange the team.** Drag agent types from the left onto the canvas. Drop one agent onto another to put it under that agent. Click a box to pick its tool (runtime), model, color and instructions in the **Settings** tab. Click **Save**. +3. **Arrange the team.** Press **Teams & Tools** in the sidebar to drop in a ready team (Feature team, Bug fix, Docs) and check which agent tools are installed, or drag agent types from the left onto the canvas. Drop one agent onto another to put it under that agent. Click a box to pick its tool (runtime), model, color and instructions in the **Settings** tab. Click **Save**. 4. **Add goals.** Select the top agent and press **+ Add goal** in its **Goal** tab. Each goal has: - **Goal:** the outcome, e.g. "Add rate limiting to /login". @@ -58,7 +58,9 @@ You also need `git`, and at least one agent CLI on your `PATH` (for example `cla Want to check the plan first? Tick **Let me approve the plan before the team runs** under the manager's goals. The manager then stops after planning and shows you each report's goal and checks: edit them, skip or add reports, send the plan back with feedback, or approve it to start the team. -6. **Iterate.** Not quite right? Select the agent and write what should change in **Request changes**. Ask the top manager and it passes each part to the agents it concerns and re-runs only them; everyone keeps their work, and the changes are reviewed, merged and checked like any run. (To redo a finished goal from scratch, use **Add again** in its Finished list and press **Run**.) +6. **Iterate.** Not quite right? Select the agent and write what should change in **Request changes**. Ask the top manager and it passes each part to the agents it concerns and re-runs only them; everyone keeps their work, and the changes are reviewed, merged and checked like any run. (To redo a finished goal from scratch, use **Add again** on it and press **Run**.) + + Blocked because one report failed? Fix what it needed, then press **Continue** on the manager: the reports that finished keep their work, only the others run again, and then everything is reviewed, merged and checked together, with no new plan. 7. **Review and merge.** Open the **Diff** tab to see every change, remove or promote single hunks, or revert a checkpoint. When you're happy, press **Merge…** to merge the agent's branch into `main` (or any branch, created if needed) as a merge commit, a squash, or a fast-forward. diff --git a/internal/orch/manager.go b/internal/orch/manager.go index 8121959..98b2604 100644 --- a/internal/orch/manager.go +++ b/internal/orch/manager.go @@ -53,72 +53,83 @@ func runManager(j *job, g store.Goal, dir string, kids []store.Agent, feedback s // user approves, edits, sends back or cancels it before anything is saved or run var team []store.Agent changes := map[string]string{} // report id -> its part of the requested change - revising := j.change != "" && planned - planFeedback := feedback - for { - j.newRun(1) - var sgs []subgoal - var cs []revision + resumeFix := false // continuing a fix round + if j.cont { + // continuing a blocked run: the same team, work and changes as its last plan; no new plan var err error - if revising { - cs, err = j.proposeRevision(g, dir, kids, byID, goals, planFeedback) - } else { - sgs, err = j.proposePlan(g, dir, kids, byID, planFeedback) - } - if j.ctx.Err() != nil { - return j.fail("stopped", "stopped by user") - } - if errors.Is(err, errTokenLimit) { - return j.fail("failed", j.limitMsg()) - } - if err != nil { - return j.fail("failed", "no valid plan: "+err.Error()) - } - if !j.a.ApprovePlan { + if team, resumeFix, err = j.lastTeam(byID, changes); err != nil { + return j.fail("failed", err.Error()) + } + j.emit("plan", "continuing with the same plan: "+names(team), "") + } else { + revising := j.change != "" && planned + planFeedback := feedback + for { + j.newRun(1) + var sgs []subgoal + var cs []revision + var err error + if revising { + cs, err = j.proposeRevision(g, dir, kids, byID, goals, planFeedback) + } else { + sgs, err = j.proposePlan(g, dir, kids, byID, planFeedback) + } + if j.ctx.Err() != nil { + return j.fail("stopped", "stopped by user") + } + if errors.Is(err, errTokenLimit) { + return j.fail("failed", j.limitMsg()) + } + if err != nil { + return j.fail("failed", "no valid plan: "+err.Error()) + } + if !j.a.ApprovePlan { + team = j.apply(sgs, cs, byID, goals, changes) + break + } + kind, proposed := "plan", any(planDraft{Subgoals: sgs}) + if revising { + kind, proposed = "revision", revisionDraft{Changes: cs} + } + d, ended := j.await(kind, proposed) + if ended != "" { + return ended + } + if d.Action == "cancel" { + return j.fail("stopped", "you cancelled the plan") + } + if d.Action == "replan" { + b, _ := json.Marshal(proposed) + note := "The user sent back your plan without running it" + if d.Feedback != "" { + note += " and said: " + d.Feedback + } + j.emit("request", "you asked for a new plan"+strings.TrimPrefix(note, "The user sent back your plan without running it"), "") + planFeedback = strings.TrimSpace(feedback + "\n\n" + note + ".\nThe plan they sent back: " + string(b)) + j.status("planning", nil) + continue + } + edited := false + if revising { + edited = !sameJSON(cs, d.Changes) + sgs, cs = nil, d.Changes + } else { + edited = !sameJSON(sgs, d.Subgoals) + sgs, cs = d.Subgoals, nil + } + if edited { + j.emit("request", "you approved the plan with edits", "") + } else { + j.emit("request", "you approved the plan", "") + } team = j.apply(sgs, cs, byID, goals, changes) break } - kind, proposed := "plan", any(planDraft{Subgoals: sgs}) - if revising { - kind, proposed = "revision", revisionDraft{Changes: cs} - } - d, ended := j.await(kind, proposed) - if ended != "" { - return ended - } - if d.Action == "cancel" { - return j.fail("stopped", "you cancelled the plan") - } - if d.Action == "replan" { - b, _ := json.Marshal(proposed) - note := "The user sent back your plan without running it" - if d.Feedback != "" { - note += " and said: " + d.Feedback - } - j.emit("request", "you asked for a new plan"+strings.TrimPrefix(note, "The user sent back your plan without running it"), "") - planFeedback = strings.TrimSpace(feedback + "\n\n" + note + ".\nThe plan they sent back: " + string(b)) - j.status("planning", nil) - continue - } - edited := false - if revising { - edited = !sameJSON(cs, d.Changes) - sgs, cs = nil, d.Changes - } else { - edited = !sameJSON(sgs, d.Subgoals) - sgs, cs = d.Subgoals, nil - } - if edited { - j.emit("request", "you approved the plan with edits", "") - } else { - j.emit("request", "you approved the plan", "") - } - team = j.apply(sgs, cs, byID, goals, changes) - break } - // 2. run children, review, merge; rejected children re-run with the review feedback - if s := j.runTeam(g, dir, team, byID, changes, false); s != "" { + // 2. run children, review, merge; rejected children re-run with the review feedback. A continued + // run keeps the work of the reports that already finished and runs only the others. + if s := j.runTeam(g, dir, team, byID, changes, resumeFix, j.cont); s != "" { return s } @@ -157,7 +168,7 @@ func runManager(j *job, g store.Goal, dir string, kids []store.Agent, feedback s } fixes := map[string]string{} team := j.applyRevision("fix", cs, byID, goals, fixes) - if s := j.runTeam(g, dir, team, byID, fixes, true); s != "" { + if s := j.runTeam(g, dir, team, byID, fixes, true, false); s != "" { return s } } @@ -167,12 +178,29 @@ func runManager(j *job, g store.Goal, dir string, kids []store.Agent, feedback s // rejected reports re-run with the review's feedback. changes holds each report's part of a // requested change (fix: of a fix for the manager's failing checks). It returns "" once all the // work is merged, else the manager's final status. -func (j *job) runTeam(g store.Goal, dir string, team []store.Agent, byID map[string]store.Agent, changes map[string]string, fix bool) string { +// +// With keepDone, reports whose goal is already done aren't run again in the first round; they go +// straight to review with the others. +func (j *job) runTeam(g store.Goal, dir string, team []store.Agent, byID map[string]store.Agent, changes map[string]string, fix, keepDone bool) string { st := j.o.Store fb := map[string]string{} for round := 1; len(team) > 0; round++ { j.status("waiting", nil) - results := runChildren(j, team, fb, changes, fix) + run, results := team, map[string]string{} + if keepDone && round == 1 { + run = nil + for _, k := range team { + if kg, _ := st.Goal(k.ID); kg.Status == "done" { + results[k.ID] = "done" + j.emit("msg", k.Name+" already finished; keeping its work", "") + } else { + run = append(run, k) + } + } + } + for id, s := range runChildren(j, run, fb, changes, fix) { + results[id] = s + } if j.ctx.Err() != nil { return j.fail("stopped", "stopped by user") } @@ -274,6 +302,46 @@ func (j *job) proposeRevision(g store.Goal, dir string, kids []store.Agent, byID return rev.Changes, err } +// lastTeam rebuilds the team of the manager's last plan, routed change or fix round, with each +// report's part of the change, for continuing a blocked run. +func (j *job) lastTeam(byID map[string]store.Agent, changes map[string]string) (team []store.Agent, fix bool, err error) { + kind, raw := j.o.Store.LastDecision(j.a.ID, "plan", "revision", "fix") + if kind == "" { + return nil, false, errors.New("there's no plan to continue; press Run to plan") + } + var d struct { + Subgoals []subgoal + Changes []revision + } + if err := json.Unmarshal([]byte(raw), &d); err != nil { + return nil, false, err + } + add := func(id string) { + if k, ok := byID[id]; ok { + team = append(team, k) + } + } + for _, s := range d.Subgoals { + add(s.Agent) + } + for _, c := range d.Changes { + add(c.Agent) + changes[c.Agent] = c.Change + } + if len(team) == 0 { + return nil, false, errors.New("the last plan's reports aren't on the team anymore; press Run to plan again") + } + return team, kind == "fix", nil +} + +func names(as []store.Agent) string { + ns := make([]string, len(as)) + for i, a := range as { + ns[i] = a.Name + } + return strings.Join(ns, ", ") +} + // proposeFix asks the manager which reports must fix what, now that their merged work fails its // checks. The answer has the same shape as a routed change. func (j *job) proposeFix(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, goals map[string]store.Goal, diff --git a/internal/orch/orch.go b/internal/orch/orch.go index e914e79..f0a65e8 100644 --- a/internal/orch/orch.go +++ b/internal/orch/orch.go @@ -212,7 +212,34 @@ func checkGoal(a store.Agent, g store.Goal) error { } // Start validates a run and starts it in the background. A manager runs its whole subtree. -func (o *Orchestrator) Start(agentID string) error { _, err := o.start(agentID, ""); return err } +func (o *Orchestrator) Start(agentID string) error { _, err := o.start(agentID, "", false); return err } + +// Continue picks a blocked manager's run back up where it stopped: the reports that finished keep +// their work, the others run again, and then everything is reviewed, merged and checked together, +// with no new plan. The goal it was on, if from its list, is marked running again. +func (o *Orchestrator) Continue(agentID string) error { + kids, err := o.Store.Children(agentID) + if err != nil { + return err + } + if len(kids) == 0 { + return errors.New("only a manager can continue; run this agent instead") + } + g, _ := o.Store.Goal(agentID) + if g.Status != "blocked" && g.Status != "failed" { + return fmt.Errorf("there's nothing to continue: it's %s", g.Status) + } + last, lastErr := o.Store.LastQueueItem(agentID) + session, err := o.start(agentID, "", true) + if err != nil { + return err + } + if lastErr == nil && last.Title == g.Title && (last.Status == "blocked" || last.Status == "failed") { + o.Store.StartQueueItem(last.ID, session) + o.queueChanged(agentID) + } + return nil +} // Revise re-runs an agent that already worked, to make the change the user describes. A worker // keeps its work and makes the change; a manager passes each part of it to the reports it @@ -221,12 +248,12 @@ func (o *Orchestrator) Revise(agentID, change string) error { if change = strings.TrimSpace(change); change == "" { return errors.New("describe what should change") } - _, err := o.start(agentID, change) + _, err := o.start(agentID, change, false) return err } -// start returns the new run's log session. -func (o *Orchestrator) start(agentID, change string) (int64, error) { +// start returns the new run's log session. cont continues a blocked run (see Continue). +func (o *Orchestrator) start(agentID, change string, cont bool) (int64, error) { a, pid, err := o.Store.Agent(agentID) if errors.Is(err, sql.ErrNoRows) { return 0, fmt.Errorf("agent %s not found; save the arrangement first", agentID) @@ -252,7 +279,7 @@ func (o *Orchestrator) start(agentID, change string) (int64, error) { cancel() return 0, fmt.Errorf("%s is already running", a.Name) } - j := &job{o: o, ctx: ctx, p: p, a: a, change: change, session: time.Now().UnixMilli()} + j := &job{o: o, ctx: ctx, p: p, a: a, change: change, cont: cont, session: time.Now().UnixMilli()} if change != "" { j.emit("request", "you asked for changes: "+change, "") } @@ -279,6 +306,7 @@ type job struct { // it its manager passed down). "" for a plain run from the goal. change string fix bool // change is a fix the manager asks for because the team's merged work fails its checks + cont bool // continuing a blocked run: same plan, only unfinished reports run allow []string // shell commands the agent may run without asking: a worker's own checks diff --git a/internal/orch/orch_test.go b/internal/orch/orch_test.go index aa602b9..132eb8b 100644 --- a/internal/orch/orch_test.go +++ b/internal/orch/orch_test.go @@ -698,3 +698,57 @@ func TestCheckpointMessage(t *testing.T) { } } } + +// A blocked manager continues where it stopped: the report that finished keeps its work and isn't +// run again, the one that failed runs again, and then both are reviewed, merged and checked. +func TestContinueBlockedRun(t *testing.T) { + o := setup(t) + fixed := filepath.Join(t.TempDir(), "fixed") + plain := func(l []byte) []agents.Event { return []agents.Event{{Kind: "msg", Text: string(l)}} } + agents.Runtimes["contmgr"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string { + return []string{"-c", `p=$(cat); case "$p" in *"YOUR TEAM:"*) echo '{"subgoals":[{"agent":"a","title":"write a","checks":["test -f a.txt"]},{"agent":"b","title":"write b","checks":["test -f b.txt"]}]}' ;; *) echo '{"verdicts":[]}' ;; esac`} + }} + agents.Runtimes["contworker"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string { + // Beta can't do its part until the user fixes something + return []string{"-c", `p=$(cat); case "$p" in *"You are Alpha"*) echo a > a.txt ;; *) test -f ` + fixed + ` && echo b > b.txt ;; esac; true`} + }} + defer delete(agents.Runtimes, "contmgr") + defer delete(agents.Runtimes, "contworker") + + repo := newRepo(t) + p, _ := o.Store.CreateProject(store.Project{Name: "t", Repo: repo, Base: "main"}) + o.Store.SaveArrangement(p.ID, []store.Agent{ + {ID: "m", Name: "Lead", Role: "manager", Runtime: "contmgr"}, + {ID: "a", Name: "Alpha", Role: "programmer", Parent: "m", Runtime: "contworker"}, + {ID: "b", Name: "Beta", Role: "programmer", Parent: "m", Runtime: "contworker"}, + }) + o.Store.SaveGoal("m", store.Goal{Title: "both files", Checks: "test -f a.txt && test -f b.txt"}) + if err := o.Continue("m"); err == nil { + t.Fatal("nothing to continue before a run") + } + o.Start("m") + if g := waitDone(t, o, "m"); g.Status != "blocked" { + t.Fatalf("first run: %+v", g) + } + runs := func(id string) (n int) { + o.Store.SQL().QueryRow(`SELECT count(*) FROM runs WHERE agent_id=?`, id).Scan(&n) + return + } + ranA, ranB := runs("a"), runs("b") + os.WriteFile(fixed, nil, 0o644) // the user fixes what Beta needed + if err := o.Continue("m"); err != nil { + t.Fatal(err) + } + if g := waitDone(t, o, "m"); g.Status != "done" { + es, _ := o.Store.Events("m", 50) + t.Fatalf("continued run: %+v\n%+v", g, es) + } + if runs("a") != ranA || runs("b") <= ranB { + t.Fatalf("Alpha should keep its work (runs %d -> %d), Beta should run again (%d -> %d)", ranA, runs("a"), ranB, runs("b")) + } + for _, f := range []string{"a.txt", "b.txt"} { + if _, err := git.Run(repo, "cat-file", "-e", "arranger/m:"+f); err != nil { + t.Fatalf("%s should be merged into the manager", f) + } + } +} diff --git a/internal/orch/queue.go b/internal/orch/queue.go index 6339ae6..8e53d74 100644 --- a/internal/orch/queue.go +++ b/internal/orch/queue.go @@ -53,7 +53,7 @@ func (o *Orchestrator) runNext(agentID string) error { if err := o.Store.SaveGoal(agentID, store.Goal{Title: it.Title, Body: it.Body, Criteria: it.Criteria, Checks: it.Checks}); err != nil { return err } - session, err := o.start(agentID, "") + session, err := o.start(agentID, "", false) if err == nil { o.Store.StartQueueItem(it.ID, session) o.queueChanged(agentID) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index d89d2ad..c60bd50 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -262,6 +262,15 @@ func (s *Server) decidePlan(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) } +// continueRun picks a blocked manager's run back up (see Orchestrator.Continue). +func (s *Server) continueRun(w http.ResponseWriter, r *http.Request) { + if err := s.o.Continue(r.PathValue("id")); err != nil { + fail(w, http.StatusBadRequest, err) + return + } + w.WriteHeader(http.StatusAccepted) +} + func (s *Server) stop(w http.ResponseWriter, r *http.Request) { s.o.Stop(r.PathValue("id")) w.WriteHeader(http.StatusNoContent) diff --git a/internal/server/server.go b/internal/server/server.go index c4887f1..a62a80a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -52,6 +52,7 @@ func New(st *store.Store, o *orch.Orchestrator, loopback bool) http.Handler { mux.HandleFunc("PUT /api/agents/{id}/goal", s.saveGoal) mux.HandleFunc("POST /api/agents/{id}/run", s.run) mux.HandleFunc("POST /api/agents/{id}/revise", s.revise) + mux.HandleFunc("POST /api/agents/{id}/continue", s.continueRun) mux.HandleFunc("GET /api/agents/{id}/queue", s.getQueue) mux.HandleFunc("POST /api/agents/{id}/queue", s.addQueueItem) mux.HandleFunc("PUT /api/agents/{id}/queue/{item}", s.queueItem(s.editQueueItem)) @@ -73,6 +74,9 @@ func New(st *store.Store, o *orch.Orchestrator, loopback bool) http.Handler { mux.HandleFunc("POST /api/agents/{id}/hunks", s.hunks) mux.HandleFunc("GET /api/agents/{id}/merge", s.merge) mux.HandleFunc("POST /api/agents/{id}/merge", s.merge) + mux.HandleFunc("GET /api/setup", s.setup) + mux.HandleFunc("POST /api/templates", s.saveTemplate) + mux.HandleFunc("DELETE /api/templates/{id}", s.deleteTemplate) mux.HandleFunc("POST /api/types", s.saveType) mux.HandleFunc("PUT /api/types/{id}", s.saveType) mux.HandleFunc("DELETE /api/types/{id}", s.deleteType) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8f66752..5f887df 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -754,3 +754,45 @@ func TestMergeMessageListsEveryChange(t *testing.T) { t.Fatalf("merge commit message:\n%s", out) } } + +// Checks are suggested from the repo's own build files. +func TestSuggestChecks(t *testing.T) { + repo := t.TempDir() + os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module x\n"), 0o644) + os.WriteFile(filepath.Join(repo, "package.json"), []byte(`{"scripts":{"build":"vite build","test":"echo \"Error: no test specified\" && exit 1","lint":"eslint ."}}`), 0o644) + os.WriteFile(filepath.Join(repo, "pnpm-lock.yaml"), nil, 0o644) + os.WriteFile(filepath.Join(repo, "Makefile"), []byte("build:\n\tgo build\ntest:\n\tgo test ./...\n"), 0o644) + var got []string + for _, c := range suggestChecks(repo) { + got = append(got, c.Cmd) + } + want := "go build ./...|go vet ./...|go test ./...|pnpm run build|pnpm run lint|make test" + if strings.Join(got, "|") != want { + t.Fatalf("got %s\nwant %s", strings.Join(got, "|"), want) + } + if len(suggestChecks("")) != 0 { + t.Fatal("no repo, no suggestions") + } +} + +// Starter teams are always there; the user's own templates can be saved and deleted. +func TestTemplatesAPI(t *testing.T) { + a := newApp(t) + var s struct { + Tools []tool + Templates []store.Template + } + json.Unmarshal([]byte(a.must(200, "GET", "/api/setup?project="+a.pid, "")), &s) + if len(s.Templates) != 3 || s.Templates[0].Name != "Feature team" || s.Templates[0].Agents[1].Prompt == "" || len(s.Tools) == 0 { + t.Fatalf("setup: %+v", s) + } + a.must(400, "POST", "/api/templates", `{"name":"empty","agents":[]}`) + var saved store.Template + json.Unmarshal([]byte(a.must(200, "POST", "/api/templates", `{"name":"Mine","agents":[{"key":"x","name":"X","role":"programmer"}]}`)), &saved) + json.Unmarshal([]byte(a.must(200, "GET", "/api/setup?project="+a.pid, "")), &s) + if len(s.Templates) != 4 || s.Templates[3].Name != "Mine" { + t.Fatalf("saved template missing: %+v", s.Templates) + } + a.must(400, "DELETE", "/api/templates/starter-docs", "") + a.must(204, "DELETE", "/api/templates/"+saved.ID, "") +} diff --git a/internal/server/setup.go b/internal/server/setup.go new file mode 100644 index 0000000..05d1941 --- /dev/null +++ b/internal/server/setup.go @@ -0,0 +1,150 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "arranger/internal/agents" + "arranger/internal/store" +) + +// Getting started: which agent CLIs are ready, checks that fit the project's repo, and starter teams. + +// tool tells a new user how to get one agent CLI ready. +type tool struct { + Runtime string `json:"runtime"` + Bin string `json:"bin"` + Installed bool `json:"installed"` + Version string `json:"version"` + Install string `json:"install"` + Login string `json:"login"` + Docs string `json:"docs"` +} + +// toolHelp is how to install each agent CLI and sign in; check the tool's docs if a command changes. +var toolHelp = map[string]struct{ install, login, docs string }{ + "claude": {"npm install -g @anthropic-ai/claude-code", "claude (then /login)", "https://docs.anthropic.com/en/docs/claude-code"}, + "codex": {"npm install -g @openai/codex", "codex login", "https://github.com/openai/codex"}, + "gemini": {"npm install -g @google/gemini-cli", "gemini (then sign in)", "https://github.com/google-gemini/gemini-cli"}, + "opencode": {"npm install -g opencode-ai", "opencode auth login", "https://opencode.ai/docs"}, + "cursor": {"curl https://cursor.com/install -fsS | bash", "cursor-agent login", "https://cursor.com/cli"}, + "amp": {"npm install -g @sourcegraph/amp", "amp login", "https://ampcode.com"}, + "pi": {"npm install -g @mariozechner/pi-coding-agent", "pi (then /login)", "https://github.com/badlogic/pi-mono"}, +} + +func tools(ctx context.Context) []tool { + ts := []tool{} + for name, rt := range agents.Runtimes { + h, ok := toolHelp[name] + if !ok || rt.Bin == "" { + continue + } + t := tool{Runtime: name, Bin: rt.Bin, Install: h.install, Login: h.login, Docs: h.docs} + if _, err := exec.LookPath(rt.Bin); err == nil { + t.Installed = true + c, cancel := context.WithTimeout(ctx, 3*time.Second) + if out, err := exec.CommandContext(c, rt.Bin, "--version").Output(); err == nil { + t.Version = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) + } + cancel() + } + ts = append(ts, t) + } + sort.Slice(ts, func(i, j int) bool { + if ts[i].Installed != ts[j].Installed { + return ts[i].Installed + } + return ts[i].Runtime < ts[j].Runtime + }) + return ts +} + +// check is a shell check that fits the repo, and what it proves. +type check struct { + Cmd string `json:"cmd"` + Why string `json:"why"` +} + +// suggestChecks reads the repo's build files and suggests checks that prove work is done. +func suggestChecks(repo string) []check { + cs := []check{} + has := func(f string) bool { _, err := os.Stat(filepath.Join(repo, f)); return err == nil } + if repo == "" { + return cs + } + if has("go.mod") { + cs = append(cs, check{"go build ./...", "it compiles"}, check{"go vet ./...", "no suspicious code"}, check{"go test ./...", "the tests pass"}) + } + if has("package.json") { + pm := "npm" + switch { + case has("pnpm-lock.yaml"): + pm = "pnpm" + case has("yarn.lock"): + pm = "yarn" + case has("bun.lockb"), has("bun.lock"): + pm = "bun" + } + var pkg struct{ Scripts map[string]string } + if b, err := os.ReadFile(filepath.Join(repo, "package.json")); err == nil { + json.Unmarshal(b, &pkg) + } + for _, s := range []struct{ name, why string }{{"build", "it builds"}, {"typecheck", "types check"}, {"lint", "lint passes"}, {"test", "the tests pass"}} { + if sc, ok := pkg.Scripts[s.name]; ok && !strings.Contains(sc, "no test specified") { + cs = append(cs, check{pm + " run " + s.name, s.why}) + } + } + } + if has("Cargo.toml") { + cs = append(cs, check{"cargo build", "it compiles"}, check{"cargo test", "the tests pass"}) + } + if has("pyproject.toml") || has("setup.py") || has("requirements.txt") { + cs = append(cs, check{"python3 -m pytest -q", "the tests pass"}) + } + if b, err := os.ReadFile(filepath.Join(repo, "Makefile")); err == nil { + for _, target := range []string{"test", "check", "lint"} { + if strings.Contains("\n"+string(b), "\n"+target+":") { + cs = append(cs, check{"make " + target, "make " + target + " passes"}) + } + } + } + return cs +} + +// setup returns what a new user needs: agent CLIs, checks for the project's repo, starter teams. +func (s *Server) setup(w http.ResponseWriter, r *http.Request) { + p, _ := s.st.Project(r.URL.Query().Get("project")) + ts, err := s.st.Templates() + if err != nil { + fail(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, map[string]any{"tools": tools(r.Context()), "checks": suggestChecks(p.Repo), "templates": ts}) +} + +func (s *Server) saveTemplate(w http.ResponseWriter, r *http.Request) { + var t store.Template + if !readJSON(w, r, &t) { + return + } + if err := s.st.SaveTemplate(&t); err != nil { + fail(w, http.StatusBadRequest, err) + return + } + writeJSON(w, t) +} + +func (s *Server) deleteTemplate(w http.ResponseWriter, r *http.Request) { + if err := s.st.DeleteTemplate(r.PathValue("id")); err != nil { + fail(w, http.StatusBadRequest, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/store/queue.go b/internal/store/queue.go index f285b9e..b5f739a 100644 --- a/internal/store/queue.go +++ b/internal/store/queue.go @@ -173,6 +173,11 @@ func (s *Store) RunningQueueItem(agentID string) (QueueItem, error) { return scanQueueItem(s.db.QueryRow(`SELECT `+queueCols+` FROM queue WHERE agent_id=? AND status='running' LIMIT 1`, agentID)) } +// LastQueueItem is the goal of the agent's list that finished last; sql.ErrNoRows when none did. +func (s *Store) LastQueueItem(agentID string) (QueueItem, error) { + return scanQueueItem(s.db.QueryRow(`SELECT `+queueCols+` FROM queue WHERE agent_id=? AND status NOT IN ('queued', 'running', 'skipped') ORDER BY finished DESC, id DESC LIMIT 1`, agentID)) +} + // StartQueueItem marks a goal as running in the given log session. func (s *Store) StartQueueItem(id, session int64) { s.db.Exec(`UPDATE queue SET status='running', session=? WHERE id=?`, session, id) diff --git a/internal/store/store.go b/internal/store/store.go index 8604b6b..a7abd6b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -44,6 +44,7 @@ CREATE TABLE IF NOT EXISTS queue ( criteria TEXT NOT NULL DEFAULT '', checks TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'queued', session INTEGER NOT NULL DEFAULT 0, finished INTEGER NOT NULL DEFAULT 0); CREATE INDEX IF NOT EXISTS queue_agent ON queue(agent_id, status, pos); +CREATE TABLE IF NOT EXISTS templates (id TEXT PRIMARY KEY, name TEXT NOT NULL, about TEXT NOT NULL DEFAULT '', json TEXT NOT NULL, created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS plans ( id INTEGER PRIMARY KEY, agent_id TEXT NOT NULL, kind TEXT NOT NULL, json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', feedback TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL, decided INTEGER NOT NULL DEFAULT 0); @@ -450,6 +451,17 @@ func (s *Store) CheckpointGoal(sha string) string { return strings.TrimSpace(title) } +// LastDecision is the agent's latest decision of one of the kinds: its kind and JSON, or "" if none. +func (s *Store) LastDecision(agentID string, kinds ...string) (kind, raw string) { + q := `SELECT kind, json FROM decisions WHERE agent_id=? AND kind IN (?` + strings.Repeat(",?", len(kinds)-1) + `) ORDER BY id DESC LIMIT 1` + args := []any{agentID} + for _, k := range kinds { + args = append(args, k) + } + s.db.QueryRow(q, args...).Scan(&kind, &raw) + return +} + // AddDecision records a manager's plan or review verdicts. func (s *Store) AddDecision(agentID, kind string, v any) { b, _ := json.Marshal(v) diff --git a/internal/store/templates.go b/internal/store/templates.go new file mode 100644 index 0000000..7942be4 --- /dev/null +++ b/internal/store/templates.go @@ -0,0 +1,106 @@ +package store + +import ( + "encoding/json" + "errors" + "strings" + "time" +) + +// Template is a team to drop onto the canvas: a starter team, or an arrangement the user saved. +type Template struct { + ID string `json:"id"` + Name string `json:"name"` + About string `json:"about"` + Agents []TemplateAgent `json:"agents"` + BuiltIn bool `json:"builtIn"` +} + +// TemplateAgent is one box of a template; Key and Parent link boxes within it, X and Y are relative. +type TemplateAgent struct { + Key string `json:"key"` + Name string `json:"name"` + Role string `json:"role"` + Parent string `json:"parent"` + Runtime string `json:"runtime"` + Model string `json:"model"` + Args string `json:"args"` + Prompt string `json:"prompt"` + Color string `json:"color"` + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// StarterTeams are the teams a new project can start from. +var StarterTeams = []Template{ + {ID: "starter-feature", Name: "Feature team", About: "A manager with a product manager, two programmers and a tester.", BuiltIn: true, Agents: []TemplateAgent{ + {Key: "lead", Name: "Lead", Role: "manager", X: 360, Y: 0}, + {Key: "pm", Name: "Product Manager", Role: "product-manager", Parent: "lead", X: 0, Y: 150}, + {Key: "be", Name: "Backend", Role: "programmer", Parent: "lead", X: 240, Y: 150}, + {Key: "fe", Name: "Frontend", Role: "programmer", Parent: "lead", X: 480, Y: 150}, + {Key: "qa", Name: "Tester", Role: "tester", Parent: "lead", X: 720, Y: 150}, + }}, + {ID: "starter-bugfix", Name: "Bug fix", About: "A manager with a programmer who fixes it and a tester who proves it.", BuiltIn: true, Agents: []TemplateAgent{ + {Key: "lead", Name: "Lead", Role: "manager", X: 120, Y: 0}, + {Key: "fix", Name: "Fixer", Role: "programmer", Parent: "lead", X: 0, Y: 150}, + {Key: "qa", Name: "Tester", Role: "tester", Parent: "lead", X: 240, Y: 150}, + }}, + {ID: "starter-docs", Name: "Docs", About: "A technical content writer on its own.", BuiltIn: true, Agents: []TemplateAgent{ + {Key: "doc", Name: "Technical Content Writer", Role: "writer", X: 0, Y: 0}, + }}, +} + +// Templates are the starter teams, then the user's saved ones. +func (s *Store) Templates() ([]Template, error) { + ts := append([]Template{}, StarterTeams...) + for i := range ts { + for j := range ts[i].Agents { + a := &ts[i].Agents[j] + if a.Prompt == "" { + a.Prompt = RolePrompts[a.Role] + } + } + } + rows, err := s.db.Query(`SELECT id, name, about, json FROM templates ORDER BY created`) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var t Template + var raw string + if err := rows.Scan(&t.ID, &t.Name, &t.About, &raw); err != nil { + return nil, err + } + json.Unmarshal([]byte(raw), &t.Agents) + ts = append(ts, t) + } + return ts, rows.Err() +} + +// SaveTemplate stores the user's arrangement as a new template. +func (s *Store) SaveTemplate(t *Template) error { + t.Name = strings.TrimSpace(t.Name) + if t.Name == "" { + return errors.New("give the template a name") + } + if len(t.Agents) == 0 { + return errors.New("the canvas is empty; add agents first") + } + b, err := json.Marshal(t.Agents) + if err != nil { + return err + } + t.ID, t.BuiltIn = newID("tpl"), false + _, err = s.db.Exec(`INSERT INTO templates(id, name, about, json, created) VALUES(?,?,?,?,?)`, t.ID, t.Name, t.About, string(b), time.Now().UnixMilli()) + return err +} + +// DeleteTemplate removes a saved template; starter teams stay. +func (s *Store) DeleteTemplate(id string) error { + if strings.HasPrefix(id, "starter-") { + return errors.New("starter teams can't be deleted") + } + _, err := s.db.Exec(`DELETE FROM templates WHERE id=?`, id) + return err +} diff --git a/web/app.js b/web/app.js index 5f9b832..856d2d5 100644 --- a/web/app.js +++ b/web/app.js @@ -163,6 +163,7 @@ function addAgent(role, name, type, x, y, parent) { }; agents.set(a.id, a); makeCard(a); + showEmptyCanvas(); return a; } @@ -283,7 +284,14 @@ canvas.addEventListener("pointerdown", e => { c.onpointerup = c.onpointercancel = () => { c.onpointermove = c.onpointerup = c.onpointercancel = null; c.classList.remove("dragging"); - if (!moved) { select(a.id); return; } + if (!moved && e.shiftKey) { + const ids = new Set(picked); + if (selected && !ids.size) ids.add(selected); + ids.has(a.id) ? ids.delete(a.id) : ids.add(a.id); + setPicked(ids); + return; + } + if (!moved) { setPicked([]); select(a.id); return; } if (over) { over.classList.remove("drop"); a.parent = over.dataset.id; @@ -295,16 +303,57 @@ canvas.addEventListener("pointerdown", e => { drawLinks(); }; }); -// empty space: drag to pan, like draw.io +// picked: the agents selected together, by dragging a box around them or shift-clicking +const picked = new Set(); +function setPicked(ids) { + picked.clear(); + ids.forEach(id => picked.add(id)); + for (const a of agents.values()) a.el.classList.toggle("picked", picked.has(a.id)); + if (picked.size > 1) say(`${picked.size} agents selected · Backspace removes them, Esc clears`); +} + +// empty space: drag to select the boxes in a rectangle, like draw.io; hold Space (or use the +// middle button) and drag to move around, or just scroll +let spaceDown = false; +document.addEventListener("keydown", e => { + if (e.code !== "Space" || e.repeat || e.target.closest?.("input, textarea, select, button, [contenteditable], dialog")) return; + spaceDown = true; + main.classList.add("can-pan"); + e.preventDefault(); +}); +document.addEventListener("keyup", e => { if (e.code === "Space") { spaceDown = false; main.classList.remove("can-pan"); } }); main.addEventListener("pointerdown", e => { - if (e.button !== 0 || e.target.closest(".card, button, #links .link")) return; - const sx = e.clientX + main.scrollLeft, sy = e.clientY + main.scrollTop; + if (e.button !== 0 && e.button !== 1 || e.target.closest("button, #links .link, #empty-canvas") || e.target.closest(".card") && !spaceDown) return; try { main.setPointerCapture(e.pointerId); } catch {} - main.classList.add("panning"); - main.onpointermove = m => { main.scrollLeft = sx - m.clientX; main.scrollTop = sy - m.clientY; }; + if (spaceDown || e.button === 1) { + e.preventDefault(); + const sx = e.clientX + main.scrollLeft, sy = e.clientY + main.scrollTop; + main.classList.add("panning"); + main.onpointermove = m => { main.scrollLeft = sx - m.clientX; main.scrollTop = sy - m.clientY; }; + main.onpointerup = main.onpointercancel = () => { + main.onpointermove = main.onpointerup = main.onpointercancel = null; + main.classList.remove("panning"); + }; + return; + } + const r0 = canvas.getBoundingClientRect(), x0 = e.clientX - r0.left, y0 = e.clientY - r0.top; + const box = el("div"); + box.id = "marquee"; + let moved = false; + const before = e.shiftKey ? new Set(picked) : new Set(); // shift adds to what's selected + main.onpointermove = m => { + const r = canvas.getBoundingClientRect(), x1 = m.clientX - r.left, y1 = m.clientY - r.top; + if (!moved && Math.hypot(x1 - x0, y1 - y0) < 4) return; + if (!moved) { moved = true; canvas.append(box); } + const left = Math.min(x0, x1), top = Math.min(y0, y1), w = Math.abs(x1 - x0), h = Math.abs(y1 - y0); + Object.assign(box.style, { left: left + "px", top: top + "px", width: w + "px", height: h + "px" }); + const inside = [...agents.values()].filter(a => a.x < left + w && a.x + a.el.offsetWidth > left && a.y < top + h && a.y + a.el.offsetHeight > top).map(a => a.id); + setPicked([...before, ...inside]); + }; main.onpointerup = main.onpointercancel = () => { main.onpointermove = main.onpointerup = main.onpointercancel = null; - main.classList.remove("panning"); + box.remove(); + if (!moved && !e.shiftKey) setPicked([]); // a click on empty space clears the selection }; }); // ask shows the styled confirm dialog and resolves true when the user presses the action button, @@ -325,33 +374,62 @@ function ask(title, text, action, keep = "Cancel", danger = true) { } // removeAgent takes an agent off the canvas after the user confirms; its reports move up a level. -async function removeAgent(id) { - const a = agents.get(id); - if (!a) return; - if (BUSY.includes(a.el.dataset.status)) { say(`${a.name} is running; stop it before removing it`, true); return; } - const n = kidsOf(id).length, st = summary.agents[id]; +async function removeAgent(id) { return removeAgents([id]); } + +// removeAgents takes agents off the canvas after one confirmation. Reports of a removed agent move +// up to its nearest manager that stays. +async function removeAgents(ids) { + const gone = ids.map(id => agents.get(id)).filter(Boolean); + if (!gone.length) return; + const busy = gone.filter(a => BUSY.includes(a.el.dataset.status)); + if (busy.length) { say(`${names(busy.map(a => a.name))} ${busy.length === 1 ? "is" : "are"} running; stop ${busy.length === 1 ? "it" : "them"} before removing`, true); return; } + const out = new Set(gone.map(a => a.id)); + const keeper = id => { let p = parentOf(id); while (p && out.has(p)) p = parentOf(p); return p || ""; }; + const moving = [...agents.values()].filter(k => !out.has(k.id) && out.has(k.parent)); + const withWork = gone.filter(a => summary.agents[a.id]?.adds || summary.agents[a.id]?.dels); const notes = []; - if (n) notes.push(`Its ${n === 1 ? "report moves" : `${n} reports move`} up to ${a.parent ? nameOf(a.parent) : "the top level"}.`); - if (st?.adds || st?.dels) notes.push(`Its work (+${st.adds} −${st.dels}) and its branch arranger/${id} are deleted when you save. Merge it first if you want to keep it.`); - if (!await ask(`Remove ${a.name}?`, notes.join(" "), "Remove")) return; - if (!agents.has(id)) return; // removed some other way while the dialog was open - kidsOf(id).forEach(k => k.parent = a.parent); // reports move up a level - if (selected === id) closeInspector(); - a.el.remove(); - agents.delete(id); + if (gone.length === 1) { + const a = gone[0], st = summary.agents[a.id]; + if (moving.length) notes.push(`Its ${moving.length === 1 ? "report moves" : `${moving.length} reports move`} up to ${keeper(a.id) ? nameOf(keeper(a.id)) : "the top level"}.`); + if (withWork.length) notes.push(`Its work (+${st.adds} −${st.dels}) and its branch arranger/${a.id} are deleted when you save. Merge it first if you want to keep it.`); + } else { + notes.push(names(gone.map(a => a.name)) + "."); + if (moving.length) notes.push(`${moving.length === 1 ? "1 report moves" : `${moving.length} reports move`} up to the nearest manager that stays.`); + if (withWork.length === gone.length) notes.push("Their work and branches are deleted when you save. Merge first if you want to keep it."); + else if (withWork.length) notes.push(`The work of ${names(withWork.map(a => a.name))} and ${withWork.length === 1 ? "its branch are" : "their branches are"} deleted when you save. Merge first if you want to keep it.`); + } + if (!await ask(gone.length === 1 ? `Remove ${gone[0].name}?` : `Remove ${gone.length} agents?`, notes.join(" "), gone.length === 1 ? "Remove" : `Remove ${gone.length}`)) return; + for (const k of moving) if (agents.has(k.id)) k.parent = keeper(k.id); + for (const a of gone) { + if (!agents.has(a.id)) continue; // removed some other way while the dialog was open + if (selected === a.id) closeInspector(); + a.el.remove(); + agents.delete(a.id); + } + setPicked([]); markDirty(); drawLinks(); + showEmptyCanvas(); } canvas.addEventListener("click", e => { if (e.target.classList.contains("x")) removeAgent(e.target.closest(".card").dataset.id); }); // Backspace or Delete removes the selected agent, unless the user is typing or a dialog is open document.addEventListener("keydown", e => { - if (e.key !== "Backspace" && e.key !== "Delete" || !selected || e.metaKey || e.ctrlKey || e.altKey) return; const t = e.target; if (t.closest?.("input, textarea, select, [contenteditable], dialog") || document.querySelector("dialog[open]")) return; + if (e.key === "Escape" && picked.size) { setPicked([]); say(""); return; } + if (e.key.toLowerCase() === "a" && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) { // select every agent + if (!agents.size) return; + e.preventDefault(); + setPicked([...agents.keys()]); + return; + } + if (e.key !== "Backspace" && e.key !== "Delete" || e.metaKey || e.ctrlKey || e.altKey) return; + const ids = picked.size ? [...picked] : selected ? [selected] : []; + if (!ids.length) return; e.preventDefault(); - removeAgent(selected); + removeAgents(ids); }); // askText is ask with a text field; it resolves to the text, or null when cancelled. async function askText(title, value, action) { @@ -653,6 +731,7 @@ async function select(id) { d.dataset.path = data.dir; d.title = data.dir ? `Worktree on branch ${data.branch}. Click to copy the path.` : ""; for (const k of ["title", "body", "criteria", "checks"]) gform.elements[k].value = g[k]; + showSuggestions($("#g-suggest"), gform.elements.checks); for (const k of ["name", "runtime", "model", "args", "prompt"]) aform.elements[k].value = a[k]; aform.elements.tokenSoft.value = a.tokenSoft || ""; aform.elements.tokenHard.value = a.tokenHard || ""; @@ -699,6 +778,9 @@ $("#a-parent").onchange = e => { function showGoalState(g) { const mgr = parentOf(selected), team = kidsOf(selected); $("#i-run").title = !mgr ? "Run the goals in its list" : "Run from its goal"; + const canContinue = team.length > 0 && ["blocked", "failed"].includes(g.status); + $("#i-continue").hidden = !canContinue; + $("#i-run").classList.toggle("primary", !canContinue); // one primary action at a time $("#g-hint").textContent = kidsOf(selected).length ? "Manager: Run plans subgoals for its team, runs them in parallel, reviews and merges their work, then runs these checks on the result." : mgr ? `${nameOf(mgr)} sets this goal when it plans; editing it here is fine, but the next plan replaces it.` : ""; @@ -729,8 +811,17 @@ function renderWorking(st) { ? "It's reading the repo and writing the plan. The plan shows up here for your approval; nothing runs until you approve it." : ""; } +// showStop enables Stop only while the selected agent is working or waiting on its plan. +function showStop() { + const s = selected && summary.agents[selected]?.status; + const on = BUSY.includes(s) || s === "awaiting"; + $("#i-stop").disabled = !on; + $("#i-stop").title = on ? "Stop it (and its team)" : "Nothing to stop: it isn't running"; +} + function renderStats() { const st = selected && summary.agents[selected], box = $("#i-stats"); + showStop(); renderWorking(st); if (!st) { box.replaceChildren(); return; } const parts = [el("span", "", st.status)]; @@ -875,6 +966,20 @@ $("#r-go").onclick = async e => { btn.disabled = false; }; +// Continue: a blocked manager picks up where it stopped instead of planning and running everyone again. +async function continueRun() { + const id = selected; + try { + if (dirty) await save(); + await api("POST", `/api/agents/${encodeURIComponent(id)}/continue`); + setStatus(id, "starting"); + runMsg(`${nameOf(id)} is continuing: finished reports keep their work, the others run again.`); + refreshSummary(); + showTab("logs"); + } catch (err) { runMsg(err.message, true); } +} +$("#i-continue").onclick = continueRun; + $("#i-clone").onclick = async () => { if (!await ask(`Add a copy of ${nameOf(selected)}?`, "It gets the same settings and manager, but no goal." + (dirty ? " Unsaved changes on the canvas are saved too." : ""), "Add copy", "Cancel", false)) return; @@ -914,11 +1019,50 @@ async function loadPlan(id) { } function planChanged(id) { - document.title = (plans.size ? "Plan ready · " : "") + baseTitle; + showTabState(); refreshCards(); if (id === selected) renderPlan(); } +// showTabState puts the project's state in the browser tab, for someone working in another tab: +// "2 need you · …", "Working · …", "Done · …", with a matching dot on the icon. +let tabState = { needs: 0, running: 0 }; +function showTabState(needs = tabState.needs, running = tabState.running) { + tabState = { needs, running }; + const tops = [...agents.values()].filter(a => !parentOf(a.id)).map(a => summary.agents[a.id]?.status); + const [label, color] = needs ? [`${needs} ${needs === 1 ? "needs" : "need"} you`, "failed"] + : running ? ["Working", "running"] + : tops.length && tops.some(s => s === "done") && tops.every(s => ["done", "idle", undefined].includes(s)) ? ["Done", "done"] + : ["", ""]; + document.title = (label ? label + " · " : "") + baseTitle; + drawFavicon(color); +} + +// drawFavicon is the app icon with a colored dot for the state, or the plain icon. +const favicon = $('link[rel="icon"]'), faviconImg = new Image(); +let faviconColor = null; +faviconImg.src = "/arranger.png"; +function drawFavicon(color) { + if (color === faviconColor) return; + faviconColor = color; + if (!color) { favicon.href = "/arranger.png"; return; } + const draw = () => { + const c = document.createElement("canvas"); + c.width = c.height = 64; + const g = c.getContext("2d"); + if (faviconImg.complete && faviconImg.naturalWidth) g.drawImage(faviconImg, 0, 0, 64, 64); + g.beginPath(); + g.arc(46, 46, 16, 0, 2 * Math.PI); + g.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--" + color).trim() || "#d64545"; + g.fill(); + g.lineWidth = 5; + g.strokeStyle = "#fff"; + g.stroke(); + favicon.href = c.toDataURL("image/png"); + }; + faviconImg.complete ? draw() : faviconImg.addEventListener("load", draw, { once: true }); +} + // syncPlans fetches drafts for managers that wait on the user and drops the ones that don't anymore. function syncPlans() { for (const [id, s] of Object.entries(summary.agents)) if (s.status === "awaiting" && !plans.has(id)) loadPlan(id); @@ -1165,6 +1309,7 @@ function openItemForm(it) { $("#qf-criteria").value = it?.criteria ?? ""; $("#qf-checks").value = it?.checks ?? gform.elements.checks.value; // the agent's own checks are a good start $("#qf-save").textContent = it ? "Save goal" : "Add goal"; + showSuggestions($("#qf-suggest"), $("#qf-checks")); $("#q-actions").hidden = true; $("#qf-title").focus(); } @@ -1251,6 +1396,166 @@ $("#q-items").addEventListener("dragend", e => { queueCall("POST", "/order", { ids }); }); +/* ---------- get started: agent tools, starter teams, checks for this repo ---------- */ + +let setupData = null; +async function loadSetup() { + try { setupData = await api("GET", `/api/setup?project=${encodeURIComponent(PID)}`); } catch { setupData = null; } + return setupData; +} + +function copyButton(text) { + const b = el("button", "", "Copy"); + b.type = "button"; + b.onclick = async () => { + try { await navigator.clipboard.writeText(text); b.textContent = "Copied"; } catch { b.textContent = "Select it"; } + setTimeout(() => b.textContent = "Copy", 1500); + }; + return b; +} +function cmdRow(label, text) { + const r = el("div", "su-cmd"); + r.append(el("span", "", label), el("code", "", text), copyButton(text)); + return r; +} + +function renderSetup() { + const d = setupData; + if (!d) { $("#su-tools").replaceChildren(el("p", "note bad", "Couldn't check this machine.")); return; } + $("#su-tools").replaceChildren(...d.tools.map(t => { + const row = el("div", "su-tool"), state = el("span", t.installed ? "ok" : "no", t.installed ? `Installed${t.version ? " · " + t.version : ""}` : "Not installed"); + const cmds = el("div", "cmds"); + if (!t.installed) cmds.append(cmdRow("install", t.install)); + cmds.append(cmdRow("sign in", t.login)); + const docs = el("a", "", "Docs"); + docs.href = t.docs; docs.target = "_blank"; docs.rel = "noopener"; + const head = el("span"); + head.append(state, " · ", docs); + row.append(el("span", "name", t.runtime), head, cmds); + return row; + })); + $("#su-teams").replaceChildren(...d.templates.map(t => { + const card = el("div", "su-team"), add = el("button", "primary", "Add to canvas"), row = el("div", "row"); + add.type = "button"; + add.title = "Add this team next to the agents already on the canvas"; + add.onclick = () => addTemplate(t); + const only = el("button", "", "Replace canvas"); + only.type = "button"; + only.title = "Remove every agent on the canvas and use only this team"; + only.onclick = () => replaceWithTemplate(t); + row.append(add, only); + if (!t.builtIn) { + const del = el("button", "danger", "Delete"); + del.type = "button"; + del.onclick = async () => { + if (!await ask(`Delete the "${t.name}" template?`, "Teams already on a canvas stay.", "Delete")) return; + try { await api("DELETE", `/api/templates/${encodeURIComponent(t.id)}`); await loadSetup(); renderSetup(); } catch (err) { status($("#su-msg"), err.message, true); } + }; + row.append(del); + } + card.append(el("b", "", t.name), el("p", "hint", t.about || `${t.agents.length} agents`), el("span", "hint", t.agents.map(a => a.name).join(", ")), row); + return card; + })); + $("#su-checks").replaceChildren(...(d.checks.length ? d.checks.map(c => { const s = el("span", "", c.cmd); s.append(el("i", "", c.why)); return s; }) + : [el("p", "hint", window.PROJECT?.repo ? "No build files found to suggest checks from. Write your own: any shell command that exits 0 when the goal is met." : "Set this project's git repository (⚙ in the header) to get suggestions.")])); +} + +async function openSetup() { + status($("#su-msg"), ""); + $("#su-tools").replaceChildren(el("p", "hint", "Checking this machine…")); + $("#setup-dialog").showModal(); + await loadSetup(); + renderSetup(); +} +$("#get-started").onclick = openSetup; +$("#empty-start").onclick = openSetup; + +// addTemplate drops a team onto the canvas, to the right of what's there, and saves it. +async function addTemplate(t) { + let x0 = PAD, y0 = PAD; + for (const a of agents.values()) x0 = Math.max(x0, a.x + (a.el?.offsetWidth || 210) + GAP_X / 2); + const ids = {}; + for (const ta of t.agents) { + const a = addAgent(ta.role, ta.name, TYPES.get(ta.role) || null, x0 + ta.x, y0 + ta.y, ta.parent ? ids[ta.parent] : ""); + ids[ta.key] = a.id; + Object.assign(a, { runtime: ta.runtime || a.runtime, color: ta.color || "" }); + a.defaults = { ...(a.defaults || {}), ...(ta.model ? { model: ta.model } : {}), ...(ta.args ? { args: ta.args } : {}), ...(ta.prompt ? { prompt: ta.prompt } : {}) }; + paintCard(a); + } + drawLinks(); + showEmptyCanvas(); + try { + await save(); + $("#setup-dialog").close(); + say(`Added the ${t.name} team`); + main.scrollTo({ left: Math.max(0, x0 - PAD), top: 0, behavior: "smooth" }); + } catch (err) { status($("#su-msg"), err.message, true); } +} + +// replaceWithTemplate clears the canvas and puts only this team on it, after the user confirms. +async function replaceWithTemplate(t) { + const all = [...agents.values()]; + if (!all.length) return addTemplate(t); + const busy = all.filter(a => BUSY.includes(a.el.dataset.status)); + if (busy.length) { status($("#su-msg"), `${names(busy.map(a => a.name))} ${busy.length === 1 ? "is" : "are"} running; stop ${busy.length === 1 ? "it" : "them"} first.`, true); return; } + const withWork = all.filter(a => summary.agents[a.id]?.adds || summary.agents[a.id]?.dels); + if (!await ask(`Replace the canvas with ${t.name}?`, + `This removes the ${all.length === 1 ? "agent" : `${all.length} agents`} on it now (${names(all.map(a => a.name))}).` + + (withWork.length ? ` Their work and branches are deleted when it saves; merge first if you want to keep ${withWork.length === 1 ? "it" : "them"}.` : ""), + "Replace canvas")) return; + closeInspector(); + setPicked([]); + for (const a of all) { a.el.remove(); agents.delete(a.id); } + await addTemplate(t); // places it at the top left and saves +} + +$("#su-save").onclick = async () => { + const name = $("#su-name").value.trim(); + if (!name) { status($("#su-msg"), "Give the template a name.", true); $("#su-name").focus(); return; } + if (!agents.size) { status($("#su-msg"), "The canvas is empty; add agents first.", true); return; } + try { + if (dirty) await save(); + const list = [...agents.values()]; + const minX = Math.min(...list.map(a => a.x)), minY = Math.min(...list.map(a => a.y)); + const full = await Promise.all(list.map(a => api("GET", `/api/agents/${encodeURIComponent(a.id)}`).then(r => r.agent))); + // parents first, so a template always builds top-down + const depth = a => { let n = 0; for (let p = parentOf(a.id); p; p = parentOf(p)) n++; return n; }; + const rows = list.map((a, i) => ({ a, f: full[i] })).sort((x, y) => depth(x.a) - depth(y.a)); + await api("POST", "/api/templates", { name, agents: rows.map(({ a, f }) => ({ + key: a.id, name: a.name, role: a.role, parent: parentOf(a.id) || "", runtime: f.runtime, model: f.model, args: f.args, + prompt: f.prompt, color: f.color, x: a.x - minX, y: a.y - minY })) }); + $("#su-name").value = ""; + status($("#su-msg"), `Saved "${name}".`); + await loadSetup(); + renderSetup(); + } catch (err) { status($("#su-msg"), err.message, true); } +}; + +// showEmptyCanvas invites a new user to start from a team when the canvas is empty. +function showEmptyCanvas() { $("#empty-canvas").hidden = agents.size > 0; } + +// suggested checks under each Checks box: one click adds the line +function showSuggestions(box, textarea) { + const cs = setupData?.checks || []; + if (!cs.length) { box.replaceChildren(); return; } + const have = new Set(lines(textarea.value)); + const todo = cs.filter(c => !have.has(c.cmd)); + if (!todo.length) { box.replaceChildren(); return; } + box.replaceChildren("Suggested:", ...todo.map(c => { + const b = el("button", "", c.cmd); + b.type = "button"; + b.title = `Add this check: ${c.why}`; + b.onclick = () => { + textarea.value = (textarea.value.trim() ? textarea.value.trimEnd() + "\n" : "") + c.cmd; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + showSuggestions(box, textarea); + }; + return b; + })); +} +$("#qf-checks").addEventListener("input", () => showSuggestions($("#qf-suggest"), $("#qf-checks"))); +gform.elements.checks.addEventListener("input", () => showSuggestions($("#g-suggest"), gform.elements.checks)); + /* ---------- logs: one section per run, outcome and reason first ---------- */ const log = $("#log"), logPane = $('[data-pane="logs"]'); @@ -1562,7 +1867,14 @@ function section(g, latest, all, first) { if (act) body.append(el("p", "lact", act)); const cl = checklist(g); if (cl) body.append(cl); - if (latest && ["done", "failed", "blocked"].includes(o.state) && canRevise(logState.agent)) { + const manager = kidsOf(logState.agent).length > 0; + if (latest && manager && ["blocked", "failed"].includes(o.state)) { + const p = el("p", "lnext"), b = el("button", "primary", "Continue"); + b.type = "button"; + b.onclick = continueRun; + p.append("Fixed what stopped it? ", b, " picks up from here: finished reports keep their work, the rest run again."); + body.append(p); + } else if (latest && ["done", "failed", "blocked"].includes(o.state) && canRevise(logState.agent)) { const p = el("p", "lnext"); p.append("Not quite right? ", reviseButton("primary")); body.append(p); @@ -1894,6 +2206,7 @@ async function refreshSummary() { $("#running").hidden = running === 0; $("#tokens").textContent = fmtTok(all.reduce((n, [, s]) => n + (s.allTokens || 0), 0)); $("#n-needs").textContent = needs.length; + showTabState(needs.length, running); $("#needs").dataset.n = needs.length; $("#needs").dataset.kind = needs.every(([, s]) => s.status === "awaiting") ? "plan" : "fail"; // amber for plans, red for failures if (!needs.length) $("#needs").open = false; @@ -1918,6 +2231,7 @@ function setStatus(id, s) { st.status = s; if (BUSY.includes(s) && !st.since) st.since = Date.now(); refreshCards(); + if (id === selected) showStop(); } let summaryTimer = 0; @@ -1981,5 +2295,7 @@ renderTypes(); if ([...agents.values()].some(a => a.x == null || a.y == null)) layout(false); agents.forEach(makeCard); refreshCards(); +showEmptyCanvas(); +loadSetup(); // suggested checks for the goal forms refreshSummary(); document.fonts?.ready.then(drawLinks); // box heights settle once fonts load diff --git a/web/arrange.html b/web/arrange.html index bc1ad13..15331e9 100644 --- a/web/arrange.html +++ b/web/arrange.html @@ -35,6 +35,8 @@ button:hover { border-color: var(--muted); } button.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); font-weight: 600; } button.danger { color: var(--failed); } +button:disabled { opacity: .45; cursor: not-allowed; } +button:disabled:hover { border-color: var(--line); } button.primary.danger { background: var(--failed); border-color: var(--failed); color: #fff; } input, select, textarea { background: var(--panel); border: 1px solid var(--line); padding: 6px 8px; width: 100%; } textarea { resize: vertical; min-height: 60px; } @@ -139,6 +141,9 @@ border-left: 3px solid var(--c, var(--accent)); padding: 10px 14px; cursor: grab; user-select: none; width: 210px; } .card:hover { border-color: var(--muted); border-left-color: var(--c); } .card.sel { outline: 2px solid var(--accent); outline-offset: -1px; } +.card.picked { outline: 2px solid var(--accent); outline-offset: 2px; background: color-mix(in srgb, var(--accent) 8%, var(--card, var(--panel))); } +#marquee { position: absolute; z-index: 4; pointer-events: none; border: 1px solid var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); } +main.can-pan, main.can-pan .card { cursor: grab; } .card.drop { outline: 2px dashed var(--accent); background: var(--drop); } .card .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .card .x { color: var(--muted); cursor: pointer; } @@ -236,6 +241,34 @@ .lout { margin: 10px 0 0; font-size: 14px; font-weight: 600; color: var(--s); line-height: 1.4; } .lnext { margin: 12px 0 0; font-size: 13px; color: var(--muted); } #revise-dialog { width: min(520px, 94vw); } +#get-started { width: 100%; margin-top: 8px; } +#empty-canvas { position: absolute; left: 50%; top: 40%; transform: translate(-50%, -50%); z-index: 2; display: grid; justify-items: center; gap: 8px; + padding: 24px 28px; background: var(--panel); border: 1px dashed var(--line); text-align: center; color: var(--muted); } +#empty-canvas[hidden] { display: none; } +#empty-canvas b { color: var(--text); font-size: 15px; } +#setup-dialog { width: min(720px, 94vw); } +#setup-dialog h4 { margin: 18px 0 6px; font-size: 14px; } +.su-tool { display: grid; grid-template-columns: 90px 1fr; gap: 2px 12px; padding: 8px 0; border-top: 1px solid var(--sunk); font-size: 13px; } +.su-tool .name { font-weight: 600; } +.su-tool .ok { color: var(--done); } .su-tool .no { color: var(--muted); } +.su-tool .cmds { grid-column: 2; display: grid; gap: 4px; } +.su-cmd { display: flex; align-items: center; gap: 8px; } +.su-cmd code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; background: var(--sunk); padding: 4px 8px; font: 12px var(--mono); } +.su-cmd span { width: 44px; color: var(--muted); font-size: 12px; } +.su-cmd button, .su-team button { padding: 3px 10px; font-size: 12px; } +#su-msg:empty { display: none; } +#su-teams { margin-top: 10px; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; } +.su-team { border: 1px solid var(--line); padding: 10px 12px; display: grid; gap: 4px; align-content: start; font-size: 13px; } +.su-team b { font-size: 14px; } +.su-team .hint { margin: 0; } +.su-team .row { margin-top: 6px; flex-wrap: wrap; gap: 6px; } +.su-team .row button { white-space: nowrap; } +#su-checks { display: flex; flex-wrap: wrap; gap: 6px; } +.suggest { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 6px; font-size: 12px; color: var(--muted); } +.suggest:empty { display: none; } +.suggest button, #su-checks span { font: 12px var(--mono); padding: 2px 8px; } +#su-checks span { border: 1px solid var(--line); background: var(--sunk); } +#su-checks i { font: 12px var(--sans); color: var(--muted); font-style: normal; margin-left: 6px; } .lout code, .lchk code { font: 12px var(--mono); } .lact { margin: 4px 0 0; font-size: 13px; color: var(--muted); line-height: 1.45; } .lchk { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 4px; } @@ -302,6 +335,7 @@ .help svg { width: 14px; height: 14px; flex: none; margin-top: 1px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } #queue { margin-top: 16px; } #q-actions { margin-top: 8px; } +#q-actions[hidden] { display: none; } #run-dialog { width: min(560px, 94vw); } #run-goals { margin: 12px 0 0; padding-left: 22px; display: grid; gap: 10px; max-height: 50vh; overflow: auto; } #run-goals li b { font-weight: 600; } @@ -422,6 +456,7 @@

        Agent types

        Technical Content Writerwriter
        +
        • Drag a type onto the canvas to add an agent
        • Drop it on an agent to put it under that agent
        • @@ -429,7 +464,8 @@

          Agent types

        • Drag a box's dots to another agent to connect them
        • Hover a connection and click its × to remove it
        • Double-click a box to rename it
        • -
        • Select a box and press Backspace to remove it
        • +
        • Drag on empty space (or ⌘/Ctrl+A) to select boxes; Backspace removes them
        • +
        • Hold Space and drag, or scroll, to move around
        {{if not .Project.Repo}}

        This project has no git repository yet. Set one in Settings to run agents.

        {{end}} @@ -441,7 +477,9 @@

        Agent types

        {{end}} -
        +
        + +