Status failed
Event issue:new on abrander/zoo#16
Agent leon leon
Error orphaned: process restarted while job was running

Log

Thinking
Let me understand this task. The issue says: "I want per-agent tokens. This Sudo: shit is driving me insane. Let's create a token for each agent and skip the global Forgejo token. That way we can hand the token to the agents and they can just be themselves." So this is a feature request for the `zoo` project — the system that launches agents from Forgejo events. The request is to support per-agent tokens (each agent has its own Forgejo token, i.e., its own account/identity), rather than using a single global Forgejo token. I'm "leon", the Engineering Manager agent. The actual implementation work is probably greg's (senior developer & architect) or mika's (junior developer) job. But first, let me look at the codebase to understand what "zoo" is and how tokens are currently being handled. Let me explore the project.
Tool 🔧 list_files {"path":".","recursive":true}
{"path":".","recursive":true}

        
Tool 🔧 bash {"command":"cat /event | head -50"}
{"command":"cat /event | head -50"}
{"id":45,"url":"https://code.stdio.dk/api/v1/repos/abrander/zoo/issues/16","html_url":"https://code.stdio.dk/abrander/zoo/issues/16","number":16,"user":{"id":1,"login":"abrander","login_name":"","source_id":0,"full_name":"Anders Brander","email":"anders@brander.dk","html_url":"https://code.stdio.dk/abrander","avatar_url":"https://code.stdio.dk/avatar/d87ec4c86bb6c9d188d5e32e2424583d","language":"en-US","is_admin":true,"last_login":"2026-08-23T02:40:28+02:00","created":"2025-03-27T21:24:00+01:00","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"Real human :-)\r\n\r\nI manage all this. Talk to me if you get stuck.","visibility":"limited","followers_count":0,"following_count":0,"starred_repos_count":0},"original_author":"","original_author_id":0,"title":"Let us have per agent tokens","body":"This Sudo: shit is driving me insane. Let's have a token for each agent and skip the global Forgejo token. Then we can give the agents the token, and they can just be themselfes.","ref":"","labels":[],"milestone":null,"assignees":null,"state":"open","is_locked":false,"comments":0,"created_at":"2026-08-23T03:59:43+02:00","updated_at":"2026-08-23T03:59:43+02:00","closed_at":null,"due_date":null,"pull_request":null,"repository":{"id":6,"name":"zoo","owner":"abrander","full_name":"abrander/zoo"}}
Thinking
The list_files command returned empty. Let's check the /project directory directly.
Tool 🔧 bash {"command":"ls -la /project && git -C /project log --oneline -10 && git -C /project branch -a"}
{"command":"ls -la /project && git -C /project log --oneline -10 && git -C /project branch -a"}
total 52
drwxrwxr-x  5 1000 1000  4096 Aug 23 01:59 .
drwxr-xr-x  1 root root  4096 Aug 23 01:59 ..
drwxrwxr-x  8 1000 1000  4096 Aug 23 01:59 .git
-rw-rw-r--  1 1000 1000    22 Aug 23 01:59 .gitignore
drwxrwxr-x  3 1000 1000  4096 Aug 23 01:59 cmd
-rw-rw-r--  1 1000 1000  2628 Aug 23 01:59 go.mod
-rw-rw-r--  1 1000 1000 16578 Aug 23 01:59 go.sum
drwxrwxr-x 11 1000 1000  4096 Aug 23 01:59 internal
-rw-rw-r--  1 1000 1000  1415 Aug 23 01:59 zoo.hcl.example
809faf8 (HEAD -> issue-16-leon, origin/main, origin/HEAD, main) Less agents in example.
c3edf5a Add live streaming of job spying.
7e30176 Merge pull request 'Better container names: zoo-issue-{index}-{agent}' (#7) from issue-6-greg into main
1e3eb24 Add meaningful container names to Docker container creation
17cd966 Try to not let the bash tool get stuck on pager.
387906c Do not put the secret token inside the fricking sandbox.
ec7c296 More job logging.
1fff760 Try to reap jobs if stuck.
d1315ed redesign: modern dark-themed UI for zoo dashboard
9e06288 Init.
* issue-16-leon
  main
  remotes/origin/HEAD -> origin/main
  remotes/origin/main
Thinking
The repository is the `zoo` system itself — a Go project. Let's investigate the structure to understand how tokens are currently being handled.
Tool 🔧 bash {"command":"find /project -type f -name \"*.go\" | head -50 && echo \"---\" && cat /project/zoo.hcl.example && echo \"---\" && cat /project/.gitignore"}
{"command":"find /project -type f -name \"*.go\" | head -50 && echo \"---\" && cat /project/zoo.hcl.example && echo \"---\" && cat /project/.gitignore"}
/project/cmd/zoo/main.go
/project/internal/config/config.go
/project/internal/config/config_test.go
/project/internal/web/templates.go
/project/internal/web/web_test.go
/project/internal/web/web.go
/project/internal/livelog/hub.go
/project/internal/livelog/hub_test.go
/project/internal/tools/forgejo.go
/project/internal/tools/grep_search.go
/project/internal/tools/context.go
/project/internal/tools/remove_file.go
/project/internal/tools/registry.go
/project/internal/tools/write_file.go
/project/internal/tools/move_file.go
/project/internal/tools/read_file.go
/project/internal/tools/git.go
/project/internal/tools/list_files.go
/project/internal/tools/bash.go
/project/internal/tools/shell.go
/project/internal/tools/tools_test.go
/project/internal/llm/streamer_test.go
/project/internal/llm/client.go
/project/internal/llm/streamer.go
/project/internal/llm/types.go
/project/internal/store/store.go
/project/internal/store/store_test.go
/project/internal/forgejo/poll.go
/project/internal/forgejo/watcher.go
/project/internal/forgejo/webhook_test.go
/project/internal/forgejo/webhook.go
/project/internal/forgejo/client.go
/project/internal/forgejo/event.go
/project/internal/agentrun/forgejo.go
/project/internal/agentrun/roster_test.go
/project/internal/agentrun/context.go
/project/internal/agentrun/loop.go
/project/internal/agentrun/clone.go
/project/internal/agentrun/docker.go
/project/internal/agentrun/seed_test.go
/project/internal/agentrun/docker_integration_test.go
/project/internal/agentrun/run.go
/project/internal/agentrun/roster.go
/project/internal/scheduler/scheduler.go
/project/internal/scheduler/scheduler_test.go
---
# zoo.hcl - Configuration for the zoo daemon.
#
# Copy this file to zoo.hcl (gitignored) and fill in real values.

llm "Qwen 3.6" {
    openai = "https://code.stdio.dk"
    token  = "TOKEN"
    model  = "Qwen3.6"
}

llm "Qwen 3.8" {
    openai = "https://code.stdio.dk"
    token  = "TOKEN"
    model  = "Qwen3.8"
}

forgejo {
    url            = "https://code.stdio.dk"
    token          = "EXAMPLE_TOKEN"
    # Shared secret configured on the Forgejo webhook. Required to accept
    # webhook deliveries; polling works without it.
    webhook_secret = "EXAMPLE_WEBHOOK_SECRET"
}

max_live_agents = 5

environment {
    docker_image = "golang:latest"
}

agent "anna" {
    llm = "Qwen 3.6"
}

agent "greg" {
    llm = "Qwen 3.8"
}

# "leon" doubles as the expected Forgejo assignee username for
# issue:assigned events - see the event block below.
agent "leon" {
    llm = "Qwen 3.8"
}

agent "mika" {
    llm = "Qwen 3.6"
}

event "issue:new" {
    agent = "leon"
}

event "issue:assigned" {
    // No `agent` here: the agent whose name matches the Forgejo
    // assignee's username is triggered.
    instructions = "Please handle this issue."
}

event "issue:comment" {
    agent        = "leon"
    instructions = "Please review what this comment says and respond appropriately."
}

event "pr:new" {
    agent        = "greg"
    instructions = "Please review this pull request and respond appropriately."
}
---
/zoo
/zoo.hcl
/zoo.db
Thinking
Now I understand: `zoo` is a Go daemon that monitors Forgejo events, launches agents inside Docker containers, and the agents use tools including Forgejo tools (comment, open_pull_request, etc.). Currently, there's a single global Forgejo token configured in `zoo.hcl` under `forgejo { token = ... }`. The request: per-agent tokens — each agent has its own Forgejo token (its own account), so the agent acts as itself rather than as the global token user. Let me read the relevant files: 1. `internal/config/config.go` — configuration parsing 2. `internal/forgejo/client.go` — Forgejo API client 3. `internal/tools/forgejo.go` — Forgejo tools used by the agent 4. `internal/agentrun/run.go`, `context.go`, `loop.go` — how the agent is launched 5. `cmd/zoo/main.go` — wiring Let me read all of these.
Tool 🔧 read_file {"path":"/project/internal/config/config.go","start_line":1,"end_line":400}
{"path":"/project/internal/config/config.go","start_line":1,"end_line":400}
ERROR CALLING TOOL: read "/project/internal/config/config.go": command exited with status 1
Tool 🔧 read_file {"path":"/project/internal/forgejo/client.go","start_line":1,"end_line":200}
{"path":"/project/internal/forgejo/client.go","start_line":1,"end_line":200}
ERROR CALLING TOOL: read "/project/internal/forgejo/client.go": command exited with status 1
Thinking
Weird error. Let me try reading via bash.
Tool 🔧 bash {"command":"wc -l /project/internal/config/config.go /project/internal/forgejo/client.go /project/internal/tools/forgejo.go /project/internal/agentrun/run.go /project/internal/agentrun/context.go /project/cmd/zoo/main.go /project/internal/scheduler/scheduler.go /project/internal/agentrun/roster.go"}
{"command":"wc -l /project/internal/config/config.go /project/internal/forgejo/client.go /project/internal/tools/forgejo.go /project/internal/agentrun/run.go /project/internal/agentrun/context.go /project/cmd/zoo/main.go /project/internal/scheduler/scheduler.go /project/internal/agentrun/roster.go"}
  191 /project/internal/config/config.go
  232 /project/internal/forgejo/client.go
  175 /project/internal/tools/forgejo.go
  308 /project/internal/agentrun/run.go
   54 /project/internal/agentrun/context.go
  131 /project/cmd/zoo/main.go
  240 /project/internal/scheduler/scheduler.go
  105 /project/internal/agentrun/roster.go
 1436 total
Thinking
The file exists, but read_file failed with a strange error. Maybe it's a temporary issue. Let me try again.
Tool 🔧 read_file {"end_line":191,"path":"/project/internal/config/config.go","start_line":1}
{"end_line":191,"path":"/project/internal/config/config.go","start_line":1}
ERROR CALLING TOOL: read "/project/internal/config/config.go": command exited with status 1
Thinking
The read_file tool is failing. Let me use bash's cat instead.
Tool 🔧 bash {"command":"cat /project/internal/config/config.go"}
{"command":"cat /project/internal/config/config.go"}
// Package config loads and validates zoo's HCL configuration file.
package config

import (
	"fmt"

	"github.com/hashicorp/hcl/v2/hclsimple"
)

// Known event kinds. issue:assigned is resolved dynamically (agent name
// must match the Forgejo assignee's username) so it never carries an
// `agent` attribute; the rest map statically to one configured agent.
const (
	EventIssueNew      = "issue:new"
	EventIssueComment  = "issue:comment"
	EventIssueAssigned = "issue:assigned"
	EventPRNew         = "pr:new"
)

var staticEventKinds = map[string]bool{
	EventIssueNew:     true,
	EventIssueComment: true,
	EventPRNew:        true,
}

type Config struct {
	LLMs        []LLM       `hcl:"llm,block"`
	Forgejo     Forgejo     `hcl:"forgejo,block"`
	MaxLive     int         `hcl:"max_live_agents"`
	Environment Environment `hcl:"environment,block"`
	Agents      []Agent     `hcl:"agent,block"`
	Events      []Event     `hcl:"event,block"`
	Web         *Web        `hcl:"web,block"`
}

// Web configures the dashboard's optional bearer-token gate. Leave the
// block out of zoo.hcl entirely to run without one (fine on localhost;
// put a real gate or a proxy in front for anything else).
type Web struct {
	Token string `hcl:"token,optional"`
}

type LLM struct {
	Name   string `hcl:"name,label"`
	OpenAI string `hcl:"openai"`
	Token  string `hcl:"token"`
	Model  string `hcl:"model"`
}

type Forgejo struct {
	URL           string `hcl:"url"`
	Token         string `hcl:"token"`
	WebhookSecret string `hcl:"webhook_secret,optional"`
}

type Environment struct {
	DockerImage string `hcl:"docker_image"`
}

type Agent struct {
	Name string `hcl:"name,label"`
	LLM  string `hcl:"llm"`
}

type Event struct {
	Kind         string `hcl:"name,label"`
	Agent        string `hcl:"agent,optional"`
	Instructions string `hcl:"instructions,optional"`
}

// Load reads and validates the config file at path.
func Load(path string) (*Config, error) {
	var cfg Config

	if err := hclsimple.DecodeFile(path, nil, &cfg); err != nil {
		return nil, fmt.Errorf("parse config: %w", err)
	}

	if err := cfg.Validate(); err != nil {
		return nil, fmt.Errorf("invalid config: %w", err)
	}

	return &cfg, nil
}

// Validate checks that the config is internally consistent: every
// reference between blocks resolves, and required values are set.
func (c *Config) Validate() error {
	llmNames := make(map[string]bool, len(c.LLMs))
	for _, l := range c.LLMs {
		if l.OpenAI == "" || l.Token == "" || l.Model == "" {
			return fmt.Errorf("llm %q: openai, token, and model are required", l.Name)
		}
		llmNames[l.Name] = true
	}

	if c.Forgejo.URL == "" || c.Forgejo.Token == "" {
		return fmt.Errorf("forgejo: url and token are required")
	}

	if c.MaxLive < 1 {
		return fmt.Errorf("max_live_agents must be >= 1, got %d", c.MaxLive)
	}

	if c.Environment.DockerImage == "" {
		return fmt.Errorf("environment: docker_image is required")
	}

	agentNames := make(map[string]bool, len(c.Agents))
	for _, a := range c.Agents {
		if !llmNames[a.LLM] {
			return fmt.Errorf("agent %q: references undeclared llm %q", a.Name, a.LLM)
		}
		agentNames[a.Name] = true
	}

	seenEventKinds := make(map[string]bool, len(c.Events))
	for _, e := range c.Events {
		if seenEventKinds[e.Kind] {
			return fmt.Errorf("event %q: declared more than once", e.Kind)
		}
		seenEventKinds[e.Kind] = true

		switch {
		case e.Kind == EventIssueAssigned:
			if e.Agent != "" {
				return fmt.Errorf("event %q: must not set agent; the agent whose name matches the Forgejo assignee's username is triggered dynamically", e.Kind)
			}

		case staticEventKinds[e.Kind]:
			if e.Agent == "" {
				return fmt.Errorf("event %q: agent is required", e.Kind)
			}
			if !agentNames[e.Agent] {
				return fmt.Errorf("event %q: references undeclared agent %q", e.Kind, e.Agent)
			}

		default:
			return fmt.Errorf("event %q: unknown event kind", e.Kind)
		}
	}

	return nil
}

// AgentByName returns the agent config with the given name, if declared.
func (c *Config) AgentByName(name string) (Agent, bool) {
	for _, a := range c.Agents {
		if a.Name == name {
			return a, true
		}
	}

	return Agent{}, false
}

// LLMByName returns the llm config with the given name, if declared.
func (c *Config) LLMByName(name string) (LLM, bool) {
	for _, l := range c.LLMs {
		if l.Name == name {
			return l, true
		}
	}

	return LLM{}, false
}

// EventAgent returns the statically configured agent name for the given
// event kind, if one is declared and set.
func (c *Config) EventAgent(kind string) (string, bool) {
	for _, e := range c.Events {
		if e.Kind == kind {
			return e.Agent, e.Agent != ""
		}
	}

	return "", false
}

// EventInstructions returns the configured freeform instructions for the
// given event kind, e.g. "Please review this pull request and respond
// appropriately." Returns "" if the event isn't declared or has none.
func (c *Config) EventInstructions(kind string) string {
	for _, e := range c.Events {
		if e.Kind == kind {
			return e.Instructions
		}
	}

	return ""
}
Thinking
Interesting — the config has `Forgejo { URL, Token, WebhookSecret }` — a single global token. And `Agent { Name, LLM }`. Next, let me read the forgejo client, tools/forgejo.go, agentrun/run.go, context.go, main.go, scheduler.go, roster.go.
Tool 🔧 bash {"command":"cat /project/internal/forgejo/client.go"}
{"command":"cat /project/internal/forgejo/client.go"}
// Package forgejo wraps the Forgejo SDK and turns webhook deliveries and
// polling results into a common Event stream for the scheduler.
package forgejo

import (
	"fmt"

	sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"

	"github.com/abrander/zoo/internal/config"
)

// Client is zoo's single shared Forgejo identity, used both for the
// event sources (webhook/poll) and for actions agents/scheduler take
// (comments, labels, PRs).
type Client struct {
	sdk *sdk.Client

	baseURL string
	token   string
}

func NewClient(cfg config.Forgejo) (*Client, error) {
	c, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))
	if err != nil {
		return nil, fmt.Errorf("forgejo client: %w", err)
	}

	return &Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil
}

