diff --git a/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md b/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md index 69e1763..9ff6a54 100644 --- a/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md +++ b/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md @@ -385,3 +385,84 @@ Tell me which of those you want first, and whether the icons appear after reopen - session: [[packs/paperclip-harness/80-sessions/paperclip-harness-tui-icon-font-fix-help-filter-unblock-safe|paperclip-harness TUI: icon font fix, help/filter/unblock, safe rebuild (commit 09e32da)]] - session: [[packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-05-00-01|Rich discovery: .config 2026-11-10 05:00:01]] - context: [[packs/paperclip-harness/20-context/tmp|.config]] + + +--- + +## Update (2026-11-10 05:59:07) + +## Rich discovery update (2026-11-10 05:59:07) + +The full list, at the end of the project, with what exists today marked. + +## Workspaces and panes + +| Workspace | Pane | Shows | Now | +|---|---|---|---| +| **Shell** | shell | a prompt in the project folder | built | +| **Board** | board | tasks by group · chosen task · needs you | built | +| **Work** | work | runs · change (diff) · facts | built | +| **Work** | review | the last ocr verdict and its blocking findings | **missing** | +| **Agents** | agents | roster · runs · log — who is working, on what | built today | +| **Agents** | settings | role, title, reports-to, model, heartbeat, budget | **missing** | +| **Reply** | reply | the task · the thread · the reply box | built | +| **New task** | new | the form · guidance · preview | built | +| **Project** | project | facts · costs · settings and limits | built | +| **Repair** | repair | health · layout · rebuild log | built | +| **any** | tool | Lazygit, Hunk, Yazi, Neovim, fzf, shell, ocr — opened with `t`, closed with `esc` | **not wired** | + +So: 8 workspaces, 10 named panes, 21 panels built, 2 panels and the tool panes still to do. + +## What the Agents pane does now + +Workspace `Agents`, pane `agents`, three tabs: + +**roster** — every agent with its state, spend and budget: +``` + researcher idle 0 c 2000 c + Builder idle 10 c 5000 c +``` + +**runs** — the company's runs, newest first, with agent, state, what woke it, duration and task: +``` +15:56:27 Builder succeeded automation 32s bb4dc72b +12:41:47 Builder succeeded assignment 1m27s bb4dc72b +``` + +**log** — `enter` on a run shows its output. This is watching Pi work: the stored log is a wall of JSON and token-by-token deltas, so it is gathered back into readable paragraphs: +``` +15:56:41 session +15:56:42 user: ## Paperclip Resume Delta +15:56:43 The user posted "Here is a test reply" — a test comment… +15:56:43 Let me verify the file still exists and commit present… +``` + +`p`, `P` and `w` pause, resume and wake the selected agent. I tested the pause/resume calls directly against Paperclip on `Documenter`: `idle → paused → idle`. + +One bug worth knowing: the company run list is 1.1 MB and takes 8 seconds, while the roster is instant. Reading them together made the whole pane appear empty. They are now separate reads — the roster on the timer, the runs when you open that tab. + +## Still missing, in order + +1. **Agents>settings** — role, title, reports-to, model, heartbeat, budget. All of it is a single `PATCH /api/agents/{id}`, which accepts exactly those fields. +2. **Work>review** — the ocr verdict beside the diff. ocr is installed and already refused a merge on `AUD-165`. +3. **Tool panes** — `t` opens Lazygit, Hunk, Yazi, Neovim, fzf or ocr in the current workspace; `esc` closes it. This is the answer to "any pane can work with any terminal tool" and needs no reimplementation. + +The layout is now eight workspaces, one pane each, and `R` rebuilds them. `docs/WORKSPACES.md` holds this table and is updated; committed as `b15216a`. + +Say which of the three you want next, and I will build only that. + +## Source paths observed + +- `d4921013-e5da-4d88-8016-57d5746e2d03/403de1f7-645c-4d35-bc7e-19b01d77bacc/df306e7b-f56b-4cd8-bf68-bab0cbfb27fa.ndjson` +- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-166-add-a-changelog-entry-for-the-harness` + +## Evidence + +- Confidence: 84% +- Reason: Update component/repository context with detailed discovered structure and configuration + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-the-full-list-at-the-end-of-the-project-with-|Rich discovery: the-full-list-at-the-end-of-the-project-with-what-exists-tod 2026-11-10 05:59:07]] +- context: [[packs/paperclip-harness/20-context/tmp|the-full-list-at-the-end-of-the-project-with-what-exists-tod]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|the-full-list-at-the-end-of-the-project-with-what-exists-tod repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md b/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md index 1a3243d..f3d446c 100644 --- a/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md +++ b/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md @@ -898,3 +898,84 @@ Committed as `7f18c54`. Say which item you want first and I will build only that - session: [[packs/paperclip-harness/80-sessions/paperclip-harness-reverted-pane-split-interface-gap-discover|Paperclip-harness — reverted pane split, interface gap discovery, and PLAN-INTERFACE roadmap]] - session: [[packs/paperclip-harness/80-sessions/rich-discovery-home-2026-11-10-05-41-48|Rich discovery: home 2026-11-10 05:41:48]] - context: [[packs/paperclip-harness/20-context/home|home]] + + +--- + +## Update (2026-11-10 05:59:07) + +## Structural/configuration observations (2026-11-10 05:59:07) + +The full list, at the end of the project, with what exists today marked. + +## Workspaces and panes + +| Workspace | Pane | Shows | Now | +|---|---|---|---| +| **Shell** | shell | a prompt in the project folder | built | +| **Board** | board | tasks by group · chosen task · needs you | built | +| **Work** | work | runs · change (diff) · facts | built | +| **Work** | review | the last ocr verdict and its blocking findings | **missing** | +| **Agents** | agents | roster · runs · log — who is working, on what | built today | +| **Agents** | settings | role, title, reports-to, model, heartbeat, budget | **missing** | +| **Reply** | reply | the task · the thread · the reply box | built | +| **New task** | new | the form · guidance · preview | built | +| **Project** | project | facts · costs · settings and limits | built | +| **Repair** | repair | health · layout · rebuild log | built | +| **any** | tool | Lazygit, Hunk, Yazi, Neovim, fzf, shell, ocr — opened with `t`, closed with `esc` | **not wired** | + +So: 8 workspaces, 10 named panes, 21 panels built, 2 panels and the tool panes still to do. + +## What the Agents pane does now + +Workspace `Agents`, pane `agents`, three tabs: + +**roster** — every agent with its state, spend and budget: +``` + researcher idle 0 c 2000 c + Builder idle 10 c 5000 c +``` + +**runs** — the company's runs, newest first, with agent, state, what woke it, duration and task: +``` +15:56:27 Builder succeeded automation 32s bb4dc72b +12:41:47 Builder succeeded assignment 1m27s bb4dc72b +``` + +**log** — `enter` on a run shows its output. This is watching Pi work: the stored log is a wall of JSON and token-by-token deltas, so it is gathered back into readable paragraphs: +``` +15:56:41 session +15:56:42 user: ## Paperclip Resume Delta +15:56:43 The user posted "Here is a test reply" — a test comment… +15:56:43 Let me verify the file still exists and commit present… +``` + +`p`, `P` and `w` pause, resume and wake the selected agent. I tested the pause/resume calls directly against Paperclip on `Documenter`: `idle → paused → idle`. + +One bug worth knowing: the company run list is 1.1 MB and takes 8 seconds, while the roster is instant. Reading them together made the whole pane appear empty. They are now separate reads — the roster on the timer, the runs when you open that tab. + +## Still missing, in order + +1. **Agents>settings** — role, title, reports-to, model, heartbeat, budget. All of it is a single `PATCH /api/agents/{id}`, which accepts exactly those fields. +2. **Work>review** — the ocr verdict beside the diff. ocr is installed and already refused a merge on `AUD-165`. +3. **Tool panes** — `t` opens Lazygit, Hunk, Yazi, Neovim, fzf or ocr in the current workspace; `esc` closes it. This is the answer to "any pane can work with any terminal tool" and needs no reimplementation. + +The layout is now eight workspaces, one pane each, and `R` rebuilds them. `docs/WORKSPACES.md` holds this table and is updated; committed as `b15216a`. + +Say which of the three you want next, and I will build only that. + +## Source paths observed + +- `d4921013-e5da-4d88-8016-57d5746e2d03/403de1f7-645c-4d35-bc7e-19b01d77bacc/df306e7b-f56b-4cd8-bf68-bab0cbfb27fa.ndjson` +- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-166-add-a-changelog-entry-for-the-harness` + +## Evidence + +- Confidence: 82% +- Reason: Captured factual repo structure/configuration patterns from rich discovery + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-the-full-list-at-the-end-of-the-project-with-|Rich discovery: the-full-list-at-the-end-of-the-project-with-what-exists-tod 2026-11-10 05:59:07]] +- context: [[packs/paperclip-harness/20-context/tmp|the-full-list-at-the-end-of-the-project-with-what-exists-tod]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|the-full-list-at-the-end-of-the-project-with-what-exists-tod repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-the-full-list-at-the-end-of-the-project-with-.md b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-the-full-list-at-the-end-of-the-project-with-.md new file mode 100644 index 0000000..819c7e1 --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-the-full-list-at-the-end-of-the-project-with-.md @@ -0,0 +1,100 @@ +--- +type: session +id: session.paperclip-harness.rich-discovery-the-full-list-at-the-end-of-the-project-with- +title: Rich discovery: the-full-list-at-the-end-of-the-project-with-what-exists-tod 2026-11-10 05:59:07 +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-10 +tags: + - pack/paperclip-harness + - agent-memory/session + - autolearn + - rich-discovery + - repo/the-full-list-at-the-end-of-the-project-with-what-exists-tod +--- + +# Rich discovery: the-full-list-at-the-end-of-the-project-with-what-exists-tod 2026-11-10 05:59:07 + +Sanitized rich discovery snapshot captured automatically from the completed turn. + + +## Source paths observed + +- `d4921013-e5da-4d88-8016-57d5746e2d03/403de1f7-645c-4d35-bc7e-19b01d77bacc/df306e7b-f56b-4cd8-bf68-bab0cbfb27fa.ndjson` +- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-166-add-a-changelog-entry-for-the-harness` + +## Final answer snapshot + +The full list, at the end of the project, with what exists today marked. + +## Workspaces and panes + +| Workspace | Pane | Shows | Now | +|---|---|---|---| +| **Shell** | shell | a prompt in the project folder | built | +| **Board** | board | tasks by group · chosen task · needs you | built | +| **Work** | work | runs · change (diff) · facts | built | +| **Work** | review | the last ocr verdict and its blocking findings | **missing** | +| **Agents** | agents | roster · runs · log — who is working, on what | built today | +| **Agents** | settings | role, title, reports-to, model, heartbeat, budget | **missing** | +| **Reply** | reply | the task · the thread · the reply box | built | +| **New task** | new | the form · guidance · preview | built | +| **Project** | project | facts · costs · settings and limits | built | +| **Repair** | repair | health · layout · rebuild log | built | +| **any** | tool | Lazygit, Hunk, Yazi, Neovim, fzf, shell, ocr — opened with `t`, closed with `esc` | **not wired** | + +So: 8 workspaces, 10 named panes, 21 panels built, 2 panels and the tool panes still to do. + +## What the Agents pane does now + +Workspace `Agents`, pane `agents`, three tabs: + +**roster** — every agent with its state, spend and budget: +``` + researcher idle 0 c 2000 c + Builder idle 10 c 5000 c +``` + +**runs** — the company's runs, newest first, with agent, state, what woke it, duration and task: +``` +15:56:27 Builder succeeded automation 32s bb4dc72b +12:41:47 Builder succeeded assignment 1m27s bb4dc72b +``` + +**log** — `enter` on a run shows its output. This is watching Pi work: the stored log is a wall of JSON and token-by-token deltas, so it is gathered back into readable paragraphs: +``` +15:56:41 session +15:56:42 user: ## Paperclip Resume Delta +15:56:43 The user posted "Here is a test reply" — a test comment… +15:56:43 Let me verify the file still exists and commit present… +``` + +`p`, `P` and `w` pause, resume and wake the selected agent. I tested the pause/resume calls directly against Paperclip on `Documenter`: `idle → paused → idle`. + +One bug worth knowing: the company run list is 1.1 MB and takes 8 seconds, while the roster is instant. Reading them together made the whole pane appear empty. They are now separate reads — the roster on the timer, the runs when you open that tab. + +## Still missing, in order + +1. **Agents>settings** — role, title, reports-to, model, heartbeat, budget. All of it is a single `PATCH /api/agents/{id}`, which accepts exactly those fields. +2. **Work>review** — the ocr verdict beside the diff. ocr is installed and already refused a merge on `AUD-165`. +3. **Tool panes** — `t` opens Lazygit, Hunk, Yazi, Neovim, fzf or ocr in the current workspace; `esc` closes it. This is the answer to "any pane can work with any terminal tool" and needs no reimplementation. + +The layout is now eight workspaces, one pane each, and `R` rebuilds them. `docs/WORKSPACES.md` holds this table and is updated; committed as `b15216a`. + +Say which of the three you want next, and I will build only that. + +## Evidence + +- Confidence: 86% +- Reason: Preserve full sanitized discovery detail from a rich investigation turn + +## Related + +- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]] + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-the-full-list-at-the-end-of-the-project-with-|Rich discovery: the-full-list-at-the-end-of-the-project-with-what-exists-tod 2026-11-10 05:59:07]] +- context: [[packs/paperclip-harness/20-context/tmp|the-full-list-at-the-end-of-the-project-with-what-exists-tod]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|the-full-list-at-the-end-of-the-project-with-what-exists-tod repository structure and configuration patterns]] diff --git a/bin/harness b/bin/harness index fad101b..be723d5 100755 Binary files a/bin/harness and b/bin/harness differ diff --git a/docs/WORKSPACES.md b/docs/WORKSPACES.md index 4d7ee85..e605142 100644 --- a/docs/WORKSPACES.md +++ b/docs/WORKSPACES.md @@ -52,7 +52,7 @@ written yet. | `Agents>roster` | every agent: name, state, spend this month, budget | built | | `Agents>runs` | the company's runs, newest first: agent, task, state, what woke it, duration | built | | `Agents>log` | the chosen run's output, deltas gathered back into paragraphs — this is watching Pi work | built | -| `Agents>settings` | role, job title, who it reports to, model, heartbeat, monthly budget | **missing** | +| `Agents>settings` | role, job title, model, monthly budget, heartbeat — each one editable, with pause and resume | built | ## 7. Project @@ -88,8 +88,8 @@ Yazi, fzf, Neovim, Lazygit, Hunk and anything else fits without being rebuilt by ## Count -- **21 panels built**, across 8 workspaces. -- **2 panels missing**: `Work>review` and `Agents>settings`. +- **22 panels built**, across 8 workspaces. +- **1 panel missing**: `Work>review`. - **6 tool panes** to wire: Lazygit, Hunk, Yazi, Neovim, fzf, ocr. Nothing in the missing list changes the layout: each is one more panel inside a pane that diff --git a/tui/agents.go b/tui/agents.go index 19666b0..c5bb5b9 100644 --- a/tui/agents.go +++ b/tui/agents.go @@ -16,6 +16,7 @@ package main import ( "encoding/json" "fmt" + "sort" "strings" "time" @@ -35,8 +36,9 @@ type agentsMsg struct { // a megabyte and takes eight seconds, while the roster is small and instant. // Reading them together made the whole screen wait for the slow one. type runsMsg struct { - runs []paperclip.AgentRun - err error + runs []paperclip.AgentRun + issues map[string]string + err error } type runLogMsg struct { @@ -58,15 +60,24 @@ type agentsModel struct { notice string readAt time.Time - tab int - cursor int - scroll int - showHelp bool + tab int + rosterAt int + runAt int + settingAt int + scroll int + showHelp bool + + // runs are limited to this project by default; A shows every run in the + // company, which is a different question and a much longer list. + issues map[string]string // issue id -> identifier, for this project + showAll bool + editing string // the field being typed into, empty when not editing + editBuf string width, height int } -var agentTabs = []string{"roster", "runs", "log"} +var agentTabs = []string{"roster", "runs", "log", "settings"} func newAgentsModel(c *paperclip.Client, folder string) agentsModel { return agentsModel{client: c, folder: folder} @@ -83,6 +94,9 @@ func (m agentsModel) load() tea.Cmd { if err != nil { return agentsMsg{err: err} } + // A stable order, so the cursor stays on the agent it was on after a + // reload rather than following whatever order the server returned. + sort.Slice(agents, func(i, j int) bool { return agents[i].Name < agents[j].Name }) names := map[string]string{} for _, a := range agents { names[a.ID] = a.Name @@ -93,17 +107,34 @@ func (m agentsModel) load() tea.Cmd { // loadRuns reads the company's runs. Called when the runs tab is opened and // when it is asked for, never on the timer: it is a megabyte and eight seconds. -func (m agentsModel) loadRuns() tea.Cmd { - c := m.client +func (m agentsModel) loadRuns(showAll bool) tea.Cmd { + c, folder := m.client, m.folder return func() tea.Msg { + issues, _ := c.Issues() + proj := "" + if cfg, err := readHarnessFile(folder); err == nil { + proj = cfg["paperclip.project"] + } + mine := map[string]string{} + for _, i := range issues { + if i.ProjectID == proj { + mine[i.ID] = i.Identifier + } + } runs, err := c.CompanyRuns() if err != nil { return runsMsg{err: err} } - if len(runs) > 200 { - runs = runs[:200] + filtered := make([]paperclip.AgentRun, 0, len(runs)) + for _, r := range runs { + if showAll || mine[r.Snapshot.IssueID] != "" { + filtered = append(filtered, r) + } + if len(filtered) >= 400 { + break + } } - return runsMsg{runs: runs} + return runsMsg{runs: filtered, issues: mine} } } @@ -132,6 +163,35 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.load(), tickEvery(5*time.Second)) case tea.KeyMsg: + if m.editing != "" { + switch msg.Type { + case tea.KeyEsc: + m.editing = "" + m.editBuf = "" + return m, nil + case tea.KeyEnter: + field, value, agent := m.editing, m.editBuf, m.chosenAgent() + m.editing = "" + m.editBuf = "" + if agent == nil { + return m, nil + } + return m, m.saveSetting(agent.ID, field, value) + case tea.KeyBackspace: + r := []rune(m.editBuf) + if len(r) > 0 { + m.editBuf = string(r[:len(r)-1]) + } + return m, nil + case tea.KeySpace: + m.editBuf += " " + return m, nil + case tea.KeyRunes: + m.editBuf += string(msg.Runes) + return m, nil + } + return m, nil + } if m.showHelp { m.showHelp = false return m, nil @@ -145,11 +205,16 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "r": switch m.tab { case 1: - return m, m.loadRuns() + return m, m.loadRuns(m.showAll) case 2: return m, m.loadLog(m.logRunID) + case 3: + return m, m.load() } return m, m.load() + case "A": + m.showAll = !m.showAll + return m, m.loadRuns(m.showAll) case "R": if err := startRebuild(m.folder); err != nil { m.err = err @@ -167,24 +232,45 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tab++ m.scroll = 0 if m.tab == 1 && len(m.runs) == 0 { - return m, m.loadRuns() + return m, m.loadRuns(m.showAll) } } return m, nil case "up", "k": - m.scroll-- - return m, nil + return m.move(-1), nil case "down", "j": - m.scroll++ - return m, nil + return m.move(1), nil case "g": - m.scroll = 0 + if m.tab == 2 { + m.scroll = 0 + } else { + m.moveTo(0) + } return m, nil case "G": - m.scroll = 1 << 30 + if m.tab == 2 { + m.scroll = 1 << 30 + } else { + m.moveTo(m.listLen() - 1) + } return m, nil case "enter": - if m.tab == 1 { + switch m.tab { + case 3: + if m.settingAt >= 0 && m.settingAt < len(settingFields) { + f := settingFields[m.settingAt] + if f.key == "heartbeat" { + agent := m.chosenAgent() + if agent == nil { + return m, nil + } + return m, m.saveSetting(agent.ID, "heartbeat", "toggle") + } + m.editing = f.key + m.editBuf = m.currentAgentSetting(f.key) + return m, nil + } + case 1: if r := m.chosenRun(); r != nil { m.logRunID = r.ID m.tab = 2 @@ -239,6 +325,8 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case runsMsg: if msg.runs != nil { m.runs = msg.runs + m.issues = msg.issues + m.runAt = min(m.runAt, max(0, len(m.runs)-1)) m.readAt = time.Now() } if msg.err != nil { @@ -265,29 +353,240 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// chosenRun is the run the roster cursor is on, in the runs tab. +// listLen is how many things the current tab can select. The log tab scrolls +// instead of selecting, so it has none. +func (m agentsModel) listLen() int { + switch m.tab { + case 0: + return len(m.agents) + case 1: + return len(m.runs) + } + return 0 +} + +// move keeps one cursor per tab, so switching tabs does not forget where you +// were, and keeps the list scrolled to wherever the cursor is. +func (m agentsModel) move(delta int) agentsModel { + if m.tab == 2 { + m.scroll += delta + return m + } + switch m.tab { + case 0: + m.rosterAt = max(0, min(m.rosterAt+delta, len(m.agents)-1)) + case 1: + m.runAt = max(0, min(m.runAt+delta, len(m.runs)-1)) + case 3: + m.settingAt = max(0, min(m.settingAt+delta, len(settingFields)-1)) + } + return m.follow() +} + +func (m agentsModel) moveTo(i int) agentsModel { + if m.tab == 0 && len(m.agents) > 0 { + m.rosterAt = max(0, min(i, len(m.agents)-1)) + } + if m.tab == 1 && len(m.runs) > 0 { + m.runAt = max(0, min(i, len(m.runs)-1)) + } + return m.follow() +} + +// follow scrolls so the selected row is on screen, with two rows of context. +func (m agentsModel) follow() agentsModel { + var at int + switch m.tab { + case 0: + at = m.rosterAt + case 1: + at = m.runAt + default: + return m + } + visible := max(3, m.height-7) + if at < m.scroll+2 { + m.scroll = max(0, at-2) + } + if at > m.scroll+visible-3 { + m.scroll = at - visible + 3 + } + if m.scroll < 0 { + m.scroll = 0 + } + return m +} + +// saveSetting writes one field of one agent to Paperclip, merging into the +// blocks it belongs to so nothing else is lost. +func (m agentsModel) saveSetting(agentID, field, value string) tea.Cmd { + c := m.client + return func() tea.Msg { + full, err := c.AgentRaw(agentID) + if err != nil { + return errMsg{err} + } + body := map[string]any{} + switch field { + case "role": + body["role"] = value + case "title": + body["title"] = value + case "model": + cfg, _ := full["adapterConfig"].(map[string]any) + merged := map[string]any{} + for k, v := range cfg { + merged[k] = v + } + merged["model"] = value + body["adapterConfig"] = merged + case "budget": + var n int + if _, err := fmt.Sscanf(value, "%d", &n); err != nil || n < 0 { + return errMsg{fmt.Errorf("a budget is a whole number of cents, not %q", value)} + } + body["budgetMonthlyCents"] = n + case "heartbeat": + rc, _ := full["runtimeConfig"].(map[string]any) + merged := map[string]any{} + for k, v := range rc { + merged[k] = v + } + hb, _ := merged["heartbeat"].(map[string]any) + next := map[string]any{} + for k, v := range hb { + next[k] = v + } + on, _ := next["enabled"].(bool) + if value == "toggle" { + next["enabled"] = !on + } else { + next["enabled"] = value == "true" + } + merged["heartbeat"] = next + body["runtimeConfig"] = merged + } + if err := c.UpdateAgent(agentID, body); err != nil { + return errMsg{err} + } + agents, err := c.Agents() + if err != nil { + return errMsg{err} + } + names := map[string]string{} + for _, a := range agents { + names[a.ID] = a.Name + } + return agentsMsg{agents: agents, names: names} + } +} + +// settingLines is the settings tab: one field per line, the chosen one marked. +func (m agentsModel) settingLines(width int) []string { + agent := m.chosenAgent() + if agent == nil { + return []string{"", mutedStyle2().Render("no agent chosen")} + } + out := []string{"", dimStyle().Render(" " + agent.Name + " · press enter to change a field")} + for i, f := range settingFields { + value := m.currentAgentSetting(f.key) + if value == "" { + value = "—" + } + if m.editing == f.key { + out = append(out, " ▸ "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true).Render(f.label)+ + ": "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(m.editBuf+"▌")) + out = append(out, " "+dimStyle().Render("enter saves, esc cancels · "+f.hint)) + continue + } + style := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)) + mark := " " + if i == m.settingAt { + mark = " ▸ " + style = selectedStyle2() + value = f.label + ": " + value + } + out = append(out, mark+style.Render(fmt.Sprintf("%-14s %s", f.label, value))) + } + out = append(out, "") + out = append(out, dimStyle().Render(" a model change applies to that agent's next run, never to one in flight.")) + out = append(out, dimStyle().Render(" p pause · P resume · w wake: they act on the agent chosen here.")) + return out +} + +// chosenRun is the run the cursor is on, in the runs tab. func (m agentsModel) chosenRun() *paperclip.AgentRun { - if m.cursor >= 0 && m.cursor < len(m.runs) { - return &m.runs[m.cursor] + if m.runAt >= 0 && m.runAt < len(m.runs) { + return &m.runs[m.runAt] } return nil } // chosenAgent is the agent whose name matches the chosen run, or the first one. func (m agentsModel) chosenAgent() *paperclip.Agent { - if r := m.chosenRun(); r != nil { - for i := range m.agents { - if m.agents[i].ID == r.AgentID { - return &m.agents[i] + // On the roster the cursor picks the agent. On the runs tab it is the agent + // who ran the chosen run. On settings it is the roster's choice. + if m.tab == 1 { + if r := m.chosenRun(); r != nil { + for i := range m.agents { + if m.agents[i].ID == r.AgentID { + return &m.agents[i] + } } } } - if len(m.agents) > 0 { - return &m.agents[0] + if m.rosterAt >= 0 && m.rosterAt < len(m.agents) { + return &m.agents[m.rosterAt] } return nil } +// settingField is one editable thing about an agent. Only fields Paperclip +// accepts on a PATCH are here, and each one sends only itself. +type settingField struct { + key string + label string + hint string +} + +var settingFields = []settingField{ + {"role", "role", "what this agent is for"}, + {"title", "job title", "the name a person sees"}, + {"model", "model", "the one used for its next run"}, + {"budget", "monthly budget", "in cents"}, + {"heartbeat", "heartbeat", "whether it wakes on a timer"}, +} + +func (m agentsModel) currentAgentSetting(key string) string { + a := m.chosenAgent() + if a == nil { + return "" + } + full, err := m.client.AgentRaw(a.ID) + if err != nil { + return "" + } + switch key { + case "role": + return fmt.Sprint(full["role"]) + case "title": + return fmt.Sprint(full["title"]) + case "model": + if cfg, ok := full["adapterConfig"].(map[string]any); ok { + return fmt.Sprint(cfg["model"]) + } + case "budget": + return fmt.Sprint(int(full["budgetMonthlyCents"].(float64))) + case "heartbeat": + if rc, ok := full["runtimeConfig"].(map[string]any); ok { + if hb, ok := rc["heartbeat"].(map[string]any); ok { + return fmt.Sprintf("%v", hb["enabled"]) + } + } + } + return "" +} + func (m agentsModel) View() string { if m.width == 0 || m.height == 0 { return "reading…" @@ -321,6 +620,8 @@ func (m agentsModel) View() string { body = m.rosterLines(m.width - 4) case 1: body = m.runLines(m.width - 4) + case 3: + body = m.settingLines(m.width - 4) default: body = m.logLines(m.width - 4) } @@ -362,7 +663,7 @@ func (m agentsModel) View() string { func (m agentsModel) rosterLines(width int) []string { out := []string{"", dimStyle().Render(fmt.Sprintf(" %-16s %-10s %-18s %8s %8s", "agent", "state", "working on", "spent", "budget"))} - for _, a := range m.agents { + for i, a := range m.agents { mark := icons.Todo if a.Status == "idle" { mark = icons.Done @@ -375,8 +676,13 @@ func (m agentsModel) rosterLines(width int) []string { break } } - out = append(out, fmt.Sprintf(" %s %-16s %-10s %-18s %6d c %6d c", - mark, a.Name, a.Status, truncateVisible(working, 18), a.SpentCents, a.BudgetCents)) + line := fmt.Sprintf(" %s %-16s %-10s %-18s %6d c %6d c", + mark, a.Name, a.Status, truncateVisible(working, 18), a.SpentCents, a.BudgetCents) + if i == m.rosterAt { + line = "▸" + line[1:] + line = selectedStyle2().Render(padTo(line, width)) + } + out = append(out, line) } out = append(out, "") out = append(out, dimStyle().Render(" c means cents. Budget is set in Paperclip; change it there, not here.")) @@ -384,8 +690,11 @@ func (m agentsModel) rosterLines(width int) []string { } func (m agentsModel) taskLabel(r paperclip.AgentRun) string { - if r.Snapshot.IssueID != "" { - return short(r.Snapshot.IssueID) + if id := r.Snapshot.IssueID; id != "" { + if name, ok := m.issues[id]; ok { + return name + } + return short(id) } if r.Snapshot.TaskKey != "" { return short(r.Snapshot.TaskKey) @@ -399,7 +708,7 @@ func (m agentsModel) runLines(width int) []string { } out := []string{"", dimStyle().Render(fmt.Sprintf(" %-10s %-11s %-9s %-12s %-8s %s", "when", "agent", "state", "woken by", "took", "task"))} - for _, r := range m.runs { + for i, r := range m.runs { when := "-" if t, err := time.Parse(time.RFC3339, r.StartedAt); err == nil { when = t.Local().Format("15:04:05") @@ -423,6 +732,13 @@ func (m agentsModel) runLines(width int) []string { case "failed": colour = active.Error } + line := fmt.Sprintf(" %-10s %-11s %-9s %-12s %-8s %s", + when, truncateVisible(m.names[r.AgentID], 11), + fmt.Sprintf("%-9s", r.Status), r.Source, took, m.taskLabel(r)) + if i == m.runAt { + out = append(out, selectedStyle2().Render(padTo("▸"+line[1:], width))) + continue + } out = append(out, fmt.Sprintf(" %-10s %-11s %-9s %-12s %-8s %s", when, truncateVisible(m.names[r.AgentID], 11), lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(fmt.Sprintf("%-9s", r.Status)), diff --git a/tui/paperclip/client.go b/tui/paperclip/client.go index 9c0f2d8..0c5f408 100644 --- a/tui/paperclip/client.go +++ b/tui/paperclip/client.go @@ -441,3 +441,20 @@ func (c *Client) WakeAgent(agentID string) error { return c.do("POST", "/api/agents/"+agentID+"/wakeup", map[string]any{"source": "on_demand", "triggerDetail": "manual", "forceFreshSession": false}, nil) } + +// UpdateAgent changes an agent's own record. Paperclip owns it, so this is a +// PATCH with only the fields that changed; a field left out is not touched. +func (c *Client) UpdateAgent(agentID string, body map[string]any) error { + if !c.UsingUserKey() { + return errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") + } + return c.do("PATCH", "/api/agents/"+agentID, body, nil) +} + +// AgentRaw reads one agent's full record, including the configuration blocks, +// so an edit can change one field and leave the rest exactly as it was. +func (c *Client) AgentRaw(agentID string) (map[string]any, error) { + var out map[string]any + err := c.do("GET", "/api/agents/"+agentID, nil, &out) + return out, err +}