Details and log output for this agent run.
{"path":".","recursive":false}
drwxr-xr-x 6 user user 4096 Aug 26 23:30 . drwxr-xr-x 1 root root 4096 Aug 26 23:30 .. drwxr-xr-x 7 user user 4096 Aug 26 23:30 .git -rw-r--r-- 1 user user 22 Aug 26 23:30 .gitignore -rw-r--r-- 1 user user 6387 Aug 26 23:30 README.md drwxr-xr-x 3 user user 4096 Aug 26 23:30 cmd drwxr-xr-x 2 user user 4096 Aug 26 23:30 docker -rw-r--r-- 1 user user 2628 Aug 26 23:30 go.mod -rw-r--r-- 1 user user 16578 Aug 26 23:30 go.sum drwxr-xr-x 11 user user 4096 Aug 26 23:30 internal -rw-r--r-- 1 user user 3092 Aug 26 23:30 zoo.hcl.example
{"command":"cat /project/package.json 2>/dev/null || cat /project/pyproject.toml 2>/dev/null || echo \"No standard project file found\""}
No standard project file found
{"path":"internal","recursive":true}
/project/internal/agentrun /project/internal/agentrun/context.go /project/internal/agentrun/docker.go /project/internal/agentrun/docker_integration_test.go /project/internal/agentrun/forgejo.go /project/internal/agentrun/loop.go /project/internal/agentrun/loop_test.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/run_test.go /project/internal/agentrun/sandboxgit.go /project/internal/agentrun/sandboxgit_test.go /project/internal/agentrun/seed_test.go /project/internal/agentrun/system.md /project/internal/config /project/internal/config/config.go /project/internal/config/config_test.go /project/internal/forgejo /project/internal/forgejo/client.go /project/internal/forgejo/client_test.go /project/internal/forgejo/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/poll_test.go /project/internal/forgejo/repo_filter.go /project/internal/forgejo/repo_filter_test.go /project/internal/forgejo/watcher.go /project/internal/forgejo/watcher_test.go /project/internal/forgejo/webhook.go /project/internal/forgejo/webhook_test.go /project/internal/livelog /project/internal/livelog/hub.go /project/internal/livelog/hub_test.go /project/internal/llm /project/internal/llm/arguments.go /project/internal/llm/arguments_test.go /project/internal/llm/client.go /project/internal/llm/error.go /project/internal/llm/error_test.go /project/internal/llm/streamer.go /project/internal/llm/streamer_test.go /project/internal/llm/types.go /project/internal/scheduler /project/internal/scheduler/scheduler.go /project/internal/scheduler/scheduler_test.go /project/internal/store /project/internal/store/schema.sql /project/internal/store/store.go /project/internal/store/store_test.go /project/internal/tools /project/internal/tools/bash.go /project/internal/tools/context.go /project/internal/tools/edit-file.go /project/internal/tools/forgejo.go /project/internal/tools/grep_search.go /project/internal/tools/list_files.go /project/internal/tools/move_file.go /project/internal/tools/read_file.go /project/internal/tools/registry.go /project/internal/tools/remove_file.go /project/internal/tools/shell.go /project/internal/tools/tools_test.go /project/internal/tools/write_file.go /project/internal/web /project/internal/web/templates.go /project/internal/web/web.go /project/internal/web/web_test.go
{"path":"cmd","recursive":true}
/project/cmd/zoo /project/cmd/zoo/main.go
{"end_line":200,"path":"internal/web/web.go","start_line":1}
{"path":"internal/web/web.go","content":"// Package web is zoo's minimal server-rendered dashboard: config\n// overview, live queue/history, and a per-job log tail.\npackage web\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\ntype Server struct {\n\tcfg *config.Config\n\tstore *store.Store\n\thub *livelog.Hub\n\tfg *forgejo.Client\n\ttmpl *template.Template\n\n\tavatarMu sync.Mutex\n\tavatarCache map[string]avatarCacheEntry\n}\n\n// avatarCacheTTL bounds how long a resolved avatar URL is trusted before\n// it's re-fetched from Forgejo. Avatars rarely change, but a user can\n// re-upload one (which changes its URL), so the cache expires instead of\n// living for the process lifetime.\nconst avatarCacheTTL = time.Hour\n\ntype avatarCacheEntry struct {\n\turl string\n\tfetchedAt time.Time\n}\n\nfunc New(cfg *config.Config, st *store.Store, hub *livelog.Hub, fg *forgejo.Client) *Server {\n\treturn \u0026Server{\n\t\tcfg: cfg,\n\t\tstore: st,\n\t\thub: hub,\n\t\tfg: fg,\n\t\ttmpl: template.Must(template.New(\"\").Parse(templates)),\n\t\tavatarCache: map[string]avatarCacheEntry{},\n\t}\n}\n\n// Handler returns the dashboard's http.Handler, gated by config.Web's\n// bearer token if one is set.\nfunc (s *Server) Handler() http.Handler {\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"GET /{$}\", s.handleIndex)\n\tmux.HandleFunc(\"GET /jobs\", s.handleJobs)\n\tmux.HandleFunc(\"GET /jobs/{id}\", s.handleJobDetail)\n\tmux.HandleFunc(\"GET /jobs/{id}/events\", s.handleJobEvents)\n\tmux.HandleFunc(\"GET /events\", s.handleEvents)\n\n\treturn s.authMiddleware(mux)\n}\n\nfunc (s *Server) authMiddleware(next http.Handler) http.Handler {\n\tif s.cfg.Web == nil || s.cfg.Web.Token == \"\" {\n\t\treturn next\n\t}\n\n\ttoken := s.cfg.Web.Token\n\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tauth := r.Header.Get(\"Authorization\")\n\t\tif auth != \"Bearer \"+token {\n\t\t\tw.Header().Set(\"WWW-Authenticate\", `Bearer realm=\"zoo\"`)\n\t\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\n\n\t\t\treturn\n\t\t}\n\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\nfunc (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {\n\t// Fetch active (pending or running) jobs for the dashboard overview.\n\t// We fetch more than we display so we can filter to just active ones.\n\tallJobs, err := s.store.ListJobs(r.Context(), 200)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Collect unique agent names from active jobs.\n\tvar agentNames []string\n\tseenAgents := make(map[string]bool)\n\n\tvar activeJobs []activeJobRow\n\tfor _, j := range allJobs {\n\t\tif j.Status != store.JobPending \u0026\u0026 j.Status != store.JobRunning {\n\t\t\tcontinue\n\t\t}\n\t\tif !seenAgents[j.Agent] {\n\t\t\tseenAgents[j.Agent] = true\n\t\t\tagentNames = append(agentNames, j.Agent)\n\t\t}\n\t\tactiveJobs = append(activeJobs, activeJobRow{\n\t\t\tJob: j,\n\t\t\tAvatarURL: s.avatarFor(j.Agent),\n\t\t})\n\t}\n\n\ttype indexData struct {\n\t\t*config.Config\n\t\tActiveJobs []activeJobRow\n\t}\n\n\ts.render(w, \"index\", indexData{\n\t\tConfig: s.cfg,\n\t\tActiveJobs: activeJobs,\n\t})\n}\n\n// activeJobRow is a store.Job enriched with the agent's avatar URL.\ntype activeJobRow struct {\n\tstore.Job\n\tAvatarURL string\n}\n\n// jobRow is a store.Job plus the agent's Forgejo avatar, resolved for the\n// jobs table so it's immediately clear who is running each job.\ntype jobRow struct {\n\tstore.Job\n\tAvatarURL string\n}\n\nfunc (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {\n\tjobs, err := s.store.ListJobs(r.Context(), 200)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\n\t\treturn\n\t}\n\n\trows := make([]jobRow, 0, len(jobs))\n\n\tfor _, j := range jobs {\n\t\trows = append(rows, jobRow{Job: j, AvatarURL: s.avatarFor(j.Agent)})\n\t}\n\n\ts.render(w, \"jobs\", rows)\n}\n\n// avatarFor returns the Forgejo avatar URL of the agent named username,\n// or \"\" if it can't be resolved (no Forgejo client configured, unknown\n// user, API error). The dashboard must never fail to render because of a\n// missing avatar, so every failure mode degrades to no image. Results are\n// cached per username for avatarCacheTTL so a page refresh doesn't turn\n// into one GetUserInfo call per unique agent.\nfunc (s *Server) avatarFor(username string) string {\n\tif s.fg == nil || username == \"\" {\n\t\treturn \"\"\n\t}\n\n\ts.avatarMu.Lock()\n\n\tif e, ok := s.avatarCache[username]; ok \u0026\u0026 time.Since(e.fetchedAt) \u003c avatarCacheTTL {\n\t\ts.avatarMu.Unlock()\n\n\t\treturn e.url\n\t}\n\n\ts.avatarMu.Unlock()\n\n\tprofile, err := s.fg.AgentProfile(username)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\ts.avatarMu.Lock()\n\ts.avatarCache[username] = avatarCacheEntry{url: profile.AvatarURL, fetchedAt: time.Now()}\n\ts.avatarMu.Unlock()\n\n\treturn profile.AvatarURL\n}\n\n// toolBlock is the parsed form of a stream=\"tool\" store.LogLine, for the\n// template to render as a single collapsed detail.\ntype toolBlock struct {\n\tName string\n\tArguments string\n\tResult string\n\tError bool\n}\n\n// block is one self-contained, already-complete unit of job output: a\n// finished reasoning or assistant-message block, a finished tool call,\n// or a misc system note. Unlike the old flat log view, one store.LogLine","start_line":1,"end_line":200,"total_lines":430,"truncated":true}
{"end_line":200,"path":"internal/agentrun/roster.go","start_line":1}
{"path":"internal/agentrun/roster.go","content":"package agentrun\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"strings\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n)\n\n// agentInfo pairs a configured agent with its Forgejo profile: the bio\n// (what its account says it does) and the name/email used to set git\n// commit authorship inside its container.\ntype agentInfo struct {\n\tName string\n\tDescription string\n\tFullName string\n\tEmail string\n}\n\n// buildRoster fetches every configured agent's Forgejo profile (agent\n// config labels double as Forgejo usernames). A per-agent lookup failure\n// is logged and that agent is included with an empty profile rather than\n// failing the whole run — one missing/unreachable profile shouldn't\n// block someone else's job.\nfunc buildRoster(fg *forgejo.Client, agents []config.Agent, logger *slog.Logger) []agentInfo {\n\troster := make([]agentInfo, 0, len(agents))\n\n\tfor _, a := range agents {\n\t\tprofile, err := fg.AgentProfile(a.Name)\n\t\tif err != nil {\n\t\t\tlogger.Warn(\"failed to fetch agent profile from forgejo\", \"agent\", a.Name, \"error\", err)\n\t\t}\n\n\t\troster = append(roster, agentInfo{Name: a.Name, Description: profile.Description, FullName: profile.FullName, Email: profile.Email})\n\t}\n\n\treturn roster\n}\n\n// gitIdentity returns the git commit author name/email to configure\n// inside self's container, from its Forgejo profile, falling back to\n// its agent name and a synthetic zoo.local address for whichever fields\n// its profile doesn't set.\nfunc gitIdentity(self string, roster []agentInfo) (name, email string) {\n\tfor _, a := range roster {\n\t\tif a.Name == self {\n\t\t\tname, email = a.FullName, a.Email\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif name == \"\" {\n\t\tname = self\n\t}\n\n\tif email == \"\" {\n\t\temail = self + \"@zoo.local\"\n\t}\n\n\treturn name, email\n}\n\n// identitySection renders the \"who am I / who else is there\" prompt\n// section injected after the static system prompt: the agent's own\n// Forgejo profile description, and everyone else's, so it can decide\n// when a task is better handed off via the assign_issue tool.\nfunc identitySection(self string, roster []agentInfo) string {\n\tvar b strings.Builder\n\n\tvar selfDesc string\n\tvar others []agentInfo\n\n\tfor _, a := range roster {\n\t\tif a.Name == self {\n\t\t\tselfDesc = a.Description\n\t\t} else {\n\t\t\tothers = append(others, a)\n\t\t}\n\t}\n\n\tfmt.Fprintf(\u0026b, \"\\n\\n## Your identity\\n\\nYou are the agent %q.\", self)\n\n\tif selfDesc != \"\" {\n\t\tfmt.Fprintf(\u0026b, \" Your role, from your Forgejo profile description: %s\", selfDesc)\n\t} else {\n\t\tb.WriteString(\" Your Forgejo profile has no description set.\")\n\t}\n\n\tif len(others) \u003e 0 {\n\t\tb.WriteString(\"\\n\\n## Other agents\\n\\nIf this task is a better fit for another agent's role, use the assign_issue tool with their name to hand it off (this reassigns the issue/PR on Forgejo, which triggers their run):\\n\")\n\n\t\tfor _, a := range others {\n\t\t\tdesc := a.Description\n\t\t\tif desc == \"\" {\n\t\t\t\tdesc = \"(no description set)\"\n\t\t\t}\n\n\t\t\tfmt.Fprintf(\u0026b, \"### %s\\n\\n%s\\n\\n\", a.Name, desc)\n\t\t}\n\t}\n\n\treturn b.String()\n}","start_line":1,"end_line":105,"total_lines":105,"truncated":false}
{"end_line":200,"path":"internal/web/templates.go","start_line":1}
{"path":"internal/web/templates.go","content":"package web\n\nconst templates = `\n{{define \"layout_head\"}}\n\u003c!doctype html\u003e\n\u003chtml lang=\"en\"\u003e\n\u003chead\u003e\n\u003cmeta charset=\"utf-8\"\u003e\n\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"\u003e\n\u003ctitle\u003ezoo\u003c/title\u003e\n\u003cstyle\u003e\n :root {\n --bg: #0f0f13;\n --bg-surface: #1a1a24;\n --bg-surface-hover: #22222e;\n --bg-code: #12121a;\n --border: #2a2a3a;\n --text: #e4e4e8;\n --text-muted: #8888a0;\n --accent: #7c6aef;\n --accent-glow: rgba(124, 106, 239, 0.15);\n --radius: 12px;\n --radius-sm: 8px;\n --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n --mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace;\n }\n\n * { margin: 0; padding: 0; box-sizing: border-box; }\n\n body {\n font-family: var(--font);\n background: var(--bg);\n color: var(--text);\n line-height: 1.6;\n min-height: 100vh;\n }\n\n /* ── Navigation ─────────────────────────────── */\n nav {\n position: sticky;\n top: 0;\n z-index: 100;\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 2rem;\n height: 60px;\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n backdrop-filter: blur(12px);\n }\n\n nav .brand {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--text);\n text-decoration: none;\n letter-spacing: -0.02em;\n }\n\n nav .brand .logo {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n border-radius: var(--radius-sm);\n background: linear-gradient(135deg, var(--accent), #a78bfa);\n color: #fff;\n font-size: 1rem;\n font-weight: 800;\n }\n\n nav .links {\n display: flex;\n gap: 0.25rem;\n }\n\n nav .links a {\n display: inline-flex;\n align-items: center;\n gap: 0.4rem;\n padding: 0.5rem 1rem;\n border-radius: var(--radius-sm);\n color: var(--text-muted);\n text-decoration: none;\n font-size: 0.9rem;\n font-weight: 500;\n transition: all 0.15s ease;\n }\n\n nav .links a:hover {\n color: var(--text);\n background: var(--bg-surface-hover);\n }\n\n nav .links a.active {\n color: var(--accent);\n background: var(--accent-glow);\n }\n\n /* ── Main container ─────────────────────────── */\n .container {\n max-width: 1200px;\n margin: 0 auto;\n padding: 2rem;\n }\n\n /* ── Page header ────────────────────────────── */\n .page-header {\n margin-bottom: 2rem;\n }\n\n h1 {\n font-size: 2rem;\n font-weight: 700;\n letter-spacing: -0.03em;\n margin-bottom: 0.25rem;\n background: linear-gradient(135deg, var(--text), var(--text-muted));\n -webkit-background-clip: text;\n -webkit-text-fill-color: transparent;\n background-clip: text;\n }\n\n .page-header p {\n color: var(--text-muted);\n font-size: 0.95rem;\n }\n\n h2 {\n font-size: 1.15rem;\n font-weight: 600;\n color: var(--text-muted);\n text-transform: uppercase;\n letter-spacing: 0.06em;\n margin: 2rem 0 1rem;\n padding-bottom: 0.5rem;\n border-bottom: 1px solid var(--border);\n }\n\n /* ── Cards ──────────────────────────────────── */\n .card {\n background: var(--bg-surface);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n transition: border-color 0.2s ease;\n }\n\n .card:hover {\n border-color: #3a3a50;\n }\n\n /* ── Job Cards ──────────────────────────────── */\n .job-cards {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));\n gap: 1rem;\n margin-bottom: 2rem;\n }\n\n .job-card {\n background: var(--bg-surface);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1rem 1.25rem;\n transition: border-color 0.2s ease, box-shadow 0.2s ease;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n }\n\n .job-card:hover {\n border-color: #3a3a50;\n box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);\n }\n\n .job-card-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .job-card-link {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border-radius: var(--radius-sm);\n color: var(--text-muted);\n text-decoration: none;\n font-size: 1.1rem;\n font-weight: 600;\n transition: all 0.15s ease;\n }\n","start_line":1,"end_line":200,"total_lines":1092,"truncated":true}
{"end_line":400,"path":"internal/web/templates.go","start_line":200}
{"path":"internal/web/templates.go","content":"\n .job-card-link:hover {\n color: var(--accent);\n background: var(--accent-glow);\n }\n\n .job-card-body {\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n }\n\n .job-card-title {\n font-size: 1rem;\n font-weight: 600;\n color: var(--text);\n line-height: 1.4;\n margin: 0;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n }\n\n .job-card-meta {\n font-size: 0.82rem;\n color: var(--text-muted);\n margin: 0;\n }\n\n .job-card-meta code {\n background: var(--bg-code);\n padding: 0.15rem 0.4rem;\n border-radius: 4px;\n font-size: 0.8rem;\n }\n\n .job-card-agent {\n display: inline-flex;\n align-items: center;\n gap: 0.5rem;\n margin-top: 0.25rem;\n }\n\n .job-card-avatar {\n width: 28px;\n height: 28px;\n border-radius: 50%;\n border: 1px solid var(--border);\n background: var(--bg-code);\n flex-shrink: 0;\n }\n\n .job-card-agent-name {\n font-size: 0.9rem;\n font-weight: 500;\n color: var(--text);\n }\n\n /* ── Tables ─────────────────────────────────── */\n .table-wrap {\n border-radius: var(--radius);\n overflow: hidden;\n border: 1px solid var(--border);\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n font-size: 0.9rem;\n }\n\n thead {\n background: var(--bg-surface-hover);\n }\n\n th {\n text-align: left;\n padding: 0.75rem 1rem;\n font-weight: 600;\n font-size: 0.8rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n border-bottom: 1px solid var(--border);\n }\n\n td {\n padding: 0.75rem 1rem;\n border-bottom: 1px solid var(--border);\n vertical-align: middle;\n }\n\n tbody tr:last-child td {\n border-bottom: none;\n }\n\n tbody tr {\n transition: background 0.15s ease;\n }\n\n tbody tr:hover {\n background: var(--bg-surface-hover);\n }\n\n td a {\n color: var(--accent);\n text-decoration: none;\n font-weight: 500;\n }\n\n td a:hover {\n text-decoration: underline;\n }\n\n /* ── Badges ─────────────────────────────────── */\n .badge {\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n padding: 0.2rem 0.65rem;\n border-radius: 999px;\n font-size: 0.78rem;\n font-weight: 600;\n letter-spacing: 0.01em;\n }\n\n .badge .dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n flex-shrink: 0;\n }\n\n .badge-pending {\n background: rgba(234, 170, 2, 0.12);\n color: #eab308;\n }\n .badge-pending .dot { background: #eab308; }\n\n .badge-running {\n background: rgba(124, 106, 239, 0.15);\n color: var(--accent);\n }\n .badge-running .dot {\n background: var(--accent);\n animation: pulse 1.5s ease-in-out infinite;\n }\n\n .badge-succeeded {\n background: rgba(34, 197, 94, 0.12);\n color: #22c55e;\n }\n .badge-succeeded .dot { background: #22c55e; }\n\n .badge-failed, .badge-timed_out {\n background: rgba(239, 68, 68, 0.12);\n color: #ef4444;\n }\n .badge-failed .dot, .badge-timed_out .dot { background: #ef4444; }\n\n @keyframes pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.3; }\n }\n\n /* ── Agent avatars ──────────────────────────── */\n .agent {\n display: inline-flex;\n align-items: center;\n gap: 0.5rem;\n }\n\n .agent-avatar {\n width: 22px;\n height: 22px;\n border-radius: 50%;\n border: 1px solid var(--border);\n background: var(--bg-code);\n flex-shrink: 0;\n }\n\n /* ── Info grid ──────────────────────────────── */\n .info-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n gap: 1rem;\n margin-bottom: 1rem;\n }\n\n .info-item {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n }\n\n .info-item .label {\n font-size: 0.78rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);","start_line":200,"end_line":400,"total_lines":1092,"truncated":true}
{"end_line":200,"path":"internal/forgejo/client.go","start_line":1}
{"path":"internal/forgejo/client.go","content":"// Package forgejo wraps the Forgejo SDK and turns webhook deliveries and\n// polling results into a common Event stream for the scheduler.\npackage forgejo\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n)\n\n// Client is zoo's single shared Forgejo identity, used both for the\n// event sources (webhook/poll) and for actions agents/scheduler take\n// (comments, labels, PRs).\ntype Client struct {\n\tsdk *sdk.Client\n\n\tbaseURL string\n\ttoken string\n}\n\nfunc NewClient(cfg config.Forgejo) (*Client, error) {\n\tc, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"forgejo client: %w\", err)\n\t}\n\n\treturn \u0026Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil\n}\n\n// Token returns the shared zoo Forgejo identity's token, e.g. for\n// authenticating a host-side git clone/push against Forgejo (see\n// internal/agentrun) without ever writing the credential into a working\n// tree an agent's container can read.\nfunc (c *Client) Token() string {\n\treturn c.token\n}\n\n// As returns a new Client that authenticates as the given token.\n// This is used to create per-agent clients so each agent acts as\n// themselves on Forgejo, without needing a global token with sudo\n// privileges.\nfunc (c *Client) As(token string) *Client {\n\tclient, _ := sdk.NewClient(c.baseURL, sdk.SetToken(token))\n\treturn \u0026Client{sdk: client, baseURL: c.baseURL, token: token}\n}\n\n// Sudo returns a new Client that impersonates username (via Forgejo's\n// \"Sudo:\" header) on every API call it makes, using the same underlying\n// token. Actions an agent takes through it — comments, labels, PRs,\n// assignment — are attributed to that agent's own Forgejo account\n// instead of the shared zoo identity. The token must belong to a user\n// with sudo scope/admin rights for this to work; Forgejo rejects the\n// header otherwise.\n//\n// Deprecated: use As(token) with a per-agent token instead. Kept for\n// backward compatibility during migration.\nfunc (c *Client) Sudo(username string) (*Client, error) {\n\tsudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"forgejo client sudo %q: %w\", username, err)\n\t}\n\n\treturn \u0026Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil\n}\n\n// CreateIssueComment posts a comment on the given issue or pull request\n// (Forgejo/Gitea treat PRs as issues for commenting purposes).\nfunc (c *Client) CreateIssueComment(owner, repo string, index int64, body string) error {\n\t_, _, err := c.sdk.CreateIssueComment(owner, repo, index, sdk.CreateIssueCommentOption{Body: body})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"comment on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// IssueComment is one comment on an issue or pull request, in the\n// shape zoo needs when briefing an agent: who said what, and when.\ntype IssueComment struct {\n\tAuthor string\n\tBody string\n\tCreated time.Time\n}\n\n// ListIssueComments fetches every comment on the given issue or pull\n// request, oldest first. PRs are issues under the hood in Forgejo, so\n// the same endpoint serves both. Pages are walked until exhausted so\n// the result isn't capped by the server's default page size.\nfunc (c *Client) ListIssueComments(owner, repo string, index int64) ([]IssueComment, error) {\n\tconst pageSize = 50\n\n\tvar all []*sdk.Comment\n\n\tfor page := 1; ; page++ {\n\t\tbatch, _, err := c.sdk.ListIssueComments(owner, repo, index, sdk.ListIssueCommentOptions{\n\t\t\tListOptions: sdk.ListOptions{Page: page, PageSize: pageSize},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"list comments on %s/%s#%d (page %d): %w\", owner, repo, index, page, err)\n\t\t}\n\n\t\tall = append(all, batch...)\n\n\t\tif len(batch) \u003c pageSize {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tout := make([]IssueComment, 0, len(all))\n\tfor _, cm := range all {\n\t\tauthor := \"\"\n\t\tif cm.Poster != nil {\n\t\t\tauthor = cm.Poster.UserName\n\t\t}\n\n\t\tout = append(out, IssueComment{Author: author, Body: cm.Body, Created: cm.Created})\n\t}\n\n\treturn out, nil\n}\n\n// AddLabel attaches the label with the given name to an issue/PR,\n// creating the label (with a default color) on the repo first if it\n// doesn't already exist.\nfunc (c *Client) AddLabel(owner, repo string, index int64, name string) error {\n\tid, err := c.labelID(owner, repo, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, _, err = c.sdk.AddIssueLabels(owner, repo, index, sdk.IssueLabelsOption{Labels: []int64{id}})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"add label %q to %s/%s#%d: %w\", name, owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// RemoveLabel detaches the label with the given name from an issue/PR, if\n// both the label and the attachment exist.\nfunc (c *Client) RemoveLabel(owner, repo string, index int64, name string) error {\n\tlabels, _, err := c.sdk.GetIssueLabels(owner, repo, index, sdk.ListLabelsOptions{})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"list labels on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\tfor _, l := range labels {\n\t\tif l.Name == name {\n\t\t\t_, err := c.sdk.DeleteIssueLabel(owner, repo, index, l.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"remove label %q from %s/%s#%d: %w\", name, owner, repo, index, err)\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *Client) labelID(owner, repo, name string) (int64, error) {\n\tlabels, _, err := c.sdk.ListRepoLabels(owner, repo, sdk.ListLabelsOptions{})\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"list labels on %s/%s: %w\", owner, repo, err)\n\t}\n\n\tfor _, l := range labels {\n\t\tif l.Name == name {\n\t\t\treturn l.ID, nil\n\t\t}\n\t}\n\n\tcreated, _, err := c.sdk.CreateLabel(owner, repo, sdk.CreateLabelOption{\n\t\tName: name,\n\t\tColor: \"#ee0000\",\n\t})\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"create label %q on %s/%s: %w\", name, owner, repo, err)\n\t}\n\n\treturn created.ID, nil\n}\n\n// CreatePullRequest opens a PR from head into base.\nfunc (c *Client) CreatePullRequest(owner, repo, head, base, title, body string) error {\n\t_, _, err := c.sdk.CreatePullRequest(owner, repo, sdk.CreatePullRequestOption{\n\t\tHead: head,\n\t\tBase: base,\n\t\tTitle: title,\n\t\tBody: body,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"create pull request %s/%s %s-\u003e%s: %w\", owner, repo, head, base, err)\n\t}\n\n\treturn nil\n}\n","start_line":1,"end_line":200,"total_lines":432,"truncated":true}
{"query":"AgentProfile","glob":"*.go"}
/project/internal/web/web.go:177: profile, err := s.fg.AgentProfile(username)
/project/internal/forgejo/client.go:373:// AgentProfile is what zoo reads off an agent's Forgejo account: its bio
/project/internal/forgejo/client.go:377:type AgentProfile struct {
/project/internal/forgejo/client.go:384:// AgentProfile fetches the Forgejo profile of the user account named
/project/internal/forgejo/client.go:389:func (c *Client) AgentProfile(username string) (AgentProfile, error) {
/project/internal/forgejo/client.go:392: return AgentProfile{}, fmt.Errorf("get user %q: %w", username, err)
/project/internal/forgejo/client.go:395: p := AgentProfile{
/project/internal/agentrun/roster.go:31: profile, err := fg.AgentProfile(a.Name)
{"end_line":432,"path":"internal/forgejo/client.go","start_line":200}
{"path":"internal/forgejo/client.go","content":"\n// RequestReview asks the given users to review the pull request.\nfunc (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {\n\t_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"request review on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// Review states an agent can submit, in the friendly names the tools\n// expose. SubmitReview maps them onto the SDK's ReviewStateType.\nconst (\n\tReviewStateApproved = \"approved\"\n\tReviewStateChangesRequest = \"changes_requested\"\n\tReviewStateComment = \"comment\"\n)\n\n// SubmitReview submits a review on the pull request with the given\n// verdict and body. state is one of ReviewStateApproved,\n// ReviewStateChangesRequest, or ReviewStateComment. A body is required\n// for anything other than an approval (Forgejo enforces this too).\nfunc (c *Client) SubmitReview(owner, repo string, index int64, state, body string) error {\n\tvar sdkState sdk.ReviewStateType\n\n\tswitch state {\n\tcase ReviewStateApproved:\n\t\tsdkState = sdk.ReviewStateApproved\n\tcase ReviewStateChangesRequest:\n\t\tsdkState = sdk.ReviewStateRequestChanges\n\tcase ReviewStateComment:\n\t\tsdkState = sdk.ReviewStateComment\n\tdefault:\n\t\treturn fmt.Errorf(\"submit review on %s/%s#%d: unknown review state %q\", owner, repo, index, state)\n\t}\n\n\tif _, _, err := c.sdk.CreatePullReview(owner, repo, index, sdk.CreatePullReviewOptions{State: sdkState, Body: body}); err != nil {\n\t\treturn fmt.Errorf(\"submit review on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// ReviewCommentDetail is one inline comment on a pull request review,\n// in the shape zoo needs when briefing an agent: where it points, what\n// it says, and its id (so the agent can refer to it in its reply).\ntype ReviewCommentDetail struct {\n\tID int64\n\tPath string\n\tLine int\n\tBody string\n\tAuthor string\n}\n\n// ReviewDetail is the review context zoo briefs an agent with when a\n// pr:review event fires: the review's verdict and body, plus its inline\n// comments.\ntype ReviewDetail struct {\n\tID int64\n\tState string\n\tBody string\n\tReviewer string\n\tComments []ReviewCommentDetail\n}\n\n// ReviewDetail fetches a pull request review and its inline comments.\n// The webhook payload carries the review but not its inline comments,\n// so this is how a reacting agent gets the full feedback.\nfunc (c *Client) ReviewDetail(owner, repo string, index, reviewID int64) (*ReviewDetail, error) {\n\treview, _, err := c.sdk.GetPullReview(owner, repo, index, reviewID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get review %d on %s/%s#%d: %w\", reviewID, owner, repo, index, err)\n\t}\n\n\tcomments, _, err := c.sdk.ListPullReviewComments(owner, repo, index, reviewID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"list comments on review %d of %s/%s#%d: %w\", reviewID, owner, repo, index, err)\n\t}\n\n\tdetail := \u0026ReviewDetail{\n\t\tID: review.ID,\n\t\tState: string(review.State),\n\t\tBody: review.Body,\n\t}\n\n\tif review.Reviewer != nil {\n\t\tdetail.Reviewer = review.Reviewer.UserName\n\t}\n\n\tfor _, cm := range comments {\n\t\tauthor := \"\"\n\t\tif cm.Reviewer != nil {\n\t\t\tauthor = cm.Reviewer.UserName\n\t\t}\n\n\t\tline := int(cm.LineNum)\n\t\tif cm.OldLineNum != 0 \u0026\u0026 cm.LineNum == 0 {\n\t\t\tline = int(cm.OldLineNum)\n\t\t}\n\n\t\tdetail.Comments = append(detail.Comments, ReviewCommentDetail{\n\t\t\tID: cm.ID,\n\t\t\tPath: cm.Path,\n\t\t\tLine: line,\n\t\t\tBody: cm.Body,\n\t\t\tAuthor: author,\n\t\t})\n\t}\n\n\treturn detail, nil\n}\n\n// PullRequestInfo is the branch metadata agentrun needs to check out a\n// pull request's head.\ntype PullRequestInfo struct {\n\tHeadRef string\n\tBaseRef string\n}\n\n// PullRequestInfo returns the pull request's head and base branch refs.\nfunc (c *Client) PullRequestInfo(owner, repo string, index int64) (PullRequestInfo, error) {\n\tpr, _, err := c.sdk.GetPullRequest(owner, repo, index)\n\tif err != nil {\n\t\treturn PullRequestInfo{}, fmt.Errorf(\"get pull request %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\tinfo := PullRequestInfo{}\n\tif pr.Head != nil {\n\t\tinfo.HeadRef = pr.Head.Ref\n\t}\n\tif pr.Base != nil {\n\t\tinfo.BaseRef = pr.Base.Ref\n\t}\n\n\treturn info, nil\n}\n\n// CloseIssue closes the given issue or pull request.\nfunc (c *Client) CloseIssue(owner, repo string, index int64) error {\n\treturn c.setIssueState(owner, repo, index, sdk.StateClosed)\n}\n\n// ReopenIssue reopens the given issue or pull request.\nfunc (c *Client) ReopenIssue(owner, repo string, index int64) error {\n\treturn c.setIssueState(owner, repo, index, sdk.StateOpen)\n}\n\n// RepositoryInfo returns the pieces of repo metadata agentrun needs to\n// clone and branch off of the right place.\ntype RepositoryInfo struct {\n\tDefaultBranch string\n\tCloneURL string\n}\n\nfunc (c *Client) RepositoryInfo(owner, repo string) (RepositoryInfo, error) {\n\tr, _, err := c.sdk.GetRepo(owner, repo)\n\tif err != nil {\n\t\treturn RepositoryInfo{}, fmt.Errorf(\"get repo %s/%s: %w\", owner, repo, err)\n\t}\n\n\treturn RepositoryInfo{DefaultBranch: r.DefaultBranch, CloneURL: r.CloneURL}, nil\n}\n\nfunc (c *Client) setIssueState(owner, repo string, index int64, state sdk.StateType) error {\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{State: \u0026state})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"set state %q on %s/%s#%d: %w\", state, owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// AgentProfile is what zoo reads off an agent's Forgejo account: its bio\n// (surfaced in the system prompt), the name/email used to set git commit\n// authorship inside that agent's container, and its avatar URL (surfaced\n// on the dashboard).\ntype AgentProfile struct {\n\tDescription string\n\tFullName string\n\tEmail string\n\tAvatarURL string\n}\n\n// AgentProfile fetches the Forgejo profile of the user account named\n// username. Agent config labels double as their Forgejo usernames (see\n// issue:assigned resolution), so this is how an agent's \"what it does\"\n// text, and its commit identity, are authored: by editing that account's\n// profile on Forgejo, not in zoo.hcl.\nfunc (c *Client) AgentProfile(username string) (AgentProfile, error) {\n\tu, _, err := c.sdk.GetUserInfo(username)\n\tif err != nil {\n\t\treturn AgentProfile{}, fmt.Errorf(\"get user %q: %w\", username, err)\n\t}\n\n\tp := AgentProfile{\n\t\tDescription: u.Description,\n\t\tFullName: u.FullName,\n\t\tEmail: u.Email,\n\t\tAvatarURL: u.AvatarURL,\n\t}\n\n\treturn p, nil\n}\n\n// AssignIssue sets the sole assignee of an issue/PR to username. Used by\n// the assign_issue tool so agents can hand work off to each other: the\n// resulting issue:assigned event (webhook or poll) triggers whichever\n// agent's config label matches username.\nfunc (c *Client) AssignIssue(owner, repo string, index int64, username string) error {\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{Assignees: []string{username}})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"assign %s/%s#%d to %q: %w\", owner, repo, index, username, err)\n\t}\n\n\treturn nil\n}\n\n// IssueTitle fetches the title of an issue or pull request from Forgejo.\n// Returns an empty string (not an error) if the client is nil or the\n// issue cannot be fetched — the caller should degrade gracefully.\nfunc (c *Client) IssueTitle(owner, repo string, index int64) string {\n\tif c == nil || c.sdk == nil {\n\t\treturn \"\"\n\t}\n\n\tissue, _, err := c.sdk.GetIssue(owner, repo, index)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\treturn issue.Title\n}","start_line":200,"end_line":432,"total_lines":432,"truncated":false}
{"end_line":600,"path":"internal/web/templates.go","start_line":400}
{"path":"internal/web/templates.go","content":" color: var(--text-muted);\n font-weight: 600;\n }\n\n .info-item .value {\n font-size: 0.95rem;\n color: var(--text);\n word-break: break-all;\n }\n\n /* ── Code / Log ─────────────────────────────── */\n .log-container {\n background: var(--bg-code);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow-y: auto;\n max-height: 70vh;\n padding: 1rem;\n }\n\n /* The log box (not the window) is the scrollable element, so the\n jump-to-bottom button floats over it via a positioned wrapper. */\n .log-wrap {\n position: relative;\n }\n\n .log-jump {\n position: absolute;\n right: 1.5rem;\n bottom: 1.5rem;\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n padding: 0.45rem 0.9rem;\n border: 1px solid var(--border);\n border-radius: 999px;\n background: var(--bg-surface);\n color: var(--text);\n font-family: var(--font);\n font-size: 0.8rem;\n font-weight: 600;\n line-height: 1.2;\n cursor: pointer;\n box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);\n transition: background 0.15s ease, border-color 0.15s ease;\n }\n\n .log-jump:hover {\n background: var(--bg-surface-hover);\n border-color: var(--accent);\n }\n\n /* The display rule above would otherwise outrank the UA stylesheet's\n [hidden] { display: none }. */\n .log-jump[hidden] {\n display: none;\n }\n\n /* Plain block flow, not flex: a flex column with overflow:hidden\n children (.block-tool) gives those children an automatic min-height\n of 0 instead of their content height, so once total content\n exceeded max-height, flexbox was free to squash them down. */\n .log-container .block + .block {\n margin-top: 0.6rem;\n }\n\n pre {\n margin: 0;\n padding: 1.25rem;\n font-family: var(--mono);\n font-size: 0.82rem;\n line-height: 1.7;\n color: #c4c4d0;\n white-space: pre-wrap;\n word-break: break-all;\n }\n\n /* ── Log blocks ─────────────────────────────── */\n .block-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: var(--text-muted);\n font-weight: 600;\n margin-bottom: 0.35rem;\n }\n\n .block-body {\n font-family: var(--font);\n font-size: 0.9rem;\n line-height: 1.6;\n color: var(--text);\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .block-reasoning,\n .block-content {\n padding: 0.75rem 1rem;\n border-radius: var(--radius-sm);\n }\n\n .block-reasoning {\n background: rgba(124, 106, 239, 0.06);\n border-left: 3px solid var(--accent);\n }\n\n .block-reasoning .block-body {\n color: var(--text-muted);\n font-style: italic;\n }\n\n .block-content {\n background: var(--bg-surface);\n border: 1px solid var(--border);\n }\n\n .block-system {\n padding: 0.35rem 0.75rem;\n color: var(--text-muted);\n font-family: var(--mono);\n font-size: 0.8rem;\n }\n\n .block-tool {\n background: rgba(34, 211, 238, 0.06);\n border: 1px solid var(--border);\n border-left: 4px solid #22d3ee;\n border-radius: var(--radius-sm);\n overflow: hidden;\n }\n\n .block-tool summary {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n cursor: pointer;\n padding: 0.9rem 1.1rem;\n min-height: 2.75rem;\n color: var(--text);\n list-style: none;\n }\n\n .block-tool summary::-webkit-details-marker { display: none; }\n\n .block-tool summary::before {\n content: \"▸\";\n display: inline-block;\n font-size: 1.1rem;\n color: var(--text-muted);\n transition: transform 0.15s ease;\n flex-shrink: 0;\n }\n\n .block-tool[open] summary::before { transform: rotate(90deg); }\n\n .tool-badge {\n flex-shrink: 0;\n padding: 0.25rem 0.6rem;\n border-radius: 999px;\n background: rgba(34, 211, 238, 0.15);\n color: #22d3ee;\n font-size: 0.7rem;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n }\n\n .tool-summary-text {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n min-width: 0;\n }\n\n .block-tool .tool-name {\n font-size: 1rem;\n font-weight: 700;\n color: var(--text);\n }\n\n .block-tool .tool-args-preview {\n color: var(--text-muted);\n font-family: var(--mono);\n font-size: 0.78rem;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n }\n\n .block-tool-error { border-left-color: #ef4444; }\n .block-tool-error .tool-badge { background: rgba(239, 68, 68, 0.15); color: #ef4444; }\n\n .block-tool .block-body {\n padding: 0 1.1rem 1rem;\n border-top: 1px solid var(--border);\n /* Unlike a reasoning/content block, this wraps element children\n (labels + \u003cpre\u003es), not raw text, so it must not inherit the base\n .block-body's white-space: pre-wrap — that would render the","start_line":400,"end_line":600,"total_lines":1092,"truncated":true}
{"end_line":200,"path":"internal/config/config.go","start_line":1}
{"path":"internal/config/config.go","content":"// Package config loads and validates zoo's HCL configuration file.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2/hclsimple\"\n)\n\n// Known event kinds. issue:assigned and pr:review are resolved\n// dynamically (agent name must match the Forgejo assignee's username,\n// or the pull request author's username, respectively) so they never\n// carry an `agent` attribute; the rest map statically to one\n// configured agent.\nconst (\n\tEventIssueNew = \"issue:new\"\n\tEventIssueComment = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew = \"pr:new\"\n\tEventPRReview = \"pr:review\"\n)\n\nvar staticEventKinds = map[string]bool{\n\tEventIssueNew: true,\n\tEventIssueComment: true,\n\tEventPRNew: true,\n}\n\ntype Config struct {\n\tLLMs []LLM `hcl:\"llm,block\"`\n\tForgejo Forgejo `hcl:\"forgejo,block\"`\n\tEnvironment Environment `hcl:\"environment,block\"`\n\tAgents []Agent `hcl:\"agent,block\"`\n\tEvents []Event `hcl:\"event,block\"`\n\tWeb *Web `hcl:\"web,block\"`\n}\n\n// Web configures the dashboard's optional bearer-token gate. Leave the\n// block out of zoo.hcl entirely to run without one (fine on localhost;\n// put a real gate or a proxy in front for anything else).\ntype Web struct {\n\tToken string `hcl:\"token,optional\"`\n}\n\ntype LLM struct {\n\tName string `hcl:\"name,label\"`\n\tOpenAI string `hcl:\"openai\"`\n\tToken string `hcl:\"token\"`\n\tModel string `hcl:\"model\"`\n}\n\ntype Forgejo struct {\n\tURL string `hcl:\"url\"`\n\tToken string `hcl:\"token\"`\n\tWebhookSecret string `hcl:\"webhook_secret,optional\"`\n\n\t// Repos is the allowlist of repository patterns to watch, e.g.\n\t// [\"acme/*\", \"acme/widgets\"]. Patterns are \"owner/repo\" pairs with\n\t// glob wildcards; \"*\" watches everything on the instance. An empty\n\t// list keeps the historical behavior of watching every repository\n\t// the token can see.\n\tRepos []string `hcl:\"repos,optional\"`\n}\n\ntype Environment struct {\n\tDockerImage string `hcl:\"docker_image\"`\n\tMaxLive int `hcl:\"max_live_agents\"`\n}\n\ntype Agent struct {\n\tName string `hcl:\"name,label\"`\n\tLLM string `hcl:\"llm\"`\n\tToken string `hcl:\"token,optional\"`\n}\n\ntype Event struct {\n\tKind string `hcl:\"name,label\"`\n\tAgent string `hcl:\"agent,optional\"`\n\tInstructions string `hcl:\"instructions,optional\"`\n}\n\n// Load reads and validates the config file at path.\nfunc Load(path string) (*Config, error) {\n\tvar cfg Config\n\n\tif err := hclsimple.DecodeFile(path, nil, \u0026cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse config: %w\", err)\n\t}\n\n\tif err := cfg.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid config: %w\", err)\n\t}\n\n\treturn \u0026cfg, nil\n}\n\n// Validate checks that the config is internally consistent: every\n// reference between blocks resolves, and required values are set.\nfunc (c *Config) Validate() error {\n\tllmNames := make(map[string]bool, len(c.LLMs))\n\tfor _, l := range c.LLMs {\n\t\tif l.OpenAI == \"\" || l.Token == \"\" || l.Model == \"\" {\n\t\t\treturn fmt.Errorf(\"llm %q: openai, token, and model are required\", l.Name)\n\t\t}\n\t\tllmNames[l.Name] = true\n\t}\n\n\tif c.Forgejo.URL == \"\" || c.Forgejo.Token == \"\" {\n\t\treturn fmt.Errorf(\"forgejo: url and token are required\")\n\t}\n\n\tfor _, p := range c.Forgejo.Repos {\n\t\tif err := validRepoPattern(p); err != nil {\n\t\t\treturn fmt.Errorf(\"forgejo: %w\", err)\n\t\t}\n\t}\n\n\tif c.Environment.MaxLive \u003c 1 {\n\t\treturn fmt.Errorf(\"environment: max_live_agents must be \u003e= 1, got %d\", c.Environment.MaxLive)\n\t}\n\n\tif c.Environment.DockerImage == \"\" {\n\t\treturn fmt.Errorf(\"environment: docker_image is required\")\n\t}\n\n\tagentNames := make(map[string]bool, len(c.Agents))\n\tfor _, a := range c.Agents {\n\t\tif !llmNames[a.LLM] {\n\t\t\treturn fmt.Errorf(\"agent %q: references undeclared llm %q\", a.Name, a.LLM)\n\t\t}\n\t\tagentNames[a.Name] = true\n\t}\n\n\tseenEventKinds := make(map[string]bool, len(c.Events))\n\tfor _, e := range c.Events {\n\t\tif seenEventKinds[e.Kind] {\n\t\t\treturn fmt.Errorf(\"event %q: declared more than once\", e.Kind)\n\t\t}\n\t\tseenEventKinds[e.Kind] = true\n\n\t\tswitch {\n\t\tcase e.Kind == EventIssueAssigned:\n\t\t\tif e.Agent != \"\" {\n\t\t\t\treturn fmt.Errorf(\"event %q: must not set agent; the agent whose name matches the Forgejo assignee's username is triggered dynamically\", e.Kind)\n\t\t\t}\n\n\t\tcase e.Kind == EventPRReview:\n\t\t\tif e.Agent != \"\" {\n\t\t\t\treturn fmt.Errorf(\"event %q: must not set agent; the agent whose name matches the pull request author's username is triggered dynamically\", e.Kind)\n\t\t\t}\n\n\t\tcase staticEventKinds[e.Kind]:\n\t\t\tif e.Agent == \"\" {\n\t\t\t\treturn fmt.Errorf(\"event %q: agent is required\", e.Kind)\n\t\t\t}\n\t\t\tif !agentNames[e.Agent] {\n\t\t\t\treturn fmt.Errorf(\"event %q: references undeclared agent %q\", e.Kind, e.Agent)\n\t\t\t}\n\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"event %q: unknown event kind\", e.Kind)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// validRepoPattern checks the shape of a forgejo `repos` entry. It must\n// be a non-empty \"owner/repo\" pair (or a bare owner name, or \"*\") — at\n// most one slash, none at either end. Glob syntax within the segments\n// is validated later by forgejo.NewRepoFilter.\nfunc validRepoPattern(p string) error {\n\tif p == \"\" {\n\t\treturn fmt.Errorf(\"repos: pattern must not be empty\")\n\t}\n\n\tif strings.Count(p, \"/\") \u003e 1 {\n\t\treturn fmt.Errorf(\"repos: pattern %q must be \\\"owner/repo\\\" (at most one \\\"/\\\")\", p)\n\t}\n\n\tif strings.HasPrefix(p, \"/\") || strings.HasSuffix(p, \"/\") {\n\t\treturn fmt.Errorf(\"repos: pattern %q must not start or end with \\\"/\\\"\", p)\n\t}\n\n\treturn nil\n}\n\n// AgentConfig holds an agent's full configuration including its\n// Forgejo token (if configured).\ntype AgentConfig struct {\n\tName string\n\tLLM string\n\tToken string\n}\n\n// AgentByName returns the agent config with the given name, if declared.\nfunc (c *Config) AgentByName(name string) (AgentConfig, bool) {\n\tfor _, a := range c.Agents {\n\t\tif a.Name == name {","start_line":1,"end_line":200,"total_lines":246,"truncated":true}
{"end_line":1092,"path":"internal/web/templates.go","start_line":600}
{"path":"internal/web/templates.go","content":" .block-body's white-space: pre-wrap — that would render the\n template source's own whitespace between those child tags as\n visible blank lines. */\n white-space: normal;\n }\n\n .block-tool .tool-section-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n margin: 0.6rem 0 0.25rem;\n }\n\n .block-tool pre {\n margin: 0;\n padding: 0;\n background: transparent;\n font-size: 0.8rem;\n color: #c4c4d0;\n }\n\n code {\n font-family: var(--mono);\n background: var(--bg-code);\n padding: 0.15rem 0.45rem;\n border-radius: 4px;\n font-size: 0.85em;\n color: #c4b5fd;\n }\n\n /* ── Job detail meta ────────────────────────── */\n .job-meta {\n display: flex;\n flex-wrap: wrap;\n gap: 1.5rem;\n margin-bottom: 1.5rem;\n }\n\n .job-meta-item {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n }\n\n .job-meta-item .label {\n font-size: 0.75rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n }\n\n .job-meta-item .value {\n font-size: 0.95rem;\n }\n\n .error-text {\n color: #ef4444;\n }\n\n /* ── Responsive ─────────────────────────────── */\n @media (max-width: 768px) {\n nav { padding: 0 1rem; }\n .container { padding: 1rem; }\n h1 { font-size: 1.5rem; }\n th, td { padding: 0.5rem 0.65rem; font-size: 0.82rem; }\n .info-grid { grid-template-columns: 1fr; }\n .job-meta { gap: 1rem; }\n }\n\n /* ── Scrollbar ──────────────────────────────── */\n ::-webkit-scrollbar { width: 8px; height: 8px; }\n ::-webkit-scrollbar-track { background: transparent; }\n ::-webkit-scrollbar-thumb {\n background: var(--border);\n border-radius: 4px;\n }\n ::-webkit-scrollbar-thumb:hover { background: #3a3a50; }\n\u003c/style\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n\u003cnav\u003e\n \u003ca href=\"/\" class=\"brand\"\u003e\n \u003cspan class=\"logo\"\u003eZ\u003c/span\u003e\n zoo\n \u003c/a\u003e\n \u003cdiv class=\"links\"\u003e\n \u003ca href=\"/\"\u003eDashboard\u003c/a\u003e\n \u003ca href=\"/jobs\"\u003eJobs\u003c/a\u003e\n \u003c/div\u003e\n\u003c/nav\u003e\n{{end}}\n\n{{define \"index\"}}\n{{template \"layout_head\" .}}\n\u003cdiv class=\"container\"\u003e\n \u003cdiv class=\"page-header\"\u003e\n \u003ch1\u003eDashboard\u003c/h1\u003e\n \u003cp\u003eOverview of your zoo configuration and running agents.\u003c/p\u003e\n \u003c/div\u003e\n\n {{if .ActiveJobs}}\n \u003ch2\u003eRunning Jobs\u003c/h2\u003e\n \u003cdiv class=\"job-cards\"\u003e\n {{range .ActiveJobs}}\n \u003cdiv class=\"job-card\"\u003e\n \u003cdiv class=\"job-card-header\"\u003e\n \u003cspan class=\"badge badge-{{.Status}}\"\u003e\n \u003cspan class=\"dot\"\u003e\u003c/span\u003e\n {{.Status}}\n \u003c/span\u003e\n \u003ca href=\"/jobs/{{.ID}}\" class=\"job-card-link\" title=\"View job details\"\u003e→\u003c/a\u003e\n \u003c/div\u003e\n \u003cdiv class=\"job-card-body\"\u003e\n \u003ch3 class=\"job-card-title\"\u003e\n {{if .Title}}{{.Title}}{{else}}Issue #{{.IssueIndex}}{{end}}\n \u003c/h3\u003e\n \u003cp class=\"job-card-meta\"\u003e\n \u003ccode\u003e{{.Owner}}/{{.Repo}}#{{.IssueIndex}}\u003c/code\u003e\n \u003c/p\u003e\n \u003cdiv class=\"job-card-agent\"\u003e\n {{if .AvatarURL}}\u003cimg class=\"job-card-avatar\" src=\"{{.AvatarURL}}\" alt=\"{{.Agent}}\" title=\"{{.Agent}}\" loading=\"lazy\"\u003e{{end}}\n \u003cspan class=\"job-card-agent-name\"\u003e{{.Agent}}\u003c/span\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n {{end}}\n \u003c/div\u003e\n {{end}}\n\n \u003ch2\u003eLLMs\u003c/h2\u003e\n \u003cdiv class=\"table-wrap\"\u003e\n \u003ctable\u003e\n \u003cthead\u003e\u003ctr\u003e\u003cth\u003eName\u003c/th\u003e\u003cth\u003eEndpoint\u003c/th\u003e\u003cth\u003eModel\u003c/th\u003e\u003c/tr\u003e\u003c/thead\u003e\n \u003ctbody\u003e\n {{range .LLMs}}\n \u003ctr\u003e\n \u003ctd\u003e\u003cstrong\u003e{{.Name}}\u003c/strong\u003e\u003c/td\u003e\n \u003ctd\u003e\u003ccode\u003e{{.OpenAI}}\u003c/code\u003e\u003c/td\u003e\n \u003ctd\u003e{{.Model}}\u003c/td\u003e\n \u003c/tr\u003e\n {{end}}\n \u003c/tbody\u003e\n \u003c/table\u003e\n \u003c/div\u003e\n\n \u003ch2\u003eAgents\u003c/h2\u003e\n \u003cdiv class=\"table-wrap\"\u003e\n \u003ctable\u003e\n \u003cthead\u003e\u003ctr\u003e\u003cth\u003eName\u003c/th\u003e\u003cth\u003eLLM\u003c/th\u003e\u003c/tr\u003e\u003c/thead\u003e\n \u003ctbody\u003e\n {{range .Agents}}\n \u003ctr\u003e\n \u003ctd\u003e\u003cstrong\u003e{{.Name}}\u003c/strong\u003e\u003c/td\u003e\n \u003ctd\u003e{{.LLM}}\u003c/td\u003e\n \u003c/tr\u003e\n {{end}}\n \u003c/tbody\u003e\n \u003c/table\u003e\n \u003c/div\u003e\n\n \u003ch2\u003eEvent Mappings\u003c/h2\u003e\n \u003cdiv class=\"table-wrap\"\u003e\n \u003ctable\u003e\n \u003cthead\u003e\u003ctr\u003e\u003cth\u003eEvent\u003c/th\u003e\u003cth\u003eAgent\u003c/th\u003e\u003cth\u003eInstructions\u003c/th\u003e\u003c/tr\u003e\u003c/thead\u003e\n \u003ctbody\u003e\n {{range .Events}}\n \u003ctr\u003e\n \u003ctd\u003e\u003ccode\u003e{{.Kind}}\u003c/code\u003e\u003c/td\u003e\n \u003ctd\u003e{{if .Agent}}\u003cstrong\u003e{{.Agent}}\u003c/strong\u003e{{else}}\u003cem style=\"color:var(--text-muted)\"\u003edynamic (assignee username)\u003c/em\u003e{{end}}\u003c/td\u003e\n \u003ctd\u003e{{.Instructions}}\u003c/td\u003e\n \u003c/tr\u003e\n {{end}}\n \u003c/tbody\u003e\n \u003c/table\u003e\n \u003c/div\u003e\n\n \u003ch2\u003eEnvironment\u003c/h2\u003e\n \u003cdiv class=\"info-grid\"\u003e\n \u003cdiv class=\"info-item\"\u003e\n \u003cspan class=\"label\"\u003eDocker Image\u003c/span\u003e\n \u003cspan class=\"value\"\u003e\u003ccode\u003e{{.Environment.DockerImage}}\u003c/code\u003e\u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"info-item\"\u003e\n \u003cspan class=\"label\"\u003eMax Live Agents\u003c/span\u003e\n \u003cspan class=\"value\"\u003e{{.Environment.MaxLive}}\u003c/span\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n\n \u003cscript\u003e\n // Live dashboard: the server pushes a signal whenever the set of\n // active jobs changes (job created, started, or finished). The page\n // keeps no state of its own, so the right response to any signal is\n // to re-render from scratch — the same pattern the job detail page\n // uses when a run finishes.\n (function () {\n var es = new EventSource(\"/events\");\n\n es.onmessage = function () {\n es.close();\n location.reload();\n };\n })();\n \u003c/script\u003e\n\u003c/div\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n{{end}}\n\n{{define \"jobs\"}}\n{{template \"layout_head\" .}}\n\u003cdiv class=\"container\"\u003e\n \u003cdiv class=\"page-header\"\u003e\n \u003ch1\u003eJobs\u003c/h1\u003e\n \u003cp\u003eAll agent runs and their current status.\u003c/p\u003e\n \u003c/div\u003e\n\n \u003cdiv class=\"table-wrap\"\u003e\n \u003ctable\u003e\n \u003cthead\u003e\n \u003ctr\u003e\n \u003cth\u003eID\u003c/th\u003e\n \u003cth\u003eStatus\u003c/th\u003e\n \u003cth\u003eEvent\u003c/th\u003e\n \u003cth\u003eAgent\u003c/th\u003e\n \u003cth\u003eRepository\u003c/th\u003e\n \u003cth\u003eCreated\u003c/th\u003e\n \u003c/tr\u003e\n \u003c/thead\u003e\n \u003ctbody\u003e\n {{range .}}\n \u003ctr\u003e\n \u003ctd\u003e\u003ca href=\"/jobs/{{.ID}}\"\u003e{{.ID}}\u003c/a\u003e\u003c/td\u003e\n \u003ctd\u003e\n \u003cspan class=\"badge badge-{{.Status}}\"\u003e\n \u003cspan class=\"dot\"\u003e\u003c/span\u003e\n {{.Status}}\n \u003c/span\u003e\n \u003c/td\u003e\n \u003ctd\u003e{{.EventKind}}\u003c/td\u003e\n \u003ctd\u003e\n \u003cspan class=\"agent\"\u003e\n {{if .AvatarURL}}\u003cimg class=\"agent-avatar\" src=\"{{.AvatarURL}}\" alt=\"{{.Agent}}\" title=\"{{.Agent}}\" loading=\"lazy\"\u003e{{end}}\n \u003cstrong\u003e{{.Agent}}\u003c/strong\u003e\n \u003c/span\u003e\n \u003c/td\u003e\n \u003ctd\u003e\u003ccode\u003e{{.Owner}}/{{.Repo}}#{{.IssueIndex}}\u003c/code\u003e\u003c/td\u003e\n \u003ctd\u003e{{.CreatedAt.Format \"2006-01-02 15:04:05\"}}\u003c/td\u003e\n \u003c/tr\u003e\n {{end}}\n \u003c/tbody\u003e\n \u003c/table\u003e\n \u003c/div\u003e\n\u003c/div\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n{{end}}\n\n{{define \"job_detail\"}}\n{{template \"layout_head\" .}}\n\u003cdiv class=\"container\"\u003e\n \u003cdiv class=\"page-header\"\u003e\n \u003ch1\u003eJob {{.Job.ID}}\u003c/h1\u003e\n \u003cp\u003eDetails and log output for this agent run.\u003c/p\u003e\n \u003c/div\u003e\n\n \u003cdiv class=\"job-meta\"\u003e\n \u003cdiv class=\"job-meta-item\"\u003e\n \u003cspan class=\"label\"\u003eStatus\u003c/span\u003e\n \u003cspan class=\"value\"\u003e\n \u003cspan class=\"badge badge-{{.Job.Status}}\"\u003e\n \u003cspan class=\"dot\"\u003e\u003c/span\u003e\n {{.Job.Status}}\n \u003c/span\u003e\n \u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"job-meta-item\"\u003e\n \u003cspan class=\"label\"\u003eEvent\u003c/span\u003e\n \u003cspan class=\"value\"\u003e{{.Job.EventKind}} on \u003ccode\u003e{{.Job.Owner}}/{{.Job.Repo}}#{{.Job.IssueIndex}}\u003c/code\u003e\u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"job-meta-item\"\u003e\n \u003cspan class=\"label\"\u003eAgent\u003c/span\u003e\n \u003cspan class=\"value\"\u003e\n \u003cspan class=\"agent\"\u003e\n {{if .AvatarURL}}\u003cimg class=\"agent-avatar\" src=\"{{.AvatarURL}}\" alt=\"{{.Job.Agent}}\" title=\"{{.Job.Agent}}\"\u003e{{end}}\n \u003cstrong\u003e{{.Job.Agent}}\u003c/strong\u003e\n \u003c/span\u003e\n \u003c/span\u003e\n \u003c/div\u003e\n {{if .Job.Error}}\n \u003cdiv class=\"job-meta-item\"\u003e\n \u003cspan class=\"label\"\u003eError\u003c/span\u003e\n \u003cspan class=\"value error-text\"\u003e{{.Job.Error}}\u003c/span\u003e\n \u003c/div\u003e\n {{end}}\n \u003c/div\u003e\n\n \u003ch2\u003eLog\u003c/h2\u003e\n \u003cdiv class=\"log-wrap\"\u003e\n \u003cdiv class=\"log-container\" id=\"log\"\u003e\n {{range .Blocks}}\n {{if eq .Kind \"reasoning\"}}\n \u003cdiv class=\"block block-reasoning\"\u003e\n \u003cdiv class=\"block-label\"\u003eThinking\u003c/div\u003e\n \u003cdiv class=\"block-body\"\u003e{{.Text}}\u003c/div\u003e\n \u003c/div\u003e\n {{else if eq .Kind \"content\"}}\n \u003cdiv class=\"block block-content\"\u003e\n \u003cdiv class=\"block-body\"\u003e{{.Text}}\u003c/div\u003e\n \u003c/div\u003e\n {{else if eq .Kind \"tool\"}}\n \u003cdetails class=\"block block-tool{{if .Tool.Error}} block-tool-error{{end}}\"\u003e\n \u003csummary\u003e\n \u003cspan class=\"tool-badge\"\u003eTool\u003c/span\u003e\n \u003cspan class=\"tool-summary-text\"\u003e\n \u003cspan class=\"tool-name\"\u003e🔧 {{.Tool.Name}}\u003c/span\u003e\n \u003cspan class=\"tool-args-preview\"\u003e{{.Tool.Arguments}}\u003c/span\u003e\n \u003c/span\u003e\n \u003c/summary\u003e\n \u003cdiv class=\"block-body\"\u003e\n \u003cdiv class=\"tool-section-label\"\u003eArguments\u003c/div\u003e\n \u003cpre\u003e{{.Tool.Arguments}}\u003c/pre\u003e\n \u003cdiv class=\"tool-section-label\"\u003eResult\u003c/div\u003e\n \u003cpre\u003e{{.Tool.Result}}\u003c/pre\u003e\n \u003c/div\u003e\n \u003c/details\u003e\n {{else}}\n \u003cdiv class=\"block block-system\"\u003e{{.Text}}\u003c/div\u003e\n {{end}}\n {{end}}\n \u003c/div\u003e\n {{if .Live}}\n \u003cbutton type=\"button\" class=\"log-jump\" id=\"log-jump\" hidden\u003e↓ Latest\u003c/button\u003e\n {{end}}\n \u003c/div\u003e\n\n {{if .Live}}\n \u003cscript\u003e\n (function() {\n var jobID = {{.Job.ID}};\n var log = document.getElementById(\"log\");\n var jumpBtn = document.getElementById(\"log-jump\");\n var reasoningBody = null;\n var contentBody = null;\n\n // The log box (not the window) is what scrolls, so all scroll\n // math is done against it. While the view is pinned to the\n // newest output we keep it there as the stream grows; the moment\n // the user scrolls up to read earlier output we stop, and the\n // jump button reappears so they can get back to the live tail.\n var stick = true;\n\n function atBottom() {\n return log.scrollHeight - log.scrollTop - log.clientHeight \u003c= 80;\n }\n\n function follow() {\n if (stick) log.scrollTop = log.scrollHeight;\n }\n\n log.addEventListener(\"scroll\", function() {\n stick = atBottom();\n jumpBtn.hidden = !stick;\n });\n\n jumpBtn.addEventListener(\"click\", function() {\n stick = true;\n log.scrollTop = log.scrollHeight;\n jumpBtn.hidden = true;\n });\n\n // Opening a live job means spying on its tail: start at the\n // newest output.\n follow();\n\n function newBlock(kind, label) {\n var div = document.createElement(\"div\");\n div.className = \"block block-\" + kind;\n if (label) {\n var l = document.createElement(\"div\");\n l.className = \"block-label\";\n l.textContent = label;\n div.appendChild(l);\n }\n var body = document.createElement(\"div\");\n body.className = \"block-body\";\n div.appendChild(body);\n log.appendChild(div);\n return body;\n }\n\n function newToolBlock(ev) {\n var details = document.createElement(\"details\");\n details.className = \"block block-tool\" + (ev.error ? \" block-tool-error\" : \"\");\n\n var summary = document.createElement(\"summary\");\n\n var badge = document.createElement(\"span\");\n badge.className = \"tool-badge\";\n badge.textContent = \"Tool\";\n\n var text = document.createElement(\"span\");\n text.className = \"tool-summary-text\";\n\n var name = document.createElement(\"span\");\n name.className = \"tool-name\";\n name.textContent = \"🔧 \" + ev.name;\n\n var preview = document.createElement(\"span\");\n preview.className = \"tool-args-preview\";\n preview.textContent = ev.arguments;\n\n text.appendChild(name);\n text.appendChild(preview);\n summary.appendChild(badge);\n summary.appendChild(text);\n details.appendChild(summary);\n\n var body = document.createElement(\"div\");\n body.className = \"block-body\";\n\n var argsLabel = document.createElement(\"div\");\n argsLabel.className = \"tool-section-label\";\n argsLabel.textContent = \"Arguments\";\n var argsPre = document.createElement(\"pre\");\n argsPre.textContent = ev.arguments;\n\n var resultLabel = document.createElement(\"div\");\n resultLabel.className = \"tool-section-label\";\n resultLabel.textContent = \"Result\";\n var resultPre = document.createElement(\"pre\");\n resultPre.textContent = ev.result;\n\n body.appendChild(argsLabel);\n body.appendChild(argsPre);\n body.appendChild(resultLabel);\n body.appendChild(resultPre);\n details.appendChild(body);\n log.appendChild(details);\n }\n\n var es = new EventSource(\"/jobs/\" + jobID + \"/events\");\n\n es.onmessage = function(e) {\n var ev = JSON.parse(e.data);\n\n switch (ev.type) {\n case \"reasoning_start\":\n reasoningBody = newBlock(\"reasoning\", \"Thinking\");\n break;\n case \"reasoning_delta\":\n if (reasoningBody) reasoningBody.textContent += ev.text;\n break;\n case \"reasoning_end\":\n // Model output routinely starts/ends with newlines; drop them\n // so the finished block matches the static (trimmed) view.\n if (reasoningBody) reasoningBody.textContent = reasoningBody.textContent.trim();\n reasoningBody = null;\n break;\n case \"content_start\":\n contentBody = newBlock(\"content\", null);\n break;\n case \"content_delta\":\n if (contentBody) contentBody.textContent += ev.text;\n break;\n case \"content_end\":\n if (contentBody) contentBody.textContent = contentBody.textContent.trim();\n contentBody = null;\n break;\n case \"tool\":\n newToolBlock(ev);\n break;\n case \"system\":\n newBlock(\"system\", null).textContent = ev.text;\n break;\n case \"status\":\n es.close();\n location.reload();\n return;\n }\n\n follow();\n };\n })();\n \u003c/script\u003e\n {{end}}\n\u003c/div\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n{{end}}\n`","start_line":600,"end_line":1092,"total_lines":1092,"truncated":false}
{"end_line":100,"path":"zoo.hcl.example","start_line":1}
{"path":"zoo.hcl.example","content":"# zoo.hcl - Configuration for the zoo daemon.\n#\n# Copy this file to zoo.hcl (gitignored) and fill in real values.\n\nllm \"Qwen 3.6\" {\n openai = \"https://code.stdio.dk\"\n token = \"TOKEN\"\n model = \"Qwen3.6\"\n}\n\nllm \"Qwen 3.8\" {\n openai = \"https://code.stdio.dk\"\n token = \"TOKEN\"\n model = \"Qwen3.8\"\n}\n\nforgejo {\n url = \"https://code.stdio.dk\"\n token = \"ZOO_SERVICE_TOKEN\"\n # Shared secret configured on the Forgejo webhook. Required to accept\n # webhook deliveries; polling works without it.\n webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\n\n # Which repositories to watch. Events from every other repository\n # are ignored. Each entry is an \"owner/repo\" pair with glob\n # wildcards:\n #\n # repos = [\"acme/*\"] # everything in the acme org\n # repos = [\"acme/widgets\"] # just that one repository\n # repos = [\"acme/web*\"] # repos in acme whose name starts with \"web\"\n # repos = [\"acme\", \"bob\"] # bare names mean \"everything in that org\"\n # repos = [\"*\"] # everything on the instance\n #\n # Owner names match case-insensitively (like Forgejo usernames);\n # repository names match case-sensitively. Leave the list out (or\n # empty) to watch everything the token can see.\n # repos = [\"abrander/*\", \"acme/widgets\"]\n}\n\nenvironment {\n docker_image = \"golang:latest\"\n max_live_agents = 5\n}\n\nagent \"anna\" {\n llm = \"Qwen 3.6\"\n token = \"ANNA_FORGEJO_TOKEN\"\n}\n\nagent \"greg\" {\n llm = \"Qwen 3.8\"\n token = \"GREG_FORGEJO_TOKEN\"\n}\n\n# \"leon\" doubles as the expected Forgejo assignee username for\n# issue:assigned events - see the event block below.\nagent \"leon\" {\n llm = \"Qwen 3.8\"\n token = \"LEON_FORGEJO_TOKEN\"\n}\n\nagent \"mika\" {\n llm = \"Qwen 3.6\"\n token = \"MIKA_FORGEJO_TOKEN\"\n}\n\nevent \"issue:new\" {\n agent = \"leon\"\n}\n\nevent \"issue:assigned\" {\n // No `agent` here: the agent whose name matches the Forgejo\n // assignee's username is triggered.\n instructions = \"Please handle this issue.\"\n}\n\nevent \"issue:comment\" {\n agent = \"leon\"\n instructions = \"Please review what this comment says and respond appropriately.\"\n}\n\nevent \"pr:new\" {\n agent = \"greg\"\n instructions = \"Please review this pull request and respond appropriately.\"\n}\n\n# A review was submitted on a pull request (approved, changes requested,\n# or comment). The agent that opened the PR is triggered to handle it,\n# like issue:assigned: its working tree is checked out on the PR's head\n# branch, so changes it pushes update the PR directly. It should address\n# the feedback and reply to the review with a summary of what it\n# addressed. (Forgejo has no API to mark review comments resolved, so\n# that stays with the reviewer.)\nevent \"pr:review\" {\n // No `agent` here: the agent whose name matches the pull request\n // author's username is triggered.\n instructions = \"A review was submitted on this pull request. Address the feedback, push your changes to the PR branch, and reply to the review with a summary of what you addressed.\"\n}","start_line":1,"end_line":98,"total_lines":98,"truncated":false}
{"end_line":246,"path":"internal/config/config.go","start_line":200}
{"path":"internal/config/config.go","content":"\t\tif a.Name == name {\n\t\t\treturn AgentConfig{\n\t\t\t\tName: a.Name,\n\t\t\t\tLLM: a.LLM,\n\t\t\t\tToken: a.Token,\n\t\t\t}, true\n\t\t}\n\t}\n\n\treturn AgentConfig{}, false\n}\n\n// LLMByName returns the llm config with the given name, if declared.\nfunc (c *Config) LLMByName(name string) (LLM, bool) {\n\tfor _, l := range c.LLMs {\n\t\tif l.Name == name {\n\t\t\treturn l, true\n\t\t}\n\t}\n\n\treturn LLM{}, false\n}\n\n// EventAgent returns the statically configured agent name for the given\n// event kind, if one is declared and set.\nfunc (c *Config) EventAgent(kind string) (string, bool) {\n\tfor _, e := range c.Events {\n\t\tif e.Kind == kind {\n\t\t\treturn e.Agent, e.Agent != \"\"\n\t\t}\n\t}\n\n\treturn \"\", false\n}\n\n// EventInstructions returns the configured freeform instructions for the\n// given event kind, e.g. \"Please review this pull request and respond\n// appropriately.\" Returns \"\" if the event isn't declared or has none.\nfunc (c *Config) EventInstructions(kind string) string {\n\tfor _, e := range c.Events {\n\t\tif e.Kind == kind {\n\t\t\treturn e.Instructions\n\t\t}\n\t}\n\n\treturn \"\"\n}","start_line":200,"end_line":246,"total_lines":246,"truncated":false}
{"end_line":100,"path":"cmd/zoo/main.go","start_line":1}
{"path":"cmd/zoo/main.go","content":"// Command zoo runs the daemon: it watches a Forgejo instance for\n// issue/PR events, dispatches them to configured AI agents running in\n// Docker containers, and serves a small dashboard over the result.\npackage main\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/agentrun\"\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/scheduler\"\n\t\"github.com/abrander/zoo/internal/store\"\n\t\"github.com/abrander/zoo/internal/web\"\n)\n\nfunc main() {\n\tif err := run(); err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"zoo:\", err)\n\n\t\tos.Exit(1)\n\t}\n}\n\nfunc run() error {\n\tvar (\n\t\tconfigPath = flag.String(\"config\", \"zoo.hcl\", \"path to the zoo.hcl config file\")\n\t\tdbPath = flag.String(\"db\", \"zoo.db\", \"path to the sqlite state database\")\n\t\tlisten = flag.String(\"listen\", \":8080\", \"address to serve webhooks and the dashboard on\")\n\t\trunTimeout = flag.Duration(\"run-timeout\", agentrun.DefaultTimeout, \"wall-clock timeout for a single agent run\")\n\t\tkeepOnFailure = flag.Bool(\"keep-on-failure\", false, \"keep the container and clone around after a failed run, for debugging\")\n\t)\n\n\tflag.Parse()\n\n\tlogger := slog.New(slog.NewTextHandler(os.Stderr, nil))\n\n\tcfg, err := config.Load(*configPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"load config: %w\", err)\n\t}\n\n\tst, err := store.Open(*dbPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"open store: %w\", err)\n\t}\n\tdefer st.Close()\n\n\tif n, err := st.ReapOrphanedJobs(context.Background()); err != nil {\n\t\tlogger.Warn(\"failed to reap orphaned jobs\", \"error\", err)\n\t} else if n \u003e 0 {\n\t\tlogger.Warn(\"reaped jobs left running by a previous process instance\", \"count\", n)\n\t}\n\n\tfgClient, err := forgejo.NewClient(cfg.Forgejo)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo client: %w\", err)\n\t}\n\n\trepoFilter, err := forgejo.NewRepoFilter(cfg.Forgejo.Repos)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo repos: %w\", err)\n\t}\n\n\twatcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, repoFilter, logger.With(\"component\", \"forgejo\"))\n\n\thub := livelog.NewHub()\n\n\trunner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With(\"component\", \"agentrun\"), *runTimeout, *keepOnFailure)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"agent runner: %w\", err)\n\t}\n\n\tsched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With(\"component\", \"scheduler\"))\n\tdashboard := web.New(cfg, st, hub, fgClient)\n\n\tmux := http.NewServeMux()\n\n\tmux.Handle(\"/webhooks/forgejo\", watcher.Handler())\n\n\tmux.HandleFunc(\"/healthz\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"ok\"))\n\t})\n\n\tmux.Handle(\"/\", dashboard.Handler())\n\n\tserver := \u0026http.Server{Addr: *listen, Handler: mux}\n\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()","start_line":1,"end_line":100,"total_lines":136,"truncated":true}
{"end_line":100,"path":"internal/web/web.go","start_line":1}
{"path":"internal/web/web.go","content":"// Package web is zoo's minimal server-rendered dashboard: config\n// overview, live queue/history, and a per-job log tail.\npackage web\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\ntype Server struct {\n\tcfg *config.Config\n\tstore *store.Store\n\thub *livelog.Hub\n\tfg *forgejo.Client\n\ttmpl *template.Template\n\n\tavatarMu sync.Mutex\n\tavatarCache map[string]avatarCacheEntry\n}\n\n// avatarCacheTTL bounds how long a resolved avatar URL is trusted before\n// it's re-fetched from Forgejo. Avatars rarely change, but a user can\n// re-upload one (which changes its URL), so the cache expires instead of\n// living for the process lifetime.\nconst avatarCacheTTL = time.Hour\n\ntype avatarCacheEntry struct {\n\turl string\n\tfetchedAt time.Time\n}\n\nfunc New(cfg *config.Config, st *store.Store, hub *livelog.Hub, fg *forgejo.Client) *Server {\n\treturn \u0026Server{\n\t\tcfg: cfg,\n\t\tstore: st,\n\t\thub: hub,\n\t\tfg: fg,\n\t\ttmpl: template.Must(template.New(\"\").Parse(templates)),\n\t\tavatarCache: map[string]avatarCacheEntry{},\n\t}\n}\n\n// Handler returns the dashboard's http.Handler, gated by config.Web's\n// bearer token if one is set.\nfunc (s *Server) Handler() http.Handler {\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"GET /{$}\", s.handleIndex)\n\tmux.HandleFunc(\"GET /jobs\", s.handleJobs)\n\tmux.HandleFunc(\"GET /jobs/{id}\", s.handleJobDetail)\n\tmux.HandleFunc(\"GET /jobs/{id}/events\", s.handleJobEvents)\n\tmux.HandleFunc(\"GET /events\", s.handleEvents)\n\n\treturn s.authMiddleware(mux)\n}\n\nfunc (s *Server) authMiddleware(next http.Handler) http.Handler {\n\tif s.cfg.Web == nil || s.cfg.Web.Token == \"\" {\n\t\treturn next\n\t}\n\n\ttoken := s.cfg.Web.Token\n\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tauth := r.Header.Get(\"Authorization\")\n\t\tif auth != \"Bearer \"+token {\n\t\t\tw.Header().Set(\"WWW-Authenticate\", `Bearer realm=\"zoo\"`)\n\t\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\n\n\t\t\treturn\n\t\t}\n\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\nfunc (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {\n\t// Fetch active (pending or running) jobs for the dashboard overview.\n\t// We fetch more than we display so we can filter to just active ones.\n\tallJobs, err := s.store.ListJobs(r.Context(), 200)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Collect unique agent names from active jobs.\n\tvar agentNames []string\n\tseenAgents := make(map[string]bool)\n\n\tvar activeJobs []activeJobRow","start_line":1,"end_line":100,"total_lines":430,"truncated":true}
{"find":"\tmux.HandleFunc(\"GET /{$}\", s.handleIndex)\n\tmux.HandleFunc(\"GET /jobs\", s.handleJobs)\n\tmux.HandleFunc(\"GET /jobs/{id}\", s.handleJobDetail)\n\tmux.HandleFunc(\"GET /jobs/{id}/events\", s.handleJobEvents)\n\tmux.HandleFunc(\"GET /events\", s.handleEvents)","path":"internal/web/web.go","replace":"\tmux.HandleFunc(\"GET /{$}\", s.handleIndex)\n\tmux.HandleFunc(\"GET /jobs\", s.handleJobs)\n\tmux.HandleFunc(\"GET /jobs/{id}\", s.handleJobDetail)\n\tmux.HandleFunc(\"GET /jobs/{id}/events\", s.handleJobEvents)\n\tmux.HandleFunc(\"GET /events\", s.handleEvents)\n\tmux.HandleFunc(\"GET /team\", s.handleTeam)"}
1 occurrences replaced
{"end_line":430,"path":"internal/web/web.go","start_line":300}
{"path":"internal/web/web.go","content":"\t}\n\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\tw.Header().Set(\"Connection\", \"keep-alive\")\n\tw.WriteHeader(http.StatusOK)\n\n\tbuffered, ch, cancel := s.hub.Subscribe(id)\n\tdefer cancel()\n\n\twrite := func(ev livelog.Event) bool {\n\t\tdata, err := json.Marshal(ev)\n\t\tif err != nil {\n\t\t\treturn true\n\t\t}\n\n\t\tif _, err := fmt.Fprintf(w, \"data: %s\\n\\n\", data); err != nil {\n\t\t\treturn false\n\t\t}\n\n\t\tflusher.Flush()\n\n\t\treturn true\n\t}\n\n\tfor _, ev := range buffered {\n\t\tif !write(ev) {\n\t\t\treturn\n\t\t}\n\t}\n\n\theartbeat := time.NewTicker(15 * time.Second)\n\tdefer heartbeat.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase ev, ok := \u003c-ch:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !write(ev) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif ev.Type == livelog.Status {\n\t\t\t\treturn\n\t\t\t}\n\n\t\tcase \u003c-heartbeat.C:\n\t\t\tif _, err := fmt.Fprint(w, \": heartbeat\\n\\n\"); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tflusher.Flush()\n\n\t\tcase \u003c-r.Context().Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// handleEvents streams job-set changes as Server-Sent Events: the\n// dashboard page subscribes once and reloads itself whenever a job is\n// created, started, or finished, so new jobs appear (and finished ones\n// disappear) without a manual refresh. Like handleJobEvents, it carries\n// no state of its own — the reload re-renders the page from the store,\n// which is the single source of truth.\nfunc (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {\n\tflusher, ok := w.(http.Flusher)\n\tif !ok {\n\t\thttp.Error(w, \"streaming unsupported\", http.StatusInternalServerError)\n\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\tw.Header().Set(\"Connection\", \"keep-alive\")\n\tw.WriteHeader(http.StatusOK)\n\n\tch, cancel := s.hub.SubscribeJobsChanged()\n\tdefer cancel()\n\n\t// A comment, not a data event: it confirms the stream is open\n\t// without making the client reload on connect.\n\tif _, err := fmt.Fprint(w, \": connected\\n\\n\"); err != nil {\n\t\treturn\n\t}\n\n\tflusher.Flush()\n\n\theartbeat := time.NewTicker(15 * time.Second)\n\tdefer heartbeat.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase \u003c-ch:\n\t\t\tif _, err := fmt.Fprint(w, \"data: {}\\n\\n\"); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tflusher.Flush()\n\n\t\tcase \u003c-heartbeat.C:\n\t\t\tif _, err := fmt.Fprint(w, \": heartbeat\\n\\n\"); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tflusher.Flush()\n\n\t\tcase \u003c-r.Context().Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (s *Server) render(w http.ResponseWriter, name string, data any) {\n\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n\n\t// Every page here reflects live, fast-changing state (job status, log\n\t// blocks). Without this, browsers are free to serve a stale cached\n\t// copy of the job detail page on a same-URL navigation — including\n\t// the auto location.reload() a job's SSE stream triggers when it\n\t// finishes — which looks exactly like blocks that were visible while\n\t// streaming vanishing once the page reloads.\n\tw.Header().Set(\"Cache-Control\", \"no-store\")\n\n\tif err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}","start_line":300,"end_line":430,"total_lines":431,"truncated":true}
{"end_line":431,"path":"internal/web/web.go","start_line":420}
{"path":"internal/web/web.go","content":"\t// Every page here reflects live, fast-changing state (job status, log\n\t// blocks). Without this, browsers are free to serve a stale cached\n\t// copy of the job detail page on a same-URL navigation — including\n\t// the auto location.reload() a job's SSE stream triggers when it\n\t// finishes — which looks exactly like blocks that were visible while\n\t// streaming vanishing once the page reloads.\n\tw.Header().Set(\"Cache-Control\", \"no-store\")\n\n\tif err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}\n}","start_line":420,"end_line":431,"total_lines":431,"truncated":false}
{"find":"func (s *Server) render(w http.ResponseWriter, name string, data any) {","path":"internal/web/web.go","replace":"// teamMember is an agent's Forgejo profile, enriched for the team page.\ntype teamMember struct {\n\tName string\n\tDescription string\n\tFullName string\n\tEmail string\n\tAvatarURL string\n\tLLMName string\n}\n\n// handleTeam renders the \"Meet the Team\" page with a card for each agent.\nfunc (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) {\n\tmembers := make([]teamMember, 0, len(s.cfg.Agents))\n\n\tfor _, a := range s.cfg.Agents {\n\t\tprofile, err := s.fg.AgentProfile(a.Name)\n\t\tif err != nil {\n\t\t\t// Log the error but don't fail the page — one missing profile\n\t\t\t// shouldn't hide the rest of the team.\n\t\t\ts.log.Warn(\"failed to fetch agent profile for team page\", \"agent\", a.Name, \"error\", err)\n\t\t}\n\n\t\tllmName := a.LLM\n\t\tif llmName == \"\" {\n\t\t\tllmName = \"—\"\n\t\t}\n\n\t\tmembers = append(members, teamMember{\n\t\t\tName: a.Name,\n\t\t\tDescription: profile.Description,\n\t\t\tFullName: profile.FullName,\n\t\t\tEmail: profile.Email,\n\t\t\tAvatarURL: profile.AvatarURL,\n\t\t\tLLMName: llmName,\n\t\t})\n\t}\n\n\ts.render(w, \"team\", members)\n}\n\nfunc (s *Server) render(w http.ResponseWriter, name string, data any) {"}
1 occurrences replaced
{"query":"type Server struct","glob":"internal/web/web.go"}
/project/internal/web/web.go:20:type Server struct {
{"find":"// handleTeam renders the \"Meet the Team\" page with a card for each agent.\nfunc (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) {\n\tmembers := make([]teamMember, 0, len(s.cfg.Agents))\n\n\tfor _, a := range s.cfg.Agents {\n\t\tprofile, err := s.fg.AgentProfile(a.Name)\n\t\tif err != nil {\n\t\t\t// Log the error but don't fail the page — one missing profile\n\t\t\t// shouldn't hide the rest of the team.\n\t\t\ts.log.Warn(\"failed to fetch agent profile for team page\", \"agent\", a.Name, \"error\", err)\n\t\t}\n\n\t\tllmName := a.LLM\n\t\tif llmName == \"\" {\n\t\t\tllmName = \"—\"\n\t\t}\n\n\t\tmembers = append(members, teamMember{\n\t\t\tName: a.Name,\n\t\t\tDescription: profile.Description,\n\t\t\tFullName: profile.FullName,\n\t\t\tEmail: profile.Email,\n\t\t\tAvatarURL: profile.AvatarURL,\n\t\t\tLLMName: llmName,\n\t\t})\n\t}\n\n\ts.render(w, \"team\", members)\n}","path":"internal/web/web.go","replace":"// handleTeam renders the \"Meet the Team\" page with a card for each agent.\nfunc (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) {\n\tmembers := make([]teamMember, 0, len(s.cfg.Agents))\n\n\tfor _, a := range s.cfg.Agents {\n\t\tprofile, err := s.fg.AgentProfile(a.Name)\n\t\tif err != nil {\n\t\t\t// One missing profile shouldn't hide the rest of the team.\n\t\t\tprofile = forgejo.AgentProfile{}\n\t\t}\n\n\t\tllmName := a.LLM\n\t\tif llmName == \"\" {\n\t\t\tllmName = \"—\"\n\t\t}\n\n\t\tmembers = append(members, teamMember{\n\t\t\tName: a.Name,\n\t\t\tDescription: profile.Description,\n\t\t\tFullName: profile.FullName,\n\t\t\tEmail: profile.Email,\n\t\t\tAvatarURL: profile.AvatarURL,\n\t\t\tLLMName: llmName,\n\t\t})\n\t}\n\n\ts.render(w, \"team\", members)\n}"}
1 occurrences replaced
{"find":" /* ── Scrollbar ──────────────────────────────── */\n ::-webkit-scrollbar { width: 8px; height: 8px; }\n ::-webkit-scrollbar-track { background: transparent; }\n ::-webkit-scrollbar-thumb {\n background: var(--border);\n border-radius: 4px;\n }\n ::-webkit-scrollbar-thumb:hover { background: #3a3a50; }","path":"internal/web/templates.go","replace":" /* ── Scrollbar ──────────────────────────────── */\n ::-webkit-scrollbar { width: 8px; height: 8px; }\n ::-webkit-scrollbar-track { background: transparent; }\n ::-webkit-scrollbar-thumb {\n background: var(--border);\n border-radius: 4px;\n }\n ::-webkit-scrollbar-thumb:hover { background: #3a3a50; }\n\n /* ── Team page ──────────────────────────────── */\n .team-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));\n gap: 1.25rem;\n }\n\n .team-card {\n background: var(--bg-surface);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.5rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;\n position: relative;\n overflow: hidden;\n }\n\n .team-card::before {\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n height: 3px;\n background: linear-gradient(90deg, var(--accent), #a78bfa, #c084fc);\n opacity: 0;\n transition: opacity 0.2s ease;\n }\n\n .team-card:hover {\n border-color: #3a3a50;\n box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);\n transform: translateY(-2px);\n }\n\n .team-card:hover::before {\n opacity: 1;\n }\n\n .team-card-header {\n display: flex;\n align-items: center;\n gap: 1rem;\n }\n\n .team-avatar {\n width: 56px;\n height: 56px;\n border-radius: 50%;\n border: 2px solid var(--border);\n background: var(--bg-code);\n flex-shrink: 0;\n object-fit: cover;\n }\n\n .team-avatar-placeholder {\n width: 56px;\n height: 56px;\n border-radius: 50%;\n border: 2px solid var(--border);\n background: linear-gradient(135deg, var(--accent), #a78bfa);\n flex-shrink: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 1.5rem;\n font-weight: 700;\n color: #fff;\n }\n\n .team-name {\n font-size: 1.15rem;\n font-weight: 700;\n color: var(--text);\n margin: 0;\n line-height: 1.3;\n }\n\n .team-real-name {\n font-size: 0.85rem;\n color: var(--text-muted);\n margin: 0.15rem 0 0;\n }\n\n .team-description {\n font-size: 0.9rem;\n color: var(--text-muted);\n line-height: 1.55;\n margin: 0;\n }\n\n .team-tags {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n margin-top: auto;\n padding-top: 0.75rem;\n border-top: 1px solid var(--border);\n }\n\n .team-tag {\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n padding: 0.2rem 0.6rem;\n border-radius: 999px;\n font-size: 0.75rem;\n font-weight: 600;\n background: var(--bg-code);\n color: var(--text-muted);\n border: 1px solid var(--border);\n }\n\n .team-tag .tag-icon {\n font-size: 0.8rem;\n }\n\n .team-tag--llm {\n background: rgba(124, 106, 239, 0.1);\n color: var(--accent);\n border-color: rgba(124, 106, 239, 0.25);\n }\n\n .team-tag--name {\n background: rgba(34, 211, 238, 0.08);\n color: #22d3ee;\n border-color: rgba(34, 211, 238, 0.2);\n }\n\n .team-empty {\n text-align: center;\n padding: 3rem 1rem;\n color: var(--text-muted);\n }\n\n .team-empty .empty-icon {\n font-size: 3rem;\n margin-bottom: 1rem;\n opacity: 0.5;\n }"}
1 occurrences replaced
{"find":" </script>\n</div>\n</body>\n</html>\n{{end}}\n`","path":"internal/web/templates.go","replace":" </script>\n</div>\n</body>\n</html>\n{{end}}\n\n{{define \"team\"}}\n{{template \"layout_head\" .}}\n<div class=\"container\">\n <div class=\"page-header\">\n <h1>Meet the Team</h1>\n <p>The agents that make the zoo run — each one a unique AI-powered teammate.</p>\n </div>\n\n {{if .}}\n <div class=\"team-grid\">\n {{range .}}\n <div class=\"team-card\">\n <div class=\"team-card-header\">\n {{if .AvatarURL}}\n <img class=\"team-avatar\" src=\"{{.AvatarURL}}\" alt=\"{{.Name}}\" title=\"{{.Name}}\" loading=\"lazy\">\n {{else}}\n <div class=\"team-avatar-placeholder\" title=\"{{.Name}}\">{{index .Name 0}}</div>\n {{end}}\n <div>\n <h2 class=\"team-name\">{{.Name}}</h2>\n {{if .FullName}}\n <p class=\"team-real-name\">{{.FullName}}</p>\n {{end}}\n </div>\n </div>\n\n {{if .Description}}\n <p class=\"team-description\">{{.Description}}</p>\n {{else}}\n <p class=\"team-description\" style=\"font-style: italic; opacity: 0.7;\">No description set yet.</p>\n {{end}}\n\n <div class=\"team-tags\">\n {{if .LLMName}}\n <span class=\"team-tag team-tag--llm\">\n <span class=\"tag-icon\">🧠</span>\n {{.LLMName}}\n </span>\n {{end}}\n <span class=\"team-tag team-tag--name\">\n <span class=\"tag-icon\">@</span>\n {{.Name}}\n </span>\n </div>\n </div>\n {{end}}\n </div>\n {{else}}\n <div class=\"team-empty\">\n <div class=\"empty-icon\">🦓</div>\n <p>No agents configured yet.<br>Add agents to <code>zoo.hcl</code> to see them here.</p>\n </div>\n {{end}}\n</div>\n</body>\n</html>\n{{end}}\n`"}
ERROR CALLING TOOL: " </script>\n</div>\n</body>\n</html>\n{{end}}\n`" not found in "internal/web/templates.go"
{"end_line":1092,"path":"internal/web/templates.go","start_line":1080}
{"path":"internal/web/templates.go","content":" \u003c/div\u003e\n\n {{if .Live}}\n \u003cscript\u003e\n (function() {\n var jobID = {{.Job.ID}};\n var log = document.getElementById(\"log\");\n var jumpBtn = document.getElementById(\"log-jump\");\n var reasoningBody = null;\n var contentBody = null;\n\n // The log box (not the window) is what scrolls, so all scroll\n // math is done against it. While the view is pinned to the","start_line":1080,"end_line":1092,"total_lines":1236,"truncated":true}
{"command":"tail -30 /project/internal/web/templates.go"}
break;
case "content_delta":
if (contentBody) contentBody.textContent += ev.text;
break;
case "content_end":
if (contentBody) contentBody.textContent = contentBody.textContent.trim();
contentBody = null;
break;
case "tool":
newToolBlock(ev);
break;
case "system":
newBlock("system", null).textContent = ev.text;
break;
case "status":
es.close();
location.reload();
return;
}
follow();
};
})();
</script>
{{end}}
</div>
</body>
</html>
{{end}}
`
{"find":" follow();\n };\n })();\n </script>\n {{end}}\n</div>\n</body>\n</html>\n{{end}}\n`","path":"internal/web/templates.go","replace":" follow();\n };\n })();\n </script>\n {{end}}\n</div>\n</body>\n</html>\n{{end}}\n\n{{define \"team\"}}\n{{template \"layout_head\" .}}\n<div class=\"container\">\n <div class=\"page-header\">\n <h1>Meet the Team</h1>\n <p>The agents that make the zoo run — each one a unique AI-powered teammate.</p>\n </div>\n\n {{if .}}\n <div class=\"team-grid\">\n {{range .}}\n <div class=\"team-card\">\n <div class=\"team-card-header\">\n {{if .AvatarURL}}\n <img class=\"team-avatar\" src=\"{{.AvatarURL}}\" alt=\"{{.Name}}\" title=\"{{.Name}}\" loading=\"lazy\">\n {{else}}\n <div class=\"team-avatar-placeholder\" title=\"{{.Name}}\">{{index .Name 0}}</div>\n {{end}}\n <div>\n <h2 class=\"team-name\">{{.Name}}</h2>\n {{if .FullName}}\n <p class=\"team-real-name\">{{.FullName}}</p>\n {{end}}\n </div>\n </div>\n\n {{if .Description}}\n <p class=\"team-description\">{{.Description}}</p>\n {{else}}\n <p class=\"team-description\" style=\"font-style: italic; opacity: 0.7;\">No description set yet.</p>\n {{end}}\n\n <div class=\"team-tags\">\n {{if .LLMName}}\n <span class=\"team-tag team-tag--llm\">\n <span class=\"tag-icon\">🧠</span>\n {{.LLMName}}\n </span>\n {{end}}\n <span class=\"team-tag team-tag--name\">\n <span class=\"tag-icon\">@</span>\n {{.Name}}\n </span>\n </div>\n </div>\n {{end}}\n </div>\n {{else}}\n <div class=\"team-empty\">\n <div class=\"empty-icon\">🦓</div>\n <p>No agents configured yet.<br>Add agents to <code>zoo.hcl</code> to see them here.</p>\n </div>\n {{end}}\n</div>\n</body>\n</html>\n{{end}}\n`"}
1 occurrences replaced
{"query":"<nav>.*</nav>","glob":"internal/web/templates.go","context_after":5}
No matches found.
{"query":"Dashboard</nav>","glob":"internal/web/templates.go","context_after":3}
No matches found.
{"query":"Dashboard","glob":"internal/web/templates.go","context_after":3}
/project/internal/web/templates.go:833: <a href="/">Dashboard</a> /project/internal/web/templates.go-834- <a href="/jobs">Jobs</a> /project/internal/web/templates.go-835- </div> /project/internal/web/templates.go-836-</nav> -- /project/internal/web/templates.go:843: <h1>Dashboard</h1> /project/internal/web/templates.go-844- <p>Overview of your zoo configuration and running agents.</p> /project/internal/web/templates.go-845- </div> /project/internal/web/templates.go-846- -- /project/internal/web/templates.go:936: // Live dashboard: the server pushes a signal whenever the set of /project/internal/web/templates.go-937- // active jobs changes (job created, started, or finished). The page /project/internal/web/templates.go-938- // keeps no state of its own, so the right response to any signal is /project/internal/web/templates.go-939- // to re-render from scratch — the same pattern the job detail page
{"find":" <a href=\"/\">Dashboard</a>\n <a href=\"/jobs\">Jobs</a>\n </div>\n</nav>","path":"internal/web/templates.go","replace":" <a href=\"/\">Dashboard</a>\n <a href=\"/jobs\">Jobs</a>\n <a href=\"/team\">Team</a>\n </div>\n</nav>"}
1 occurrences replaced
{"command":"cd /project && go build ./..."}
go: downloading github.com/docker/docker v28.5.2+incompatible go: downloading github.com/hashicorp/hcl/v2 v2.24.0 go: downloading codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 go: downloading modernc.org/sqlite v1.56.0 go: downloading github.com/google/uuid v1.6.0 go: downloading github.com/docker/go-connections v0.8.1 go: downloading github.com/docker/go-units v0.5.0 go: downloading github.com/moby/docker-image-spec v1.3.1 go: downloading github.com/opencontainers/image-spec v1.1.1 go: downloading github.com/containerd/errdefs v1.0.0 go: downloading github.com/containerd/errdefs/pkg v0.3.0 go: downloading github.com/distribution/reference v0.6.0 go: downloading github.com/opencontainers/go-digest v1.0.0 go: downloading github.com/pkg/errors v0.9.1 go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go: downloading go.opentelemetry.io/otel/trace v1.45.0 go: downloading go.opentelemetry.io/otel v1.45.0 go: downloading github.com/42wim/httpsig v1.2.3 go: downloading github.com/go-fed/httpsig v1.1.0 go: downloading github.com/hashicorp/go-version v1.7.0 go: downloading golang.org/x/crypto v0.39.0 go: downloading github.com/felixge/httpsnoop v1.1.0 go: downloading go.opentelemetry.io/otel/metric v1.45.0 go: downloading github.com/agext/levenshtein v1.2.1 go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0 go: downloading github.com/mitchellh/go-wordwrap v1.0.1 go: downloading github.com/zclconf/go-cty v1.16.3 go: downloading github.com/go-logr/logr v1.4.4 go: downloading github.com/google/go-cmp v0.7.0 go: downloading golang.org/x/sys v0.47.0 go: downloading github.com/go-logr/stdr v1.2.2 go: downloading go.opentelemetry.io/auto/sdk v1.2.1 go: downloading github.com/cespare/xxhash/v2 v2.3.0 go: downloading modernc.org/libc v1.74.4 go: downloading golang.org/x/text v0.40.0 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
{"command":"cd /project && go test ./... 2>&1 | head -50"}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.023s ok github.com/abrander/zoo/internal/config 0.005s ok github.com/abrander/zoo/internal/forgejo 1.078s ok github.com/abrander/zoo/internal/livelog 0.052s ok github.com/abrander/zoo/internal/llm 0.003s ok github.com/abrander/zoo/internal/scheduler 3.381s ok github.com/abrander/zoo/internal/store 1.911s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 1.979s
{"command":"cd /project && git diff --stat"}
internal/web/templates.go | 203 ++++++++++++++++++++++++++++++++++++++++++++++ internal/web/web.go | 40 +++++++++ 2 files changed, 243 insertions(+)
{"command":"cd /project && git diff internal/web/web.go"}
diff --git a/internal/web/web.go b/internal/web/web.go
index c97b950..8a4ba5b 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -60,6 +60,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /jobs/{id}", s.handleJobDetail)
mux.HandleFunc("GET /jobs/{id}/events", s.handleJobEvents)
mux.HandleFunc("GET /events", s.handleEvents)
+ mux.HandleFunc("GET /team", s.handleTeam)
return s.authMiddleware(mux)
}
@@ -413,6 +414,45 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
}
}
+// teamMember is an agent's Forgejo profile, enriched for the team page.
+type teamMember struct {
+ Name string
+ Description string
+ FullName string
+ Email string
+ AvatarURL string
+ LLMName string
+}
+
+// handleTeam renders the "Meet the Team" page with a card for each agent.
+func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) {
+ members := make([]teamMember, 0, len(s.cfg.Agents))
+
+ for _, a := range s.cfg.Agents {
+ profile, err := s.fg.AgentProfile(a.Name)
+ if err != nil {
+ // One missing profile shouldn't hide the rest of the team.
+ profile = forgejo.AgentProfile{}
+ }
+
+ llmName := a.LLM
+ if llmName == "" {
+ llmName = "—"
+ }
+
+ members = append(members, teamMember{
+ Name: a.Name,
+ Description: profile.Description,
+ FullName: profile.FullName,
+ Email: profile.Email,
+ AvatarURL: profile.AvatarURL,
+ LLMName: llmName,
+ })
+ }
+
+ s.render(w, "team", members)
+}
+
func (s *Server) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
{"command":"cd /project && git diff internal/web/templates.go | head -150"}
diff --git a/internal/web/templates.go b/internal/web/templates.go
index 9ab7ff0..22f9ff7 100644
--- a/internal/web/templates.go
+++ b/internal/web/templates.go
@@ -677,6 +677,150 @@ const templates = `
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover { background: #3a3a50; }
+
+ /* ── Team page ──────────────────────────────── */
+ .team-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
+ gap: 1.25rem;
+ }
+
+ .team-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .team-card::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: linear-gradient(90deg, var(--accent), #a78bfa, #c084fc);
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ }
+
+ .team-card:hover {
+ border-color: #3a3a50;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
+ transform: translateY(-2px);
+ }
+
+ .team-card:hover::before {
+ opacity: 1;
+ }
+
+ .team-card-header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ }
+
+ .team-avatar {
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ border: 2px solid var(--border);
+ background: var(--bg-code);
+ flex-shrink: 0;
+ object-fit: cover;
+ }
+
+ .team-avatar-placeholder {
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ border: 2px solid var(--border);
+ background: linear-gradient(135deg, var(--accent), #a78bfa);
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #fff;
+ }
+
+ .team-name {
+ font-size: 1.15rem;
+ font-weight: 700;
+ color: var(--text);
+ margin: 0;
+ line-height: 1.3;
+ }
+
+ .team-real-name {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin: 0.15rem 0 0;
+ }
+
+ .team-description {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ line-height: 1.55;
+ margin: 0;
+ }
+
+ .team-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: auto;
+ padding-top: 0.75rem;
+ border-top: 1px solid var(--border);
+ }
+
+ .team-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ padding: 0.2rem 0.6rem;
+ border-radius: 999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ background: var(--bg-code);
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+ }
+
+ .team-tag .tag-icon {
+ font-size: 0.8rem;
+ }
+
+ .team-tag--llm {
+ background: rgba(124, 106, 239, 0.1);
+ color: var(--accent);
+ border-color: rgba(124, 106, 239, 0.25);
+ }
+
+ .team-tag--name {
+ background: rgba(34, 211, 238, 0.08);
+ color: #22d3ee;
+ border-color: rgba(34, 211, 238, 0.2);
+ }
+
+ .team-empty {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: var(--text-muted);
+ }
+
+ .team-empty .empty-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
{"command":"cd /project && git diff internal/web/templates.go | tail -100"}
+ font-size: 0.8rem;
+ }
+
+ .team-tag--llm {
+ background: rgba(124, 106, 239, 0.1);
+ color: var(--accent);
+ border-color: rgba(124, 106, 239, 0.25);
+ }
+
+ .team-tag--name {
+ background: rgba(34, 211, 238, 0.08);
+ color: #22d3ee;
+ border-color: rgba(34, 211, 238, 0.2);
+ }
+
+ .team-empty {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: var(--text-muted);
+ }
+
+ .team-empty .empty-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
+ opacity: 0.5;
+ }
</style>
</head>
<body>
@@ -688,6 +832,7 @@ const templates = `
<div class="links">
<a href="/">Dashboard</a>
<a href="/jobs">Jobs</a>
+ <a href="/team">Team</a>
</div>
</nav>
{{end}}
@@ -1089,4 +1234,62 @@ const templates = `
</body>
</html>
{{end}}
+
+{{define "team"}}
+{{template "layout_head" .}}
+<div class="container">
+ <div class="page-header">
+ <h1>Meet the Team</h1>
+ <p>The agents that make the zoo run — each one a unique AI-powered teammate.</p>
+ </div>
+
+ {{if .}}
+ <div class="team-grid">
+ {{range .}}
+ <div class="team-card">
+ <div class="team-card-header">
+ {{if .AvatarURL}}
+ <img class="team-avatar" src="{{.AvatarURL}}" alt="{{.Name}}" title="{{.Name}}" loading="lazy">
+ {{else}}
+ <div class="team-avatar-placeholder" title="{{.Name}}">{{index .Name 0}}</div>
+ {{end}}
+ <div>
+ <h2 class="team-name">{{.Name}}</h2>
+ {{if .FullName}}
+ <p class="team-real-name">{{.FullName}}</p>
+ {{end}}
+ </div>
+ </div>
+
+ {{if .Description}}
+ <p class="team-description">{{.Description}}</p>
+ {{else}}
+ <p class="team-description" style="font-style: italic; opacity: 0.7;">No description set yet.</p>
+ {{end}}
+
+ <div class="team-tags">
+ {{if .LLMName}}
+ <span class="team-tag team-tag--llm">
+ <span class="tag-icon">🧠</span>
+ {{.LLMName}}
+ </span>
+ {{end}}
+ <span class="team-tag team-tag--name">
+ <span class="tag-icon">@</span>
+ {{.Name}}
+ </span>
+ </div>
+ </div>
+ {{end}}
+ </div>
+ {{else}}
+ <div class="team-empty">
+ <div class="empty-icon">🦓</div>
+ <p>No agents configured yet.<br>Add agents to <code>zoo.hcl</code> to see them here.</p>
+ </div>
+ {{end}}
+</div>
+</body>
+</html>
+{{end}}
`
{"command":"cd /project && git add -A && git commit -m \"Add Meet the Team page with agent cards\n\n- New /team route showing all configured agents as beautiful cards\n- Each card displays: avatar, agent name, real name, description, LLM model\n- Two-column responsive grid layout (half page width cards)\n- Hover effects with gradient top border and lift animation\n- Fallback avatar placeholder when no avatar URL available\n- Team link added to navigation bar\n- Empty state message when no agents are configured\""}
[issue-70-anna 86b7967] Add Meet the Team page with agent cards 2 files changed, 243 insertions(+)
{"command":"cd /project && git push origin issue-70-anna"}
To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-70-anna -> issue-70-anna
{"base":"main","body":"## ✨ Meet the Team page\n\nThis PR adds a new \"Meet the Team\" page at `/team` that displays all configured agents as beautiful cards in a responsive grid layout.\n\n### Features\n- **Card layout**: Each agent gets a card showing their avatar, agent name, real name, description, and LLM model\n- **Responsive grid**: Cards are roughly half page width (two per line on desktop), stacking to single column on mobile\n- **Hover effects**: Cards lift and show a gradient top border on hover\n- **Fallback avatars**: When no avatar URL is available, a styled placeholder with the agent's initial letter is shown\n- **Empty state**: Graceful message when no agents are configured\n- **Navigation**: New \"Team\" link in the top nav bar\n\n### Design choices\n- Dark theme consistent with the existing dashboard\n- Gradient accent bar on hover for visual feedback\n- Tags at the bottom of each card for LLM model and agent name\n- Uses the same CSS variables and design tokens as the rest of the app\n\n### Files changed\n- `internal/web/web.go`: Added `/team` route and handler\n- `internal/web/templates.go`: Added CSS styles and team page template","head":"issue-70-anna","title":"Add Meet the Team page with agent cards"}
Pull request opened
{"body":"Done! I've added a \"Meet the Team\" page at `/team` with beautiful cards for each agent. 🎨\n\n**Features:**\n- Responsive two-column grid layout (half page width cards as requested)\n- Each card shows: avatar, agent name, real name, description, and LLM model\n- Hover effects with gradient top border and subtle lift animation\n- Fallback avatar placeholder when no avatar is available\n- \"Team\" link added to the navigation bar\n\nYou can check it out in the PR: https://code.stdio.dk/abrander/zoo/pulls/71"}
Comment posted