// Token returns the shared zoo Forgejo identity's token, e.g. for
// authenticating a host-side git clone/push against Forgejo (see
// internal/agentrun) without ever writing the credential into a working
// tree an agent's container can read.
func (c *Client) Token() string {
	return c.token
}

// Sudo returns a new Client that impersonates username (via Forgejo's
// "Sudo:" header) on every API call it makes, using the same underlying
// token. Actions an agent takes through it — comments, labels, PRs,
// assignment — are attributed to that agent's own Forgejo account
// instead of the shared zoo identity. The token must belong to a user
// with sudo scope/admin rights for this to work; Forgejo rejects the
// header otherwise.
//
// The SDK's Sudo setting lives on the *sdk.Client itself and isn't
// safe to flip per-request on a shared client under concurrent agent
// runs, so this constructs a separate client rather than mutating one.
func (c *Client) Sudo(username string) (*Client, error) {
	sudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))
	if err != nil {
		return nil, fmt.Errorf("forgejo client sudo %q: %w", username, err)
	}

	return &Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil
}

// CreateIssueComment posts a comment on the given issue or pull request
// (Forgejo/Gitea treat PRs as issues for commenting purposes).
func (c *Client) CreateIssueComment(owner, repo string, index int64, body string) error {
	_, _, err := c.sdk.CreateIssueComment(owner, repo, index, sdk.CreateIssueCommentOption{Body: body})
	if err != nil {
		return fmt.Errorf("comment on %s/%s#%d: %w", owner, repo, index, err)
	}

	return nil
}

// AddLabel attaches the label with the given name to an issue/PR,
// creating the label (with a default color) on the repo first if it
// doesn't already exist.
func (c *Client) AddLabel(owner, repo string, index int64, name string) error {
	id, err := c.labelID(owner, repo, name)
	if err != nil {
		return err
	}

	_, _, err = c.sdk.AddIssueLabels(owner, repo, index, sdk.IssueLabelsOption{Labels: []int64{id}})
	if err != nil {
		return fmt.Errorf("add label %q to %s/%s#%d: %w", name, owner, repo, index, err)
	}

	return nil
}

