Agents workspace: roster, runs, and a readable run log; layout now eight workspaces

This commit is contained in:
Sam
2026-11-10 16:58:59 +11:00
parent 7f18c548fe
commit b15216aa7b
14 changed files with 1339 additions and 2 deletions

633
tui/agents.go Normal file
View File

@@ -0,0 +1,633 @@
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"
"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
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
cursor int
scroll int
showHelp bool
width, height int
}
var agentTabs = []string{"roster", "runs", "log"}
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}
}
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() tea.Cmd {
c := m.client
return func() tea.Msg {
runs, err := c.CompanyRuns()
if err != nil {
return runsMsg{err: err}
}
if len(runs) > 200 {
runs = runs[:200]
}
return runsMsg{runs: runs}
}
}
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.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()
case 2:
return m, m.loadLog(m.logRunID)
}
return m, m.load()
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()
}
}
return m, nil
case "up", "k":
m.scroll--
return m, nil
case "down", "j":
m.scroll++
return m, nil
case "g":
m.scroll = 0
return m, nil
case "G":
m.scroll = 1 << 30
return m, nil
case "enter":
if m.tab == 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.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
}
// chosenRun is the run the roster 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]
}
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]
}
}
}
if len(m.agents) > 0 {
return &m.agents[0]
}
return nil
}
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)
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 _, 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
}
}
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))
}
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 r.Snapshot.IssueID != "" {
return short(r.Snapshot.IssueID)
}
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 _, 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
}
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
}

View File

@@ -289,3 +289,12 @@ func helpOverlay(title string, entries [][2]string, w, h int) string {
p := panel{title: title, width: boxW, height: boxH, focused: true}
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, p.render(lines))
}
// short is the first eight characters of an identifier: enough to recognise a
// run or a task in a column, never enough to confuse two of them on one screen.
func short(id string) string {
if len(id) <= 8 {
return id
}
return id[:8]
}

View File

@@ -19,7 +19,7 @@ import (
const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc"
func main() {
screen := flag.String("screen", "board", "which screen to run: board, work, reply, new, project, repair")
screen := flag.String("screen", "board", "which screen to run: board, work, reply, new, agents, project, repair")
folder := flag.String("folder", "", "project folder, for the board screen (defaults to the current directory)")
light := flag.String("theme", "", "colour variant: dark, light, or empty to follow the terminal")
ascii := flag.Bool("ascii", false, "plain characters instead of Nerd Font icons")
@@ -54,6 +54,13 @@ func main() {
}
m = newBoardModel(client, proj, dir)
case "agents":
dir := *folder
if dir == "" {
dir, _ = os.Getwd()
}
m = newAgentsModel(client, dir)
case "project":
dir := *folder
if dir == "" {
@@ -127,7 +134,7 @@ func main() {
m = newReplyModel(client, dir, id)
default:
fmt.Fprintf(os.Stderr, "unknown screen %q: use board, work, reply, new, project or repair\n", *screen)
fmt.Fprintf(os.Stderr, "unknown screen %q: use board, work, reply, new, agents, project or repair\n", *screen)
os.Exit(1)
}

View File

@@ -373,3 +373,71 @@ func (c *Client) CostSummary(issueID string) (Cost, error) {
func (c *Client) Health() error {
return c.do("GET", "/api/health", nil, nil)
}
// AgentRun is one heartbeat run in the company, whichever agent ran it.
type AgentRun struct {
ID string `json:"id"`
AgentID string `json:"agentId"`
Status string `json:"status"`
Source string `json:"invocationSource"`
StartedAt string `json:"createdAt"`
FinishedAt string `json:"finishedAt"`
ErrorCode string `json:"errorCode"`
LastOutputAt string `json:"lastOutputAt"`
LastOutputBytes int `json:"lastOutputBytes"`
// The task a run worked on lives inside the run's context snapshot, not on
// the run itself.
Snapshot struct {
IssueID string `json:"issueId"`
TaskKey string `json:"taskKey"`
} `json:"contextSnapshot"`
}
// Runs returns every run in the company, newest first.
func (c *Client) CompanyRuns() ([]AgentRun, error) {
var out []AgentRun
err := c.do("GET", "/api/companies/"+c.Company+"/heartbeat-runs", nil, &out)
return out, err
}
// RunLog returns the recorded output of one run.
func (c *Client) RunLog(runID string, maxBytes int) (string, error) {
var raw []byte
req, err := http.NewRequest("GET", c.BaseURL+"/api/heartbeat-runs/"+runID+"/log", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
return "", fmt.Errorf("run log returned %d: %s", resp.StatusCode, snippet(b))
}
raw, err = io.ReadAll(io.LimitReader(resp.Body, int64(maxBytes)))
if err != nil {
return "", err
}
if len(raw) == maxBytes {
return string(raw) + "\n… (only the last " + fmt.Sprint(maxBytes) + " bytes are shown)", nil
}
return string(raw), nil
}
// PauseAgent and ResumeAgent are the two ways to stop and start an agent.
func (c *Client) PauseAgent(agentID string) error {
return c.do("POST", "/api/agents/"+agentID+"/pause", map[string]any{}, nil)
}
func (c *Client) ResumeAgent(agentID string) error {
return c.do("POST", "/api/agents/"+agentID+"/resume", map[string]any{}, nil)
}
// WakeAgent asks Paperclip to start a run for this agent now.
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)
}