package main // The Agents workspace: who is working, on what, and for how much. // // This is the view the project was missing. Paperclip already records every // agent, every run and every run's output; nothing here is invented, and // nothing here is stored. Three panels in one pane, switched with tabs: // // roster every agent, its state, its spend and its budget // runs every run in the company, newest first // log the output of the chosen run — watching Pi work // // Pause, resume and wake are the only writes, and each one is a single call to // Paperclip, which owns the agent. import ( "encoding/json" "fmt" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "paperclip-harness/tui/paperclip" ) type agentsMsg struct { agents []paperclip.Agent names map[string]string err error } // runsMsg is separate from agentsMsg on purpose: the company's run list is over // 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 issues map[string]string err error } type runLogMsg struct { runID string text string err error } type agentsModel struct { client *paperclip.Client folder string agents []paperclip.Agent runs []paperclip.AgentRun names map[string]string logRunID string logText string err error notice string readAt time.Time 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", "settings"} func newAgentsModel(c *paperclip.Client, folder string) agentsModel { return agentsModel{client: c, folder: folder} } func (m agentsModel) Init() tea.Cmd { return tea.Batch(m.load(), tickEvery(5*time.Second)) } func (m agentsModel) load() tea.Cmd { c := m.client return func() tea.Msg { agents, err := c.Agents() 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 } return agentsMsg{agents: agents, names: names} } } // 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(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} } 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: filtered, issues: mine} } } func (m agentsModel) loadLog(runID string) tea.Cmd { c := m.client return func() tea.Msg { if runID == "" { return runLogMsg{} } // The tail, not the whole thing: a run log here is tens of thousands of // lines and only the end says what is happening now. // Read the whole log: the stored form is one JSON envelope, and a // partial read cuts the envelope in half and makes it unreadable. text, err := c.RunLog(runID, 8<<20) return runLogMsg{runID: runID, text: text, err: err} } } func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height return m, nil case tickMsg: 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 } switch msg.String() { case "?": m.showHelp = true return m, nil case "q", "ctrl+c": return m, tea.Quit case "r": switch m.tab { case 1: 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 return m, nil } return m, tea.Quit case "[", "h": if m.tab > 0 { m.tab-- m.scroll = 0 } return m, nil case "]", "l", "tab": if m.tab < len(agentTabs)-1 { m.tab++ m.scroll = 0 if m.tab == 1 && len(m.runs) == 0 { return m, m.loadRuns(m.showAll) } } return m, nil case "up", "k": return m.move(-1), nil case "down", "j": return m.move(1), nil case "g": if m.tab == 2 { m.scroll = 0 } else { m.moveTo(0) } return m, nil case "G": if m.tab == 2 { m.scroll = 1 << 30 } else { m.moveTo(m.listLen() - 1) } return m, nil case "enter": 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 m.scroll = 1 << 30 return m, m.loadLog(r.ID) } } return m, nil case "p", "P", "w": agent := m.chosenAgent() if agent == nil { return m, nil } c := m.client action := msg.String() return m, func() tea.Msg { var err error switch action { case "p": err = c.PauseAgent(agent.ID) case "P": err = c.ResumeAgent(agent.ID) case "w": err = c.WakeAgent(agent.ID) } if err != nil { return errMsg{err} } agents, err := c.Agents() if err != nil { return errMsg{err} } return agentsMsg{agents: agents} } case "esc": return m, nil } case agentsMsg: if msg.agents != nil { m.agents = msg.agents } m.names = msg.names if msg.err != nil { m.err = msg.err } else { m.err = nil } m.readAt = time.Now() return m, nil 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 { m.err = msg.err } else { m.err = nil } return m, nil case runLogMsg: if msg.runID != "" { m.logRunID = msg.runID } m.logText = msg.text if msg.err != nil { m.err = msg.err } return m, nil case errMsg: m.err = msg.err return m, nil } return m, nil } // 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.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 { // 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 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…" } if m.width < 80 || m.height < 24 { return lipgloss.NewStyle().Width(m.width).Height(m.height).Render( fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height)) } if m.showHelp { return helpOverlay("agents", [][2]string{ {"[ ] h l", "roster, runs, log"}, {"enter", "open the chosen run's log"}, {"p / P", "pause / resume the agent"}, {"w", "wake the agent now"}, {"↑ ↓ j k", "scroll"}, {"r / R", "read again / rebuild"}, {"q", "leave"}, }, m.width, m.height) } head := titleStyle.Render("Agents") + mutedStyle2().Render(" · who is working, on what, for how much") state := "read " + m.readAt.Format("15:04:05") if m.readAt.IsZero() { state = "reading…" } var body []string switch m.tab { case 0: 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) } p := panel{title: fmt.Sprintf("agents · %d agent(s), %d run(s)", len(m.agents), len(m.runs)), width: m.width, height: m.height - 2, tabs: agentTabs, active: m.tab, focused: true} visible := p.bodyHeight() maxScroll := max(0, len(body)-visible) if m.scroll > maxScroll { m.scroll = maxScroll } if m.scroll < 0 { m.scroll = 0 } from := m.scroll to := min(len(body), from+visible) footer := hintLine([]string{ keyHint("[ ]", "roster, runs, log"), keyHint("enter", "open a run"), keyHint("p P w", "pause, resume, wake"), keyHint("r", "read again"), keyHint("?", "keys"), }, m.width) if m.notice != "" { footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice) } if m.err != nil { footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(truncateVisible("error: "+m.err.Error(), m.width)) } gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1) return strings.Join([]string{ head + strings.Repeat(" ", gap) + mutedStyle2().Render(state), p.render(body[from:to]), footer, }, "\n") } 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 i, a := range m.agents { mark := icons.Todo if a.Status == "idle" { mark = icons.Done } working := "—" for _, r := range m.runs { if r.AgentID == a.ID && r.Status == "running" { working = m.taskLabel(r) mark = icons.Running break } } 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.")) return out } func (m agentsModel) taskLabel(r paperclip.AgentRun) string { 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) } return "—" } func (m agentsModel) runLines(width int) []string { if len(m.runs) == 0 { return []string{"", mutedStyle2().Render("no runs recorded")} } out := []string{"", dimStyle().Render(fmt.Sprintf(" %-10s %-11s %-9s %-12s %-8s %s", "when", "agent", "state", "woken by", "took", "task"))} 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") } took := "-" if r.FinishedAt != "" { if s, err := time.Parse(time.RFC3339, r.StartedAt); err == nil { if f, err := time.Parse(time.RFC3339, r.FinishedAt); err == nil { took = f.Sub(s).Round(time.Second).String() } } } else if r.Status == "running" { took = "running" } colour := active.Text switch r.Status { case "running": colour = active.Info case "succeeded": colour = active.Success 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)), r.Source, took, m.taskLabel(r))) } return out } // logEntry is one readable line out of a run log. type logEntry struct { when string stream string line string } // decodeRunLog turns what Paperclip stores into something a person can read. // // A run log is not lines of prose: it is an envelope around newline-separated // JSON, and most of those are token-by-token streaming events. Printed raw they // are unreadable and they redraw the panel. So deltas are gathered back into // paragraphs, and everything else becomes one labelled line. func decodeRunLog(raw string) []logEntry { var envelope struct { Content string `json:"content"` } content := raw if json.Unmarshal([]byte(raw), &envelope) == nil && envelope.Content != "" { content = envelope.Content } var out []logEntry var buf strings.Builder bufWhen := "" flush := func() { text := strings.TrimSpace(cleanText(buf.String())) buf.Reset() if text == "" { return } for _, para := range strings.Split(text, "\n") { para = strings.TrimSpace(para) if para != "" { out = append(out, logEntry{when: bufWhen, stream: "thinking", line: para}) } } } for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if line == "" { continue } var rec struct { TS string `json:"ts"` Stream string `json:"stream"` Chunk string `json:"chunk"` } text := line when := "" if json.Unmarshal([]byte(line), &rec) == nil && rec.Chunk != "" { text, when = rec.Chunk, shortTime(rec.TS) } var ev struct { Type string `json:"type"` Delta string `json:"delta"` Message struct { Role string `json:"role"` Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` } `json:"message"` AssistantMessageEvent struct { Type string `json:"type"` Delta string `json:"delta"` } `json:"assistantMessageEvent"` } if json.Unmarshal([]byte(text), &ev) != nil { // Not JSON we know: show it as a line, trimmed. if t := strings.TrimSpace(cleanText(text)); t != "" { out = append(out, logEntry{when: when, line: t}) } continue } kind := ev.AssistantMessageEvent.Type if ev.Type == "message_update" && strings.HasSuffix(kind, "_delta") { if bufWhen == "" { bufWhen = when } buf.WriteString(ev.AssistantMessageEvent.Delta) continue } // Anything else ends the paragraph that was being gathered. flush() bufWhen = when switch ev.Type { case "message_start", "message_end": role := ev.Message.Role if role == "" { continue } for _, c := range ev.Message.Content { t := strings.TrimSpace(cleanText(c.Text)) if t == "" { continue } if i := strings.Index(t, "\n"); i > 0 { t = t[:i] } out = append(out, logEntry{when: when, stream: "prompt", line: role + ": " + t}) break } case "tool_call", "tool_result", "tool_use": out = append(out, logEntry{when: when, stream: "tool", line: ev.Type}) case "turn_start", "turn_end", "session", "agent_start", "agent_end": out = append(out, logEntry{when: when, stream: "event", line: ev.Type}) default: if kind != "" { out = append(out, logEntry{when: when, stream: "event", line: kind}) } } } flush() if len(out) == 0 { for _, line := range strings.Split(strings.TrimRight(content, "\n"), "\n") { if t := strings.TrimSpace(cleanText(line)); t != "" { out = append(out, logEntry{line: t}) } } } return out } // shortTime is the clock time of a log line, in the reader's timezone. func shortTime(ts string) string { if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { return t.Local().Format("15:04:05") } return "" } // cleanText removes escape sequences and control characters, so a log cannot // repaint the panel it is drawn in. func cleanText(s string) string { var b strings.Builder skip := false for _, r := range s { if r == 0x1b { skip = true continue } if skip { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { skip = false } continue } if r == '\t' { b.WriteString(" ") continue } if r < 0x20 && r != '\n' { continue } b.WriteRune(r) } return b.String() } func (m agentsModel) logLines(width int) []string { if m.logRunID == "" { return []string{"", mutedStyle2().Render("choose a run on the runs tab, then press enter")} } if m.logText == "" { return []string{"", mutedStyle2().Render("no output recorded for this run")} } var out []string out = append(out, "", dimStyle().Render(" run "+short(m.logRunID))) entries := decodeRunLog(m.logText) if len(entries) > 2000 { entries = entries[len(entries)-2000:] } for _, e := range entries { if lipgloss.Width(e.line) > width { e.line = truncateVisible(e.line, width) } colour := active.Text switch e.stream { case "thinking": colour = active.Muted case "prompt": colour = active.Info case "tool": colour = active.Warning case "event": colour = active.Overlay } out = append(out, " "+dimStyle().Render(e.when)+" "+ lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(e.line)) } return out }