// RemoveLabel detaches the label with the given name from an issue/PR, if
// both the label and the attachment exist.
func (c *Client) RemoveLabel(owner, repo string, index int64, name string) error {
	labels, _, err := c.sdk.GetIssueLabels(owner, repo, index, sdk.ListLabelsOptions{})
	if err != nil {
		return fmt.Errorf("list labels on %s/%s#%d: %w", owner, repo, index, err)
	}

	for _, l := range labels {
		if l.Name == name {
			_, err := c.sdk.DeleteIssueLabel(owner, repo, index, l.ID)
			if err != nil {
				return fmt.Errorf("remove label %q from %s/%s#%d: %w", name, owner, repo, index, err)
			}
			return nil
		}
	}

	return nil
}

func (c *Client) labelID(owner, repo, name string) (int64, error) {
	labels, _, err := c.sdk.ListRepoLabels(owner, repo, sdk.ListLabelsOptions{})
	if err != nil {
		return 0, fmt.Errorf("list labels on %s/%s: %w", owner, repo, err)
	}

	for _, l := range labels {
		if l.Name == name {
			return l.ID, nil
		}
	}

	created, _, err := c.sdk.CreateLabel(owner, repo, sdk.CreateLabelOption{
		Name:  name,
		Color: "#ee0000",
	})
	if err != nil {
		return 0, fmt.Errorf("create label %q on %s/%s: %w", name, owner, repo, err)
	}

	return created.ID, nil
}

// CreatePullRequest opens a PR from head into base.
func (c *Client) CreatePullRequest(owner, repo, head, base, title, body string) error {
	_, _, err := c.sdk.CreatePullRequest(owner, repo, sdk.CreatePullRequestOption{
		Head:  head,
		Base:  base,
		Title: title,
		Body:  body,
	})
	if err != nil {
		return fmt.Errorf("create pull request %s/%s %s->%s: %w", owner, repo, head, base, err)
	}

	return nil
}

// RequestReview asks the given users to review the pull request.
func (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {
	_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})
	if err != nil {
		return fmt.Errorf("request review on %s/%s#%d: %w", owner, repo, index, err)
	}

	return nil
}

// CloseIssue closes the given issue or pull request.
func (c *Client) CloseIssue(owner, repo string, index int64) error {
	return c.setIssueState(owner, repo, index, sdk.StateClosed)
}

// ReopenIssue reopens the given issue or pull request.
func (c *Client) ReopenIssue(owner, repo string, index int64) error {
	return c.setIssueState(owner, repo, index, sdk.StateOpen)
}

// RepositoryInfo returns the pieces of repo metadata agentrun needs to
// clone and branch off of the right place.
type RepositoryInfo struct {
	DefaultBranch string
	CloneURL      string
}

func (c *Client) RepositoryInfo(owner, repo string) (RepositoryInfo, error) {
	r, _, err := c.sdk.GetRepo(owner, repo)
	if err != nil {
		return RepositoryInfo{}, fmt.Errorf("get repo %s/%s: %w", owner, repo, err)
	}

	return RepositoryInfo{DefaultBranch: r.DefaultBranch, CloneURL: r.CloneURL}, nil
}

func (c *Client) setIssueState(owner, repo string, index int64, state sdk.StateType) error {
	_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{State: &state})
	if err != nil {
		return fmt.Errorf("set state %q on %s/%s#%d: %w", state, owner, repo, index, err)
	}

	return nil
}

// AgentProfile is what zoo reads off an agent's Forgejo account: its bio
// (surfaced in the system prompt) and the name/email used to set git
// commit authorship inside that agent's container.
type AgentProfile struct {
	Description string
	FullName    string
	Email       string
}

// AgentProfile fetches the Forgejo profile of the user account named
// username. Agent config labels double as their Forgejo usernames (see
// issue:assigned resolution), so this is how an agent's "what it does"
// text, and its commit identity, are authored: by editing that account's
// profile on Forgejo, not in zoo.hcl.
func (c *Client) AgentProfile(username string) (AgentProfile, error) {
	u, _, err := c.sdk.GetUserInfo(username)
	if err != nil {
		return AgentProfile{}, fmt.Errorf("get user %q: %w", username, err)
	}

	p := AgentProfile{
		Description: u.Description,
		FullName:    u.FullName,
		Email:       u.Email,
	}

	return p, nil
}

// AssignIssue sets the sole assignee of an issue/PR to username. Used by
// the assign_issue tool so agents can hand work off to each other: the
// resulting issue:assigned event (webhook or poll) triggers whichever
// agent's config label matches username.
func (c *Client) AssignIssue(owner, repo string, index int64, username string) error {
	_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{Assignees: []string{username}})
	if err != nil {
		return fmt.Errorf("assign %s/%s#%d to %q: %w", owner, repo, index, username, err)
	}

	return nil
}
Thinking
Now the problem is clearly visible. The client has a `Sudo(username)` method, which uses the `Sudo:` header with a global (admin) token to impersonate the agent. The issue author says "This Sudo: shit is driving me insane" — meaning the Sudo header approach is annoying (requires an admin token, and it's a hack). The request is to instead give each agent its own Forgejo token, so that the agent's actions are performed with its own token (as its own identity), without needing Sudo. So the task is: 1. Add a per-agent `token` attribute to the `agent` block in zoo.hcl (optional, with a fallback to the global token? Or make it required?). 2. When the agent runs, use that agent's token for the Forgejo client (instead of the Sudo impersonation). 3. Keep the global token for the event source (webhook/polling) and host-side git operations. Let me read the rest: tools/forgejo.go, agentrun/run.go, agentrun/context.go, agentrun/roster.go, scheduler.go, main.go, forgejo.go (agentrun), clone.go, docker.go.
Now I can see the core issue — the `Sudo` impersonation via a global admin token. Let me read the rest of the relevant code.
Tool 🔧 bash {"command":"cat /project/internal/tools/forgejo.go /project/internal/agentrun/context.go"}
{"command":"cat /project/internal/tools/forgejo.go /project/internal/agentrun/context.go"}
package tools

import (
	"fmt"

	"github.com/abrander/zoo/internal/llm"
)

type commentParams struct {
	Body string `json:"body"`
}

func init() {
	tool := llm.NewTool(
		"comment",
		"Post a comment on the issue or pull request that triggered this run.")

	tool.AddStringProperty("body", "The comment text, in Markdown", true)

	Register(tool, comment)
}

func comment(ctx Context, params commentParams) (string, error) {
	if err := ctx.Forgejo().Comment(params.Body); err != nil {
		return "", err
	}

	return "Comment posted", nil
}

type openPullRequestParams struct {
	Head  string `json:"head"`
	Base  string `json:"base"`
	Title string `json:"title"`
	Body  string `json:"body"`
}

func init() {
	tool := llm.NewTool(
		"open_pull_request",
		"Open a pull request from a branch you've pushed into the repository's default branch (or another base branch).")

	tool.AddStringProperty("head", "The branch containing your changes (usually the run's own branch)", true)
	tool.AddStringProperty("base", "The branch to merge into, e.g. the repository's default branch", true)
	tool.AddStringProperty("title", "The pull request title", true)
	tool.AddStringProperty("body", "The pull request description, in Markdown", true)

	Register(tool, openPullRequest)
}

func openPullRequest(ctx Context, params openPullRequestParams) (string, error) {
	if err := ctx.Forgejo().OpenPullRequest(params.Head, params.Base, params.Title, params.Body); err != nil {
		return "", err
	}

	return "Pull request opened", nil
}

type requestReviewParams struct {
	Reviewers []string `json:"reviewers"`
}

func init() {
	tool := llm.NewTool(
		"request_review",
		"Request review of the current pull request from one or more Forgejo users.")

	tool.AddStringArrayProperty("reviewers", "Usernames to request review from", true)

	Register(tool, requestReview)
}

func requestReview(ctx Context, params requestReviewParams) (string, error) {
	if err := ctx.Forgejo().RequestReview(params.Reviewers); err != nil {
		return "", err
	}

	return "Review requested", nil
}

type labelParams struct {
	Name string `json:"name"`
}

func init() {
	tool := llm.NewTool(
		"add_label",
		"Add a label to the issue or pull request that triggered this run, creating it on the repo first if needed.")

	tool.AddStringProperty("name", "The label name", true)

	Register(tool, addLabel)
}

func addLabel(ctx Context, params labelParams) (string, error) {
	if err := ctx.Forgejo().AddLabel(params.Name); err != nil {
		return "", err
	}

	return "Label added", nil
}

func init() {
	tool := llm.NewTool(
		"remove_label",
		"Remove a label from the issue or pull request that triggered this run.")

	tool.AddStringProperty("name", "The label name", true)

	Register(tool, removeLabel)
}

func removeLabel(ctx Context, params labelParams) (string, error) {
	if err := ctx.Forgejo().RemoveLabel(params.Name); err != nil {
		return "", err
	}

	return "Label removed", nil
}

type assignIssueParams struct {
	Agent string `json:"agent"`
}

func init() {
	tool := llm.NewTool(
		"assign_issue",
		"Hand the issue or pull request that triggered this run off to another agent, by name. This assigns it to that agent's Forgejo account, which triggers their run. Use this when the task is better suited to another agent's described role than your own.")

	tool.AddStringProperty("agent", "The name of the agent to hand this off to, e.g. one listed in the \"Other agents\" section of your system prompt", true)

	Register(tool, assignIssue)
}

func assignIssue(ctx Context, params assignIssueParams) (string, error) {
	if err := ctx.Forgejo().AssignIssue(params.Agent); err != nil {
		return "", err
	}

	return fmt.Sprintf("Assigned to %s", params.Agent), nil
}

type noParams struct{}

func init() {
	tool := llm.NewTool(
		"close_issue",
		"Close the issue or pull request that triggered this run.")

	Register(tool, closeIssue)
}

func closeIssue(ctx Context, _ noParams) (string, error) {
	if err := ctx.Forgejo().CloseIssue(); err != nil {
		return "", err
	}

	return "Issue closed", nil
}

func init() {
	tool := llm.NewTool(
		"reopen_issue",
		"Reopen the issue or pull request that triggered this run.")

	Register(tool, reopenIssue)
}

func reopenIssue(ctx Context, _ noParams) (string, error) {
	if err := ctx.Forgejo().ReopenIssue(); err != nil {
		return "", err
	}

	return "Issue reopened", nil
}
package agentrun

import (
	"context"
	"fmt"

	"github.com/abrander/zoo/internal/tools"
)

// runContext implements tools.Context for a single agent run, executing
// shell commands via docker exec against the run's container, git
// commands as the host process against the run's working tree (see
// Git), and binding Forgejo actions to the triggering issue/PR.
type runContext struct {
	docker      *dockerRuntime
	containerID string
	projectDir  string
	token       string
	forgejo     tools.ForgejoActions
}

func (c *runContext) Exec(ctx context.Context, command string) (string, error) {
	output, exitCode, err := c.docker.exec(ctx, c.containerID, command)

	if err != nil {
		return output, err
	}

	if exitCode != 0 {
		return output, fmt.Errorf("command exited with status %d", exitCode)
	}

	return output, nil
}

// Git implements tools.Context.Git by running git on the host against
// c.projectDir rather than via docker exec. Only push needs to
// authenticate against Forgejo, so only it carries the token, via a
// per-invocation -c override that never touches .git/config (see
// gitAuthArgs) — the container this same working tree is bind-mounted
// into has no way to read it out.
func (c *runContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {
	cmdArgs := append([]string{subcommand}, args...)

	if subcommand == "push" {
		return runGitAuthed(ctx, c.projectDir, c.token, cmdArgs...)
	}

	return runGit(ctx, c.projectDir, cmdArgs...)
}

func (c *runContext) Forgejo() tools.ForgejoActions {
	return c.forgejo
}
Thinking
Next, let's take a look at the tools.Context interface, agentrun/forgejo.go (ForgejoActions), run.go, roster.go, scheduler.go, and main.go.
Tool 🔧 bash {"command":"cat /project/internal/tools/context.go /project/internal/agentrun/forgejo.go"}
{"command":"cat /project/internal/tools/context.go /project/internal/agentrun/forgejo.go"}
// Package tools is the registry of functions an agent's tool-calling
// loop can invoke, ported from ../a/tools but retargeted at a Docker
// container (via Exec) and the Forgejo issue/PR that triggered the run
// (via Forgejo()) instead of a local sandboxed filesystem.
package tools

import "context"

// Context is bound to a single agent run (job), unlike ../a's single
// global Context, since multiple agents run concurrently in zoo.
type Context interface {
	// Exec runs command inside the run's container via `sh -c` and
	// returns combined stdout+stderr. The container never holds a
	// Forgejo credential, so this cannot reach the Forgejo API or
	// authenticate git operations against it — see Git.
	Exec(ctx context.Context, command string) (string, error)

	// Git runs a git subcommand against the run's working tree on the
	// host, outside the container, so that operations needing a Forgejo
	// credential (currently just push) can authenticate without that
	// credential ever being written to disk where the container (and
	// anything run inside it via Exec) could read it.
	Git(ctx context.Context, subcommand string, args ...string) (string, error)

	// Forgejo returns the actions bound to the issue/PR that triggered
	// this run, so tools don't need to be told which repo/issue to act
	// on.
	Forgejo() ForgejoActions
}

// ForgejoActions are the actions available to an agent against the
// triggering issue/PR. Implemented by internal/agentrun, backed by
// internal/forgejo.Client plus the run's owner/repo/issue.
type ForgejoActions interface {
	Comment(body string) error
	OpenPullRequest(head, base, title, body string) error
	RequestReview(reviewers []string) error
	AddLabel(name string) error
	RemoveLabel(name string) error
	CloseIssue() error
	ReopenIssue() error

	// AssignIssue hands the triggering issue/PR off to another agent by
	// their name (which doubles as their Forgejo username); the
	// resulting issue:assigned event triggers that agent's run.
	AssignIssue(agentName string) error
}
package agentrun

import (
	"log/slog"

	"github.com/abrander/zoo/internal/forgejo"
)

// runForgejoActions implements tools.ForgejoActions, binding the
// generic forgejo.Client to the single issue/PR that triggered this run
// so agent tool calls don't need to specify owner/repo/index themselves.
type runForgejoActions struct {
	client      *forgejo.Client
	owner, repo string
	index       int64
	logger      *slog.Logger
}

// logResult logs a forge action once it's known to have succeeded or
// failed, so every comment, PR, label, or issue-state change an agent
// makes is traceable in zoo's own logs, not just in Forgejo's history.
func (a *runForgejoActions) logResult(action string, err error, args ...any) {
	fields := append([]any{"owner", a.owner, "repo", a.repo, "index", a.index}, args...)

	if err != nil {
		a.logger.Warn("forge action failed", append(append([]any{"action", action}, fields...), "error", err)...)
		return
	}

	a.logger.Info("forge action", append([]any{"action", action}, fields...)...)
}

func (a *runForgejoActions) Comment(body string) error {
	err := a.client.CreateIssueComment(a.owner, a.repo, a.index, body)
	a.logResult("comment", err)

	return err
}

func (a *runForgejoActions) OpenPullRequest(head, base, title, body string) error {
	err := a.client.CreatePullRequest(a.owner, a.repo, head, base, title, body)
	a.logResult("open_pull_request", err, "head", head, "base", base, "title", title)

	return err
}

func (a *runForgejoActions) RequestReview(reviewers []string) error {
	err := a.client.RequestReview(a.owner, a.repo, a.index, reviewers)
	a.logResult("request_review", err, "reviewers", reviewers)

	return err
}

func (a *runForgejoActions) AddLabel(name string) error {
	err := a.client.AddLabel(a.owner, a.repo, a.index, name)
	a.logResult("add_label", err, "label", name)

	return err
}

func (a *runForgejoActions) RemoveLabel(name string) error {
	err := a.client.RemoveLabel(a.owner, a.repo, a.index, name)
	a.logResult("remove_label", err, "label", name)

	return err
}

func (a *runForgejoActions) CloseIssue() error {
	err := a.client.CloseIssue(a.owner, a.repo, a.index)
	a.logResult("close_issue", err)

	return err
}

func (a *runForgejoActions) ReopenIssue() error {
	err := a.client.ReopenIssue(a.owner, a.repo, a.index)
	a.logResult("reopen_issue", err)

	return err
}

func (a *runForgejoActions) AssignIssue(agentName string) error {
	err := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)
	a.logResult("assign_issue", err, "assignee", agentName)

	return err
}
Thinking
Next, let's take a look at agentrun/run.go, roster.go, scheduler.go, and main.go.
Tool 🔧 bash {"command":"cat /project/internal/agentrun/run.go"}
{"command":"cat /project/internal/agentrun/run.go"}
// Package agentrun creates the throwaway per-job environment an agent
// runs in: a git clone + branch bind-mounted into a Docker container,
// the triggering event written to /event, and the tool-calling loop
// (internal/llm + internal/tools) driven against it.
package agentrun

import (
	"context"
	_ "embed"
	"encoding/json"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/abrander/zoo/internal/config"
	"github.com/abrander/zoo/internal/forgejo"
	"github.com/abrander/zoo/internal/livelog"
	"github.com/abrander/zoo/internal/llm"
	"github.com/abrander/zoo/internal/store"
)

//go:embed system.md
var defaultSystemPrompt string

// DefaultTimeout bounds a single agent run's wall-clock time if the
// caller doesn't override it.
const DefaultTimeout = 20 * time.Minute

type Runner struct {
	docker        *dockerRuntime
	forgejo       *forgejo.Client
	store         *store.Store
	hub           *livelog.Hub
	cfg           *config.Config
	logger        *slog.Logger
	timeout       time.Duration
	keepOnFailure bool

	agentClientsMu sync.Mutex
	agentClients   map[string]*forgejo.Client
}

func NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {
	docker, err := newDockerRuntime()
	if err != nil {
		return nil, err
	}

	if timeout <= 0 {
		timeout = DefaultTimeout
	}

	return &Runner{
		docker:        docker,
		forgejo:       fg,
		store:         st,
		hub:           hub,
		cfg:           cfg,
		logger:        logger,
		timeout:       timeout,
		keepOnFailure: keepOnFailure,
		agentClients:  make(map[string]*forgejo.Client),
	}, nil
}

// forgejoAs returns a Forgejo client that impersonates agentName (via
// Sudo:) for every API call it makes, so an agent's actions — comments,
// labels, PRs, assignment — are attributed to its own Forgejo account
// rather than zoo's shared identity. Clients are built once per agent
// and cached, since constructing one costs an extra API round trip.
// Sudo requires the configured forgejo.token to have admin/sudo rights;
// if it doesn't, this logs a warning and falls back to the shared
// identity rather than failing the run outright.
func (r *Runner) forgejoAs(agentName string) *forgejo.Client {
	r.agentClientsMu.Lock()
	defer r.agentClientsMu.Unlock()

	if c, ok := r.agentClients[agentName]; ok {
		return c
	}

	c, err := r.forgejo.Sudo(agentName)
	if err != nil {
		r.logger.Warn("failed to create sudo'd forgejo client for agent; actions will be attributed to the shared zoo identity instead", "agent", agentName, "error", err)

		c = r.forgejo
	}

	r.agentClients[agentName] = c

	return c
}

// Run implements scheduler.Runner.
func (r *Runner) Run(ctx context.Context, jobID string, agent config.Agent, llmCfg config.LLM, dockerImage string, ev forgejo.Event) error {
	ctx, cancel := context.WithTimeout(ctx, r.timeout)
	defer cancel()

	logger := r.logger.With("job", jobID, "agent", agent.Name)

	repoInfo, err := r.forgejo.RepositoryInfo(ev.Owner, ev.Repo)
	if err != nil {
		return fmt.Errorf("look up repository: %w", err)
	}

	workDir, err := os.MkdirTemp("", "zoo-run-*")
	if err != nil {
		return fmt.Errorf("create work dir: %w", err)
	}

	succeeded := false

	defer func() {
		if succeeded || !r.keepOnFailure {
			os.RemoveAll(workDir)
		} else {
			logger.Warn("keeping work dir after failure", "dir", workDir)
		}
	}()

	branch := fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name)
	projectDir := filepath.Join(workDir, "project")

	if err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {
		return fmt.Errorf("prepare git working tree: %w", err)
	}

	roster := buildRoster(r.forgejo, r.cfg.Agents, logger)
	gitName, gitEmail := gitIdentity(agent.Name, roster)

	// Local (not --global) scope, so this identity lives in
	// projectDir/.git/config: the one place both this host-side clone
	// and the container it's bind-mounted into (as /project) actually
	// share.
	if out, err := runGit(ctx, projectDir, "config", "user.name", gitName); err != nil {
		return fmt.Errorf("configure git user.name: %w: %s", err, out)
	}
	if out, err := runGit(ctx, projectDir, "config", "user.email", gitEmail); err != nil {
		return fmt.Errorf("configure git user.email: %w: %s", err, out)
	}

	eventPath := filepath.Join(workDir, "event.json")
	if err := os.WriteFile(eventPath, ev.Raw, 0o644); err != nil {
		return fmt.Errorf("write event file: %w", err)
	}

	containerID, err := r.docker.createContainer(ctx, dockerImage, []string{
		projectDir + ":/project",
		eventPath + ":/event:ro",
	}, fmt.Sprintf("zoo-issue-%d-%s", ev.Index, agent.Name))
	if err != nil {
		return fmt.Errorf("start container: %w", err)
	}

	defer func() {
		cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cleanupCancel()
		if err := r.docker.remove(cleanupCtx, containerID); err != nil {
			logger.Warn("failed to remove container", "container", containerID, "error", err)
		}
	}()

	// /project is bind-mounted from the host, so it's owned by the host
	// UID that ran the clone, not whatever UID runs inside the
	// container (usually root) — git's ownership check rejects that by
	// default ("detected dubious ownership") unless told otherwise.
	// --system (not --global) so this holds regardless of which user
	// subsequent `docker exec` calls run as. Commit identity is
	// configured host-side, above, with --local scope so it's visible
	// from both sides of the bind mount without needing --global here.
	out, exitCode, err := r.docker.exec(ctx, containerID, "git config --system --add safe.directory '*'")
	if err != nil {
		return fmt.Errorf("configure git safe.directory in container: %w: %s", err, out)
	}
	if exitCode != 0 {
		return fmt.Errorf("configure git safe.directory in container: exit %d: %s", exitCode, out)
	}

	logAppend := func(stream, line string) {
		if err := r.store.AppendLog(context.Background(), jobID, stream, line); err != nil {
			logger.Warn("failed to append log", "error", err)
		}
	}

	runCtx := &runContext{
		docker:      r.docker,
		containerID: containerID,
		projectDir:  projectDir,
		token:       r.forgejo.Token(),
		forgejo: &runForgejoActions{
			client: r.forgejoAs(agent.Name),
			owner:  ev.Owner,
			repo:   ev.Repo,
			index:  ev.Index,
			logger: logger,
		},
	}

	llmClient := llm.NewClient(llmCfg)

	systemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)

	instructions := r.cfg.EventInstructions(ev.Kind)

	messages := []llm.Message{
		{Role: "system", Content: systemPrompt},
		{Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions)},
	}

	hooks := r.streamHooks(jobID, logAppend)

	_, err = runLoop(ctx, llmClient, runCtx, messages, hooks)
	if err != nil {
		return fmt.Errorf("agent loop: %w", err)
	}

	succeeded = true

	return nil
}

// streamHooks builds the Hooks a single Run passes to runLoop: every
// delta is published live to the hub for connected dashboard viewers,
// and once a reasoning/content block or tool call is complete, it's
// persisted to the store as one row and the hub's replay buffer for
// jobID is checkpointed — so a viewer connecting from this point on
// sees it via the persisted history instead of a live replay, and is
// never shown it twice.
func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {
	var reasoningBuf, contentBuf strings.Builder

	reasoningOpen, contentOpen := false, false

	return Hooks{
		OnReasoningDelta: func(delta string) {
			if !reasoningOpen {
				reasoningOpen = true
				reasoningBuf.Reset()
				r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
			}

			reasoningBuf.WriteString(delta)
			r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
		},
		OnContentDelta: func(delta string) {
			if !contentOpen {
				contentOpen = true
				contentBuf.Reset()
				r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
			}

			contentBuf.WriteString(delta)
			r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
		},
		OnTurnEnd: func() {
			if reasoningOpen {
				r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
				logAppend("reasoning", reasoningBuf.String())
				r.hub.Checkpoint(jobID)
				reasoningOpen = false
			}

			if contentOpen {
				r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
				logAppend("content", contentBuf.String())
				r.hub.Checkpoint(jobID)
				contentOpen = false
			}
		},
		OnTool: func(name, arguments, result string, toolErr bool) {
			r.hub.Publish(jobID, livelog.Event{
				Type:      livelog.Tool,
				Name:      name,
				Arguments: arguments,
				Result:    result,
				Error:     toolErr,
			})

			line, err := json.Marshal(store.ToolLogEntry{Name: name, Arguments: arguments, Result: result, Error: toolErr})
			if err != nil {
				r.logger.Warn("failed to marshal tool log entry", "job", jobID, "error", err)
			} else {
				logAppend("tool", string(line))
			}

			r.hub.Checkpoint(jobID)
		},
	}
}

func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string) string {
	raw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), "", "  ")

	var instructionsSection string
	if instructions != "" {
		instructionsSection = fmt.Sprintf("Instructions for this event, from zoo.hcl:\n%s\n\n", instructions)
	}

	return fmt.Sprintf(
		"You were triggered by a %q event on %s/%s.\n\n"+
			"%sYour working branch is %q, checked out from the default branch %q.\n\n"+
			"Title: %s\n\nBody:\n%s\n\nFull event payload:\n```json\n%s\n```",
		ev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, raw)
}
Thinking
Now I understand how the runner works: 1. `Runner.Run` receives `agent config.Agent`. 2. It calls `r.forgejoAs(agent.Name)`, which uses `Sudo(agentName)` to create a client that impersonates the agent. 3. `runContext.token` is `r.forgejo.Token()` — the global token used for `git push`. So for per-agent tokens: - Add an optional `token` attribute to the `agent` block in config. - In `forgejoAs`, if the agent has a token configured, create a client with that token (no Sudo). If not, fall back to the Sudo approach (or the shared identity). - For git push: if the agent has a token, use the agent's token, otherwise use the global token. Let me check the remaining files: roster.go, scheduler.go, main.go, clone.go, docker.go, and the tests. Also, let me check `internal/forgejo/poll.go`, `watcher.go`, `webhook.go`, `event.go` to see how the global client is used for event sources (those will continue to use the global token).
Tool 🔧 bash {"command":"cat /project/internal/agentrun/roster.go /project/internal/agentrun/clone.go"}
{"command":"cat /project/internal/agentrun/roster.go /project/internal/agentrun/clone.go"}
package agentrun

import (
	"fmt"
	"log/slog"
	"strings"

	"github.com/abrander/zoo/internal/config"
	"github.com/abrander/zoo/internal/forgejo"
)

// agentInfo pairs a configured agent with its Forgejo profile: the bio
// (what its account says it does) and the name/email used to set git
// commit authorship inside its container.
type agentInfo struct {
	Name        string
	Description string
	FullName    string
	Email       string
}

// buildRoster fetches every configured agent's Forgejo profile (agent
// config labels double as Forgejo usernames). A per-agent lookup failure
// is logged and that agent is included with an empty profile rather than
// failing the whole run — one missing/unreachable profile shouldn't
// block someone else's job.
func buildRoster(fg *forgejo.Client, agents []config.Agent, logger *slog.Logger) []agentInfo {
	roster := make([]agentInfo, 0, len(agents))

	for _, a := range agents {
		profile, err := fg.AgentProfile(a.Name)
		if err != nil {
			logger.Warn("failed to fetch agent profile from forgejo", "agent", a.Name, "error", err)
		}

		roster = append(roster, agentInfo{Name: a.Name, Description: profile.Description, FullName: profile.FullName, Email: profile.Email})
	}

	return roster
}

// gitIdentity returns the git commit author name/email to configure
// inside self's container, from its Forgejo profile, falling back to
// its agent name and a synthetic zoo.local address for whichever fields
// its profile doesn't set.
func gitIdentity(self string, roster []agentInfo) (name, email string) {
	for _, a := range roster {
		if a.Name == self {
			name, email = a.FullName, a.Email
			break
		}
	}

	if name == "" {
		name = self
	}

	if email == "" {
		email = self + "@zoo.local"
	}

	return name, email
}

// identitySection renders the "who am I / who else is there" prompt
// section injected after the static system prompt: the agent's own
// Forgejo profile description, and everyone else's, so it can decide
// when a task is better handed off via the assign_issue tool.
func identitySection(self string, roster []agentInfo) string {
	var b strings.Builder

	var selfDesc string
	var others []agentInfo

	for _, a := range roster {
		if a.Name == self {
			selfDesc = a.Description
		} else {
			others = append(others, a)
		}
	}

	fmt.Fprintf(&b, "\n\n## Your identity\n\nYou are the agent %q.", self)

	if selfDesc != "" {
		fmt.Fprintf(&b, " Your role, from your Forgejo profile description: %s", selfDesc)
	} else {
		b.WriteString(" Your Forgejo profile has no description set.")
	}

	if len(others) > 0 {
		b.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")

		for _, a := range others {
			desc := a.Description
			if desc == "" {
				desc = "(no description set)"
			}

			fmt.Fprintf(&b, "### %s\n\n%s\n\n", a.Name, desc)
		}
	}

	return b.String()
}
package agentrun

import (
	"context"
	"encoding/base64"
	"fmt"
	"os/exec"
	"strings"
)

// gitAuthArgs returns the `-c http.extraHeader=...` global git arguments
// that authenticate a single invocation as the shared zoo Forgejo
// identity. Unlike embedding the token in the remote URL, a `-c`
// override lives only for the lifetime of that one process and is never
// written to .git/config on disk — where the container bind-mounting
// this working tree (and any command an agent runs inside it via the
// bash tool) could otherwise read it straight back out.
func gitAuthArgs(token string) []string {
	auth := base64.StdEncoding.EncodeToString([]byte("zoo:" + token))

	return []string{"-c", "http.extraHeader=Authorization: Basic " + auth}
}

// cloneAndBranch clones cloneURL into dir, authenticating the clone
// itself with token (see gitAuthArgs), and checks out a new branch off
// of defaultBranch. token never ends up in the resulting working tree:
// the origin remote is left with the plain cloneURL.
func cloneAndBranch(ctx context.Context, cloneURL, token, defaultBranch, branch, dir string) error {
	args := append(gitAuthArgs(token), "clone", "--branch", defaultBranch, "--single-branch", cloneURL, dir)
	if out, err := runGit(ctx, "", args...); err != nil {
		return fmt.Errorf("clone: %w: %s", err, out)
	}

	if out, err := runGit(ctx, dir, "checkout", "-b", branch); err != nil {
		return fmt.Errorf("checkout branch %q: %w: %s", branch, err, out)
	}

	return nil
}

// runGit runs a git subcommand on the host (not inside any container)
// with no Forgejo credential attached.
func runGit(ctx context.Context, dir string, args ...string) (string, error) {
	cmd := exec.CommandContext(ctx, "git", args...)
	if dir != "" {
		cmd.Dir = dir
	}

	out, err := cmd.CombinedOutput()

	return strings.TrimSpace(string(out)), err
}

// runGitAuthed is runGit with token attached via gitAuthArgs, for the
// one subcommand (push) that needs to authenticate against Forgejo.
func runGitAuthed(ctx context.Context, dir, token string, args ...string) (string, error) {
	return runGit(ctx, dir, append(gitAuthArgs(token), args...)...)
}
Thinking
Next up are the scheduler and main:
Tool 🔧 bash {"command":"cat /project/internal/scheduler/scheduler.go /project/cmd/zoo/main.go"}
{"command":"cat /project/internal/scheduler/scheduler.go /project/cmd/zoo/main.go"}
// Package scheduler resolves incoming Forgejo events to configured
// agents and runs them, bounded by max_live_agents.
package scheduler

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"sync"

	"github.com/google/uuid"

	"github.com/abrander/zoo/internal/config"
	"github.com/abrander/zoo/internal/forgejo"
	"github.com/abrander/zoo/internal/livelog"
	"github.com/abrander/zoo/internal/store"
)

// forgejoActions is the narrow slice of Client the scheduler needs for
// its own failure-reporting side effects (defined here, not in
// internal/forgejo, so tests can inject a fake).
type forgejoActions interface {
	CreateIssueComment(owner, repo string, index int64, body string) error
	AddLabel(owner, repo string, index int64, name string) error
}

// FailureLabel is applied to the triggering issue/PR, alongside a
// comment, whenever an agent run fails or times out.
const FailureLabel = "zoo:failed"

// Runner runs a single agent invocation to completion. Implemented by
// internal/agentrun.Run; a narrow interface here so the scheduler is
// testable without Docker.
type Runner interface {
	Run(ctx context.Context, jobID string, agent config.Agent, llm config.LLM, dockerImage string, ev forgejo.Event) error
}

type Scheduler struct {
	cfg     *config.Config
	store   *store.Store
	forgejo forgejoActions
	runner  Runner
	hub     *livelog.Hub
	logger  *slog.Logger

	sem chan struct{}
	wg  sync.WaitGroup
}

func New(cfg *config.Config, st *store.Store, fg forgejoActions, runner Runner, hub *livelog.Hub, logger *slog.Logger) *Scheduler {
	return &Scheduler{
		cfg:     cfg,
		store:   st,
		forgejo: fg,
		runner:  runner,
		hub:     hub,
		logger:  logger,
		sem:     make(chan struct{}, cfg.MaxLive),
	}
}

// resolveAgent returns the name of the agent that should handle ev, if
// any. issue:assigned resolves dynamically: the agent whose config label
// matches the Forgejo assignee's username. Every other event kind uses
// the static event->agent mapping from config.
func resolveAgent(cfg *config.Config, ev forgejo.Event) (string, bool) {
	if ev.Kind == config.EventIssueAssigned {
		if _, ok := cfg.AgentByName(ev.Assignee); ok {
			return ev.Assignee, true
		}

		return "", false
	}

	return cfg.EventAgent(ev.Kind)
}

// Run consumes events until ctx is canceled or the channel closes,
// dispatching each to its resolved agent and blocking on the
// max_live_agents semaphore before starting a run.
func (s *Scheduler) Run(ctx context.Context, events <-chan forgejo.Event) {
	for {
		select {
		case <-ctx.Done():
			return

		case ev, ok := <-events:
			if !ok {
				return
			}

			s.handle(ctx, ev)
		}
	}
}

func (s *Scheduler) handle(ctx context.Context, ev forgejo.Event) {
	agentName, ok := resolveAgent(s.cfg, ev)
	if !ok {
		s.logger.Debug("no agent resolved for event, dropping", "kind", ev.Kind, "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index)
		return
	}

	// An agent's own actions (e.g. a comment posted via the `comment`
	// tool, attributed to it by Sudo) can themselves show up as new
	// events. Don't let an agent trigger itself off its own activity —
	// that's a self-reinforcing loop, not new work.
	if ev.Author != "" && ev.Author == agentName {
		s.logger.Debug("dropping event authored by the agent it would trigger", "kind", ev.Kind, "agent", agentName, "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index)
		return
	}

	agent, ok := s.cfg.AgentByName(agentName)
	if !ok {
		s.logger.Error("resolved agent not declared in config", "agent", agentName)
		return
	}

	llm, ok := s.cfg.LLMByName(agent.LLM)
	if !ok {
		s.logger.Error("agent references undeclared llm", "agent", agentName, "llm", agent.LLM)
		return
	}

	jobID := uuid.NewString()

	if err := s.store.CreateJob(ctx, store.Job{
		ID:         jobID,
		EventKind:  ev.Kind,
		Agent:      agentName,
		Owner:      ev.Owner,
		Repo:       ev.Repo,
		IssueIndex: ev.Index,
	}); err != nil {
		s.logger.Error("failed to record job", "job", jobID, "error", err)
		return
	}

	select {
	case s.sem <- struct{}{}:

	case <-ctx.Done():
		return
	}

	s.wg.Add(1)

	go func() {
		defer s.wg.Done()
		defer func() { <-s.sem }()

		s.run(ctx, jobID, agent, llm, ev)
	}()
}

func (s *Scheduler) run(ctx context.Context, jobID string, agent config.Agent, llm config.LLM, ev forgejo.Event) {
	logger := s.logger.With("job", jobID, "agent", agent.Name, "event", ev.Kind, "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index)

	// Job status writes use a context detached from ctx, not ctx itself:
	// ctx is canceled on daemon shutdown to unwind the in-flight run, and
	// an already-canceled ctx would make these UPDATEs fail instantly,
	// leaving the job stuck at "running" forever even though the process
	// has exited.
	if err := s.store.MarkJobStarted(context.Background(), jobID); err != nil {
		logger.Error("failed to mark job started", "error", err)
	}

	logger.Info("agent run starting")

	err := s.runner.Run(ctx, jobID, agent, llm, s.cfg.Environment.DockerImage, ev)

	status := store.JobSucceeded
	errMsg := ""

	if err != nil {
		errMsg = err.Error()
		// The daemon-wide ctx passed in here isn't what times a run out
		// (agentrun.Runner.Run applies its own per-run deadline
		// internally); a timed-out run surfaces as a wrapped
		// context.DeadlineExceeded in the returned error instead.
		if errors.Is(err, context.DeadlineExceeded) {
			status = store.JobTimedOut
		} else {
			status = store.JobFailed
		}

		logger.Error("agent run failed", "status", status, "error", err)
		s.reportFailure(ev, status, err)
	} else {
		logger.Info("agent run succeeded")
	}

	if err := s.store.MarkJobFinished(context.Background(), jobID, status, errMsg); err != nil {
		logger.Error("failed to mark job finished", "error", err)
	}

	// Closing the hub here, only after the terminal status is durably
	// written, is what lets a dashboard tab's auto-reload (triggered by
	// this Close) see the finished status immediately. Closing it inside
	// Runner.Run instead — before this status write — raced a fast
	// reload against MarkJobFinished: the reloaded page could still see
	// "running", open a fresh SSE subscription against a hub entry that
	// no longer exists, and be stuck showing a stale status forever.
	s.hub.Close(jobID)
}

// reportFailure implements the locked-in failure policy: comment on the
// triggering issue/PR with an error summary, and apply FailureLabel.
func (s *Scheduler) reportFailure(ev forgejo.Event, status store.JobStatus, runErr error) {
	body := fmt.Sprintf("zoo agent run %s: %s", status, runErr.Error())

	if err := s.forgejo.CreateIssueComment(ev.Owner, ev.Repo, ev.Index, body); err != nil {
		s.logger.Error("failed to post failure comment", "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index, "error", err)
	}

	if err := s.forgejo.AddLabel(ev.Owner, ev.Repo, ev.Index, FailureLabel); err != nil {
		s.logger.Error("failed to add failure label", "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index, "error", err)
	}
}

// Shutdown stops accepting new work is the caller's responsibility (stop
// feeding Run's channel); Shutdown just waits for in-flight runs, up to
// ctx's deadline.
func (s *Scheduler) Shutdown(ctx context.Context) error {
	done := make(chan struct{})

	go func() {
		s.wg.Wait()
		close(done)
	}()

	select {
	case <-done:
		return nil

	case <-ctx.Done():
		return ctx.Err()
	}
}
// Command zoo runs the daemon: it watches a Forgejo instance for
// issue/PR events, dispatches them to configured AI agents running in
// Docker containers, and serves a small dashboard over the result.
package main

import (
	"context"
	"flag"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/abrander/zoo/internal/agentrun"
	"github.com/abrander/zoo/internal/config"
	"github.com/abrander/zoo/internal/forgejo"
	"github.com/abrander/zoo/internal/livelog"
	"github.com/abrander/zoo/internal/scheduler"
	"github.com/abrander/zoo/internal/store"
	"github.com/abrander/zoo/internal/web"
)

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "zoo:", err)

		os.Exit(1)
	}
}

func run() error {
	var (
		configPath    = flag.String("config", "zoo.hcl", "path to the zoo.hcl config file")
		dbPath        = flag.String("db", "zoo.db", "path to the sqlite state database")
		listen        = flag.String("listen", ":8080", "address to serve webhooks and the dashboard on")
		runTimeout    = flag.Duration("run-timeout", agentrun.DefaultTimeout, "wall-clock timeout for a single agent run")
		keepOnFailure = flag.Bool("keep-on-failure", false, "keep the container and clone around after a failed run, for debugging")
	)

	flag.Parse()

	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))

	cfg, err := config.Load(*configPath)
	if err != nil {
		return fmt.Errorf("load config: %w", err)
	}

	st, err := store.Open(*dbPath)
	if err != nil {
		return fmt.Errorf("open store: %w", err)
	}
	defer st.Close()

	if n, err := st.ReapOrphanedJobs(context.Background()); err != nil {
		logger.Warn("failed to reap orphaned jobs", "error", err)
	} else if n > 0 {
		logger.Warn("reaped jobs left running by a previous process instance", "count", n)
	}

	fgClient, err := forgejo.NewClient(cfg.Forgejo)
	if err != nil {
		return fmt.Errorf("forgejo client: %w", err)
	}

	watcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, logger.With("component", "forgejo"))

	hub := livelog.NewHub()

	runner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With("component", "agentrun"), *runTimeout, *keepOnFailure)
	if err != nil {
		return fmt.Errorf("agent runner: %w", err)
	}

	sched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With("component", "scheduler"))
	dashboard := web.New(cfg, st, hub)

	mux := http.NewServeMux()

	mux.Handle("/webhooks/forgejo", watcher.Handler())

	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ok"))
	})

	mux.Handle("/", dashboard.Handler())

	server := &http.Server{Addr: *listen, Handler: mux}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	go watcher.Run(ctx)
	go sched.Run(ctx, watcher.Events())

	serveErr := make(chan error, 1)

	go func() {
		logger.Info("listening", "addr", *listen)
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			serveErr <- err
		}
	}()

	select {
	case <-ctx.Done():
		logger.Info("shutting down")

	case err := <-serveErr:
		stop()

		logger.Error("http server failed", "error", err)
	}

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	if err := server.Shutdown(shutdownCtx); err != nil {
		logger.Warn("http server shutdown", "error", err)
	}

	if err := sched.Shutdown(shutdownCtx); err != nil {
		logger.Warn("scheduler did not drain in time", "error", err)
	}

	return nil
}
Thinking
Now I have the full picture. Let's plan the changes: **Goal**: Per-agent Forgejo tokens. Each agent block can declare its own `token`, and when running, the agent's Forgejo actions (comments, PRs, labels, assignments, git push/clone) are authenticated with that agent's own token — no `Sudo:` header needed. The global `forgejo.token` is retained for the event source (webhook/polling) and the daemon's own actions (failure reports), and as a fallback for agents that don't have their own token. Design decisions: 1. **Config**: Add `Token string `hcl:"token,optional"`` to the `Agent` struct. Should it be optional (fall back to Sudo/shared) or required? The issue says "let's have a token per agent and skip the global Forgejo token." Hmm — "skip the global Forgejo token" — but the global token is still needed for the event source (webhook/polling) and the daemon's failure reports. Actually wait — do we really need it? The watcher uses the client for polling. The scheduler uses it for failure reports. If we skip the global token entirely, polling and failure reports break. I think the reasonable interpretation is: agents act as themselves with their own tokens, and the global token remains for the daemon's own identity (event source + failure reports). The complaint is specifically about the Sudo hack: "this Sudo: crap is driving me insane." Make `token` optional on the agent block, with a fallback: if an agent has a token, use it directly (no Sudo); otherwise, fall back to the current Sudo behavior (with a warning). This preserves backward compatibility with the existing zoo.hcl. Actually, let me think about whether it should be required. The issue title is "let's have a token per agent." The body: "let's have a token per agent and skip the global Forgejo token. Then we can hand the token to the agent, and it can just be itself." "Skip the global Forgejo token" — I read this as "stop using the global Forgejo token [for agent actions]." The global token is still needed for polling/webhook verification (well, webhook verification doesn't need a token, but polling does) and for the daemon's failure reports. So the global token stays in the config, but agent actions use per-agent tokens. For safety and backward compatibility, make it optional with a Sudo fallback. Hmm, but then the "Sudo crap" lingers for agents without tokens. Since this is a small personal project (abrander/zoo), making it optional with a clear log message is the pragmatic choice. Actually, let me reconsider: should we make it required? If required, the existing zoo.hcl.example breaks (agents without tokens). The example file would need updating. That's fine — I control the example. But the operator's actual zoo.hcl (gitignored) would break on upgrade. Optional with fallback is more forgiving. Let's go with optional + fallback to Sudo, and update the example config to show per-agent tokens. 2. **forgejo.Client**: Add a way to create a client with a different token on the same base URL. `Sudo` already constructs a new client. We can generalize: add a method like `As(token string) (*Client, error)` that creates a client with the given token. Then `Sudo` becomes a special case... actually, let's keep it simple: ```go // WithToken returns a new Client against the same Forgejo instance // authenticated with token instead of this client's own token. func (c *Client) WithToken(token string) (*Client, error) { sdkClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(token)) ... } ``` And in runner's `forgejoAs`: ```go func (r *Runner) forgejoAs(agent config.Agent) *forgejo.Client { ... if agent.Token != "" { c, err := r.forgejo.WithToken(agent.Token) ... } // fallback: Sudo c, err := r.forgejo.Sudo(agent.Name) ... } ``` The cache is keyed by agent name, so no problem. 3. **Git auth**: `runContext.token` is used for `git push`, and `cloneAndBranch` uses `r.forgejo.Token()` for cloning. If the agent has its own token, the clone/push should authenticate as that agent — so that the pushed branch is attributed to the agent's account, and the agent's token has push access to the repo. This matters: with a global admin token, the branch/PR creator is... actually, in Forgejo, PR authorship is determined by the API token used to create the PR (the `open_pull_request` tool), not by git push. But the git push needs to be able to push to the `issue-{index}-{agent}` branch. If the agent's token has push access to the repo, using the agent's token for clone+push makes the whole thing consistent ("it can just be itself"). So: in `Runner.Run`, compute `token := agent.Token; if token == "" { token = r.forgejo.Token() }` and use it for `cloneAndBranch` and `runContext.token`. Wait, but there's a subtlety with the git auth header: `gitAuthArgs` uses username "zoo" + token. For a personal access token, the username can be anything (or the actual username). Forgejo accepts `Authorization: Basic base64(user:token)` where user is arbitrary and token is the PAT. So "zoo:" + agentToken works fine. We could change the username to the agent name for clarity, but it's not required. Actually, let's keep "zoo" — it works. Hmm, but for clarity of logs, using the agent name might be nice. Not required. Keep it simple. 4. **Roster/profile**: `buildRoster` fetches agent profiles via the shared client (`fg.AgentProfile`). This works fine with the global token (reading public user info). No change needed. Could use the agent's own token, but that's unnecessary. 5. **Self-event filtering**: The scheduler drops events where `ev.Author == agentName`. With per-agent tokens, an agent's own comments are actually attributed to the agent (previously via Sudo, also attributed to the agent). So no change needed. 6. **Config validation**: Should we validate that per-agent tokens are non-empty when present? An empty string is just "not set" — HCL's optional attribute leaves it as "". No validation needed beyond what's there. 7. **zoo.hcl.example**: Update to show `token` on the agent block. 8. **Tests**: - `internal/config/config_test.go` — add tests that per-agent tokens are parsed (and that it's optional). - `internal/agentrun/seed_test.go`, `roster_test.go` — check what's there. - Maybe a test for `forgejoAs`? It needs a forgejo.Client, which requires an SDK client. `NewClient` creates an SDK client without a network round-trip (probably). Let me check the SDK: `sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))` — probably just constructs, no network. So we can test `forgejoAs` with a real Client constructed against a dummy URL: an agent with a token → returns a client with that token; an agent without → Sudo path (also no network). We can assert `Token()` returns the agent's token in the first case. Let me look at the existing tests to match the style: config_test.go, seed_test.go, roster_test.go, and check the forgejo SDK version in go.mod.
Let me look at the tests and the SDK to understand conventions.
Tool 🔧 bash {"command":"cat /project/internal/config/config_test.go /project/internal/agentrun/roster_test.go"}
{"command":"cat /project/internal/config/config_test.go /project/internal/agentrun/roster_test.go"}
package config

import (
	"os"
	"path/filepath"
	"testing"
)

func load(t *testing.T, hcl string) (*Config, error) {
	t.Helper()

	path := filepath.Join(t.TempDir(), "zoo.hcl")
	if err := os.WriteFile(path, []byte(hcl), 0o600); err != nil {
		t.Fatal(err)
	}

	return Load(path)
}

const validConfig = `
llm "qwen" {
    openai = "https://example.com"
    token  = "tok"
    model  = "qwen3"
}

forgejo {
    url   = "https://example.com"
    token = "tok"
}

max_live_agents = 2

environment {
    docker_image = "debian:unstable"
}

agent "leon" {
    llm = "qwen"
}

event "issue:new" {
    agent = "leon"
}

event "issue:assigned" {
    instructions = "Please handle this issue."
}

event "issue:comment" {
    agent = "leon"
    instructions = "Please review the comment and respond appropriately."
}

event "pr:new" {
    agent = "leon"
}
`

func TestLoadValid(t *testing.T) {
	cfg, err := load(t, validConfig)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if len(cfg.LLMs) != 1 || cfg.LLMs[0].Name != "qwen" {
		t.Fatalf("unexpected llms: %+v", cfg.LLMs)
	}

	if agent, ok := cfg.EventAgent(EventIssueNew); !ok || agent != "leon" {
		t.Fatalf("expected issue:new -> leon, got %q, %v", agent, ok)
	}

	if _, ok := cfg.EventAgent(EventIssueAssigned); ok {
		t.Fatalf("issue:assigned should have no static agent")
	}

	if got := cfg.EventInstructions(EventIssueAssigned); got != "Please handle this issue." {
		t.Fatalf("unexpected issue:assigned instructions: %q", got)
	}
	if got := cfg.EventInstructions(EventIssueComment); got != "Please review the comment and respond appropriately." {
		t.Fatalf("unexpected issue:comment instructions: %q", got)
	}
	if got := cfg.EventInstructions(EventPRNew); got != "" {
		t.Fatalf("expected no instructions for pr:new, got %q", got)
	}
}

func TestValidateUndeclaredLLM(t *testing.T) {
	_, err := load(t, `
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 1
environment { docker_image = "debian:unstable" }
agent "leon" { llm = "missing" }
`)
	if err == nil {
		t.Fatal("expected error for undeclared llm reference")
	}
}

func TestValidateUndeclaredEventAgent(t *testing.T) {
	_, err := load(t, `
llm "qwen" { openai = "https://example.com" token = "tok" model = "qwen3" }
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 1
environment { docker_image = "debian:unstable" }
agent "leon" { llm = "qwen" }
event "issue:new" { agent = "missing" }
`)
	if err == nil {
		t.Fatal("expected error for undeclared agent reference")
	}
}

func TestValidateIssueAssignedRejectsAgent(t *testing.T) {
	_, err := load(t, `
llm "qwen" { openai = "https://example.com" token = "tok" model = "qwen3" }
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 1
environment { docker_image = "debian:unstable" }
agent "leon" { llm = "qwen" }
event "issue:assigned" { agent = "leon" }
`)
	if err == nil {
		t.Fatal("expected error: issue:assigned must not set agent")
	}
}

func TestValidateMaxLiveAgents(t *testing.T) {
	_, err := load(t, `
llm "qwen" { openai = "https://example.com" token = "tok" model = "qwen3" }
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 0
environment { docker_image = "debian:unstable" }
`)
	if err == nil {
		t.Fatal("expected error for max_live_agents < 1")
	}
}

func TestValidateMissingDockerImage(t *testing.T) {
	_, err := load(t, `
llm "qwen" { openai = "https://example.com" token = "tok" model = "qwen3" }
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 1
environment { docker_image = "" }
`)
	if err == nil {
		t.Fatal("expected error for missing docker_image")
	}
}

func TestValidateUnknownEventKind(t *testing.T) {
	_, err := load(t, `
llm "qwen" { openai = "https://example.com" token = "tok" model = "qwen3" }
forgejo { url = "https://example.com" token = "tok" }
max_live_agents = 1
environment { docker_image = "debian:unstable" }
agent "leon" { llm = "qwen" }
event "pr:sync" { agent = "leon" }
`)
	if err == nil {
		t.Fatal("expected error for unknown event kind")
	}
}
package agentrun

import (
	"strings"
	"testing"
)

func TestIdentitySectionIncludesSelfAndOthers(t *testing.T) {
	roster := []agentInfo{
		{Name: "leon", Description: "Triages new issues"},
		{Name: "greg", Description: "Reviews pull requests"},
		{Name: "mika", Description: ""},
	}

	section := identitySection("leon", roster)

	if !strings.Contains(section, `You are the agent "leon"`) {
		t.Fatalf("expected self identity, got: %s", section)
	}
	if !strings.Contains(section, "Triages new issues") {
		t.Fatalf("expected self description, got: %s", section)
	}
	if strings.Contains(section, "- leon:") {
		t.Fatalf("self should not appear in the other-agents roster: %s", section)
	}
	if !strings.Contains(section, "- greg: Reviews pull requests") {
		t.Fatalf("expected greg in roster, got: %s", section)
	}
	if !strings.Contains(section, "- mika: (no description set)") {
		t.Fatalf("expected fallback text for empty description, got: %s", section)
	}
}

func TestIdentitySectionNoOthers(t *testing.T) {
	roster := []agentInfo{{Name: "leon", Description: "Solo agent"}}

	section := identitySection("leon", roster)

	if strings.Contains(section, "Other agents") {
		t.Fatalf("expected no roster section with only self, got: %s", section)
	}
}

func TestGitIdentityFromProfile(t *testing.T) {
	roster := []agentInfo{
		{Name: "leon", FullName: "Leon the Triager", Email: "leon@example.com"},
	}

	name, email := gitIdentity("leon", roster)
	if name != "Leon the Triager" || email != "leon@example.com" {
		t.Fatalf("expected profile identity, got name=%q email=%q", name, email)
	}
}

func TestGitIdentityFallsBackWhenProfileIncomplete(t *testing.T) {
	roster := []agentInfo{{Name: "leon"}}

	name, email := gitIdentity("leon", roster)
	if name != "leon" {
		t.Fatalf("expected name to fall back to agent name, got %q", name)
	}
	if email != "leon@zoo.local" {
		t.Fatalf("expected synthetic fallback email, got %q", email)
	}
}

func TestGitIdentityUnknownAgent(t *testing.T) {
	name, email := gitIdentity("mika", nil)
	if name != "mika" || email != "mika@zoo.local" {
		t.Fatalf("expected fallback identity for an agent missing from the roster, got name=%q email=%q", name, email)
	}
}
Thinking
Next, let me check the forgejo-sdk to see whether `sdk.NewClient` makes a network call, and how `SetToken`/`SetSudo` work. I'll look for the SDK in the module cache.
Tool 🔧 bash {"command":"cat /project/go.mod && find / -type d -path \"*forgejo-sdk*\" 2>/dev/null | head -5"}
{"command":"cat /project/go.mod && find / -type d -path \"*forgejo-sdk*\" 2>/dev/null | head -5"}
module github.com/abrander/zoo

go 1.26.4

require (
	codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
	github.com/docker/docker v28.5.2+incompatible
	github.com/google/uuid v1.6.0
	github.com/hashicorp/hcl/v2 v2.24.0
	modernc.org/sqlite v1.56.0
)

require (
	github.com/42wim/httpsig v1.2.3 // indirect
	github.com/Microsoft/go-winio v0.6.2 // indirect
	github.com/agext/levenshtein v1.2.1 // indirect
	github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
	github.com/containerd/errdefs v1.0.0 // indirect
	github.com/containerd/errdefs/pkg v0.3.0 // indirect
	github.com/containerd/log v0.1.0 // indirect
	github.com/davidmz/go-pageant v1.0.2 // indirect
	github.com/distribution/reference v0.6.0 // indirect
	github.com/docker/go-connections v0.8.1 // indirect
	github.com/docker/go-units v0.5.0 // indirect
	github.com/dustin/go-humanize v1.0.1 // indirect
	github.com/felixge/httpsnoop v1.1.0 // indirect
	github.com/go-fed/httpsig v1.1.0 // indirect
	github.com/go-logr/logr v1.4.4 // indirect
	github.com/go-logr/stdr v1.2.2 // indirect
	github.com/google/go-cmp v0.7.0 // indirect
	github.com/hashicorp/go-version v1.7.0 // indirect
	github.com/mattn/go-isatty v0.0.24 // indirect
	github.com/mitchellh/go-wordwrap v1.0.1 // indirect
	github.com/moby/docker-image-spec v1.3.1 // indirect
	github.com/moby/sys/atomicwriter v0.1.0 // indirect
	github.com/moby/term v0.5.2 // indirect
	github.com/morikuni/aec v1.1.0 // indirect
	github.com/ncruces/go-strftime v1.0.0 // indirect
	github.com/opencontainers/go-digest v1.0.0 // indirect
	github.com/opencontainers/image-spec v1.1.1 // indirect
	github.com/pkg/errors v0.9.1 // indirect
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
	github.com/zclconf/go-cty v1.16.3 // indirect
	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
	go.opentelemetry.io/otel v1.45.0 // indirect
	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect
	go.opentelemetry.io/otel/metric v1.45.0 // indirect
	go.opentelemetry.io/otel/trace v1.45.0 // indirect
	golang.org/x/crypto v0.39.0 // indirect
	golang.org/x/mod v0.37.0 // indirect
	golang.org/x/sync v0.22.0 // indirect
	golang.org/x/sys v0.47.0 // indirect
	golang.org/x/text v0.40.0 // indirect
	golang.org/x/time v0.15.0 // indirect
	golang.org/x/tools v0.47.0 // indirect
	gotest.tools/v3 v3.5.2 // indirect
	modernc.org/libc v1.74.4 // indirect
	modernc.org/mathutil v1.7.1 // indirect
	modernc.org/memory v1.11.0 // indirect
)
Thinking
The SDK isn't in the local module cache (find didn't return anything). Let me check whether Go is installed, and try downloading the module for investigation. First, let me verify the availability of Go and the module cache.
Tool 🔧 bash {"command":"which go && go version && ls ~/go/pkg/mod/codeberg.org 2>/dev/null || echo \"no mod cache\""}
{"command":"which go && go version && ls ~/go/pkg/mod/codeberg.org 2>/dev/null || echo \"no mod cache\""}
/usr/local/go/bin/go
go version go1.27.0 linux/amd64
no mod cache