Details and log output for this agent run.
{"command": "cat /event 2>/dev/null | head -100; echo \"---\"; ls /project"}
{"id":5,"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-24T21:07:20+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},"team":null,"state":"REQUEST_CHANGES","body":"Please fix these details.","commit_id":"b24f77cdb5fec71f7f7348c489083feb2ae0db99","stale":false,"official":true,"dismissed":false,"comments_count":0,"submitted_at":"2026-08-24T21:18:06+02:00","html_url":"https://code.stdio.dk/abrander/zoo/pulls/44#issuecomment-4695","pull_request_url":"https://code.stdio.dk/abrander/zoo/pulls/44"}---
README.md cmd go.mod go.sum internal zoo.hcl.example
{"subcommand": "log", "args": ["--oneline", "-10"]}
b24f77c docs: omit installation instructions from README per review feedback 0717fa6 docs: add comprehensive README with project overview, setup guide, and agent documentation 42fe9d9 Merge pull request 'Include full issue comment thread in the agent's seed message' (#34) from issue-33-greg into main 9e5dece Include full issue comment thread in the agent's seed message c8784fa Merge pull request 'fix(tools): make grep_search actually use regex (ERE)' (#32) from issue-31-greg into main 2c5b978 fix(tools): make grep_search actually use regex (ERE) ea073a3 Merge pull request 'Add job overview on dashboard' (#26) from issue-24-anna into main 3b38675 feat(dashboard): add job overview on dashboard 1c33680 Merge pull request 'Move max_live_agents into the environment block' (#23) from issue-22-mika into main 7cf22ff Move max_live_agents into the environment block
{"command": "env | grep -iE 'forgejo|token|gitea|api|url' | sed 's/\\(TOKEN=\\).\\{8\\}/\\1********/'; echo \"---\"; git remote -v"}
--- origin https://code.stdio.dk/abrander/zoo.git (fetch) origin https://code.stdio.dk/abrander/zoo.git (push)
{"path": "README.md", "start_line": 1, "end_line": 120}
{"path":"README.md","content":"# 🐾 Zoo\r\n\r\n**Autonomous AI agents that work your Forgejo issues \u0026 pull requests.**\r\n\r\nZoo watches a [Forgejo](https://codeberg.org/forgejo/forgejo) instance for issue and pull request events, then dispatches them to configured AI agents running in isolated Docker containers. Each agent has its own role, LLM model, and personality — like a menagerie working together to keep your projects moving.\r\n\r\n---\r\n\r\n## ✨ Features\r\n\r\n- **Multi-agent orchestration** — Assign different tasks to specialized agents (reviewers, managers, developers).\r\n- **Event-driven routing** — Configure which agent handles `issue:new`, `pr:new`, `issue:comment`, `issue:assigned`, and more.\r\n- **LLM flexibility** — Plug in any OpenAI-compatible API; each agent gets its own model choice.\r\n- **Isolated execution** — Agents run in Docker containers with full filesystem access but no persistence between runs.\r\n- **Live dashboard** — Real-time web UI showing active agents, logs, and job history.\r\n- **Webhook \u0026 polling support** — React to events instantly via webhooks, or fall back to polling.\r\n\r\n---\r\n\r\n## 🚀 Quick Start\r\n\r\n### Prerequisites\r\n\r\n| Requirement | Version |\r\n|-------------|---------|\r\n| Go | 1.26+ |\r\n| Docker | Latest |\r\n| Forgejo | Any (self-hosted or codeberg.dk) |\r\n| LLM endpoint | OpenAI-compatible API |\r\n\r\n### Configuration\r\n\r\nCopy the example config and customize it:\r\n\r\n```bash\r\ncp zoo.hcl.example zoo.hcl\r\n```\r\n\r\nEdit `zoo.hcl` with your Forgejo credentials, LLM tokens, and agent definitions. See the [configuration reference](#-configuration-reference) below.\r\n\r\n### Running\r\n\r\n```bash\r\ngo build -o zoo ./cmd/zoo\r\n./zoo\r\n```\r\n\r\nThe daemon starts on port `:8080` by default. Open your browser to see the dashboard.\r\n\r\n---\r\n\r\n## 👥 Meet the Agents\r\n\r\nZoo ships with four pre-configured agents, each with a distinct role:\r\n\r\n| Agent | Role | Suggested LLM | Handles |\r\n|----------|-----------------------|---------------------|----------------------------------|\r\n| **leon** | Engineering Manager | Qwen 3.8 | New issues, comments |\r\n| **greg** | Senior Developer | Qwen 3.8 | Pull request reviews |\r\n| **anna** | UI/UX Designer | Qwen 3.6 | Design-related issues \u0026 PRs |\r\n| **mika** | Junior Developer | Qwen 3.6 | Assigned issues |\r\n\r\nYou can add, remove, or reassign agents freely in your `zoo.hcl`.\r\n\r\n---\r\n\r\n## ⚙️ Configuration Reference\r\n\r\nAll settings live in a single HCL file (`zoo.hcl`). Here's what each section controls:\r\n\r\n### LLM Definitions\r\n\r\nDefine one or more LLM endpoints. Agents reference these by name.\r\n\r\n```hcl\r\nllm \"Qwen 3.6\" {\r\n openai = \"https://your-llm-endpoint\"\r\n token = \"YOUR_API_TOKEN\"\r\n model = \"model-name\"\r\n}\r\n```\r\n\r\n### Forgejo Connection\r\n\r\n```hcl\r\nforgejo {\r\n url = \"https://code.stdio.dk\"\r\n token = \"ZOO_SERVICE_TOKEN\"\r\n webhook_secret = \"SHARED_SECRET\" # optional if using polling\r\n}\r\n```\r\n\r\n### Environment\r\n\r\n```hcl\r\nenvironment {\r\n docker_image = \"golang:latest\" # base image for agent containers\r\n max_live_agents = 5 # concurrent agent limit\r\n}\r\n```\r\n\r\n### Agent Definition\r\n\r\n```hcl\r\nagent \"anna\" {\r\n llm = \"Qwen 3.6\"\r\n token = \"ANNA_FORGEJO_TOKEN\"\r\n}\r\n```\r\n\r\n### Event Routing\r\n\r\nMap event types to agents with optional custom instructions:\r\n\r\n```hcl\r\nevent \"issue:new\" {\r\n agent = \"leon\"\r\n instructions = \"Triage this issue.\"\r\n}\r\n\r","start_line":1,"end_line":120,"total_lines":217,"truncated":true}
{"command": "cd /project && git config --list 2>/dev/null | sed 's/password=.*/password=***/'; echo \"---\"; ls -la /root 2>/dev/null; cat /root/.netrc 2>/dev/null | sed 's/password .*/password ***/'; echo \"---\"; ls -la /project"}
safe.directory=* core.repositoryformatversion=0 core.filemode=true core.bare=false core.logallrefupdates=true remote.origin.url=https://code.stdio.dk/abrander/zoo.git remote.origin.fetch=+refs/heads/main:refs/remotes/origin/main branch.main.remote=origin branch.main.merge=refs/heads/main user.name=Greg Coolio user.email=greg@example.com color.ui=never --- total 16 drwx------ 2 root root 4096 Aug 3 00:00 . drwxr-xr-x 1 root root 4096 Aug 24 19:18 .. -rw-r--r-- 1 root root 607 Jul 4 09:05 .bashrc -rw-r--r-- 1 root root 132 Jul 4 09:05 .profile --- total 60 drwxrwxr-x 5 1000 1000 4096 Aug 24 19:18 . drwxr-xr-x 1 root root 4096 Aug 24 19:18 .. drwxrwxr-x 8 1000 1000 4096 Aug 24 19:18 .git -rw-rw-r-- 1 1000 1000 22 Aug 24 19:18 .gitignore -rw-rw-r-- 1 1000 1000 6046 Aug 24 19:18 README.md drwxrwxr-x 3 1000 1000 4096 Aug 24 19:18 cmd -rw-rw-r-- 1 1000 1000 2628 Aug 24 19:18 go.mod -rw-rw-r-- 1 1000 1000 16578 Aug 24 19:18 go.sum drwxrwxr-x 11 1000 1000 4096 Aug 24 19:18 internal -rw-rw-r-- 1 1000 1000 1564 Aug 24 19:18 zoo.hcl.example
{"path": "README.md", "start_line": 120, "end_line": 217}
{"path":"README.md","content":"\r\nevent \"issue:assigned\" {\r\n // No `agent` — dynamically matches the assignee's username\r\n instructions = \"Please handle this issue.\"\r\n}\r\n\r\nevent \"pr:new\" {\r\n agent = \"greg\"\r\n instructions = \"Review this pull request.\"\r\n}\r\n```\r\n\r\n---\r\n\r\n## 🔧 Command-Line Flags\r\n\r\n| Flag | Default | Description |\r\n|-------------------|-----------------|----------------------------------------------|\r\n| `-config` | `zoo.hcl` | Path to the configuration file |\r\n| `-db` | `zoo.db` | SQLite database path |\r\n| `-listen` | `:8080` | HTTP listen address |\r\n| `-run-timeout` | (see source) | Max wall-clock time per agent run |\r\n| `-keep-on-failure`| `false` | Preserve container after failure for debugging |\r\n\r\n---\r\n\r\n## 📁 Project Structure\r\n\r\n```\r\n├── cmd/zoo/main.go # Entry point\r\n├── internal/\r\n│ ├── agentrun/ # Docker-based agent execution\r\n│ ├── config/ # HCL config loader \u0026 validator\r\n│ ├── forgejo/ # Webhook receiver \u0026 event poller\r\n│ ├── livelog/ # Real-time log streaming hub\r\n│ ├── llm/ # LLM client \u0026 streaming\r\n│ ├── scheduler/ # Event-to-agent dispatcher\r\n│ ├── store/ # SQLite state management\r\n│ ├── tools/ # Agent tool registry\r\n│ └── web/ # Dashboard HTTP server\r\n└── zoo.hcl.example # Configuration template\r\n```\r\n\r\n---\r\n\r\n## 🛠 Development\r\n\r\n### Running Tests\r\n\r\n```bash\r\ngo test ./...\r\n```\r\n\r\n### Adding a New Agent\r\n\r\n1. Define the agent in `zoo.hcl`:\r\n\r\n ```hcl\r\n agent \"newagent\" {\r\n llm = \"Qwen 3.6\"\r\n token = \"NEW_AGENT_TOKEN\"\r\n }\r\n ```\r\n\r\n2. Add an event route:\r\n\r\n ```hcl\r\n event \"issue:new\" {\r\n agent = \"newagent\"\r\n }\r\n ```\r\n\r\n3. Update your Forgejo profile with the agent's role description.\r\n\r\n### Adding a New Tool\r\n\r\nTools are registered in `internal/tools/registry.go`. Each tool is a Go function that operates on the agent's `/project` directory. See existing tools for patterns.\r\n\r\n---\r\n\r\n## 🤝 Contributing\r\n\r\nContributions are welcome! Please:\r\n\r\n1. Fork the repository\r\n2. Create a feature branch\r\n3. Make your changes\r\n4. Open a pull request\r\n\r\n---\r\n\r\n## 📄 License\r\n\r\nThis project is available as open source under the terms of the [MIT License](LICENSE).\r\n\r\n---\r\n\r\n*Made with ❤️ by the [zoo](https://code.stdio.dk/abrander/zoo) team.*\r","start_line":120,"end_line":217,"total_lines":217,"truncated":false}
{"command": "curl -s -m 10 \"https://code.stdio.dk/api/v1/repos/abrander/zoo/pulls/44\" | head -c 500; echo; echo \"---reviews---\"; curl -s -m 10 \"https://code.stdio.dk/api/v1/repos/abrander/zoo/pulls/44/reviews\" | head -c 2000"}
{"message":"The target couldn't be found.","url":"https://code.stdio.dk/api/swagger","errors":[]}
---reviews---
{"message":"The target couldn't be found.","url":"https://code.stdio.dk/api/swagger","errors":[]}
{"command": "cd /project && cat cmd/zoo/main.go"}
// 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, fgClient)
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
}
{"command": "cd /project && cat zoo.hcl.example && echo \"=== go.mod ===\" && head -5 go.mod && echo \"=== internal ===\" && ls internal/"}
# 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 = "ZOO_SERVICE_TOKEN"
# Shared secret configured on the Forgejo webhook. Required to accept
# webhook deliveries; polling works without it.
webhook_secret = "EXAMPLE_WEBHOOK_SECRET"
}
environment {
docker_image = "golang:latest"
max_live_agents = 5
}
agent "anna" {
llm = "Qwen 3.6"
token = "ANNA_FORGEJO_TOKEN"
}
agent "greg" {
llm = "Qwen 3.8"
token = "GREG_FORGEJO_TOKEN"
}
# "leon" doubles as the expected Forgejo assignee username for
# issue:assigned events - see the event block below.
agent "leon" {
llm = "Qwen 3.8"
token = "LEON_FORGEJO_TOKEN"
}
agent "mika" {
llm = "Qwen 3.6"
token = "MIKA_FORGEJO_TOKEN"
}
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."
}
=== go.mod ===
module github.com/abrander/zoo
go 1.26.4
require (
=== internal ===
agentrun config forgejo livelog llm scheduler store tools web
{"command": "cd /project && grep -rn \"DefaultTimeout\" internal/agentrun/ | head -5; echo \"=== tools ===\"; ls internal/tools/; echo \"=== LICENSE? ===\"; ls LICENSE* 2>&1; echo \"=== tests ===\"; find . -name \"*_test.go\" | head; echo \"=== web ===\"; ls internal/web/"}
internal/agentrun/run.go:29:// DefaultTimeout bounds a single agent run's wall-clock time if the internal/agentrun/run.go:31:const DefaultTimeout = 120 * time.Minute internal/agentrun/run.go:54: timeout = DefaultTimeout === tools === bash.go forgejo.go list_files.go registry.go tools_test.go context.go git.go move_file.go remove_file.go write_file.go edit-file.go grep_search.go read_file.go shell.go === LICENSE? === ls: cannot access 'LICENSE*': No such file or directory === tests === ./internal/config/config_test.go ./internal/web/web_test.go ./internal/livelog/hub_test.go ./internal/tools/tools_test.go ./internal/llm/streamer_test.go ./internal/store/store_test.go ./internal/forgejo/webhook_test.go ./internal/forgejo/client_test.go ./internal/agentrun/roster_test.go ./internal/agentrun/seed_test.go === web === templates.go web.go web_test.go
{"command": "cd /project && grep -rn \"polling\\|Poll\" internal/forgejo/*.go | head -10; echo \"=== dashboard routes ===\"; grep -rn \"HandleFunc\\|mux.Handle\\|case \\\"/\" internal/web/*.go | head -20"}
internal/forgejo/client.go:2:// polling results into a common Event stream for the scheduler.
internal/forgejo/event.go:16:// the polling fallback, and is what the scheduler dispatches on.
internal/forgejo/event.go:21: // it arrived via webhook or polling.
internal/forgejo/event.go:38: // equivalent when polling), written to /event in the agent container.
internal/forgejo/watcher.go:12:const PollInterval = 10 * time.Second
internal/forgejo/watcher.go:14:// Watcher merges the webhook receiver and the polling fallback into a
internal/forgejo/watcher.go:46:// Run drives the polling fallback until ctx is canceled. The webhook
internal/forgejo/watcher.go:49: ticker := time.NewTicker(PollInterval)
internal/forgejo/webhook_test.go:144:func TestAssignedIDMatchesAcrossWebhookAndPoll(t *testing.T) {
=== dashboard routes ===
internal/web/web.go:57: mux.HandleFunc("GET /{$}", s.handleIndex)
internal/web/web.go:58: mux.HandleFunc("GET /jobs", s.handleJobs)
internal/web/web.go:59: mux.HandleFunc("GET /jobs/{id}", s.handleJobDetail)
internal/web/web.go:60: mux.HandleFunc("GET /jobs/{id}/events", s.handleJobEvents)
{"command": "cd /project && sed -n '1,80p' internal/web/web.go; echo \"=== index template ===\"; grep -n \"func (s \\*Server) handleIndex\" -A 30 internal/web/web.go"}
// Package web is zoo's minimal server-rendered dashboard: config
// overview, live queue/history, and a per-job log tail.
package web
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"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/store"
)
type Server struct {
cfg *config.Config
store *store.Store
hub *livelog.Hub
fg *forgejo.Client
tmpl *template.Template
avatarMu sync.Mutex
avatarCache map[string]avatarCacheEntry
}
// avatarCacheTTL bounds how long a resolved avatar URL is trusted before
// it's re-fetched from Forgejo. Avatars rarely change, but a user can
// re-upload one (which changes its URL), so the cache expires instead of
// living for the process lifetime.
const avatarCacheTTL = time.Hour
type avatarCacheEntry struct {
url string
fetchedAt time.Time
}
func New(cfg *config.Config, st *store.Store, hub *livelog.Hub, fg *forgejo.Client) *Server {
return &Server{
cfg: cfg,
store: st,
hub: hub,
fg: fg,
tmpl: template.Must(template.New("").Parse(templates)),
avatarCache: map[string]avatarCacheEntry{},
}
}
// Handler returns the dashboard's http.Handler, gated by config.Web's
// bearer token if one is set.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /jobs", s.handleJobs)
mux.HandleFunc("GET /jobs/{id}", s.handleJobDetail)
mux.HandleFunc("GET /jobs/{id}/events", s.handleJobEvents)
return s.authMiddleware(mux)
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
if s.cfg.Web == nil || s.cfg.Web.Token == "" {
return next
}
token := s.cfg.Web.Token
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth != "Bearer "+token {
w.Header().Set("WWW-Authenticate", `Bearer realm="zoo"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
=== index template ===
85:func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
86- // Fetch active (pending or running) jobs for the dashboard overview.
87- // We fetch more than we display so we can filter to just active ones.
88- allJobs, err := s.store.ListJobs(r.Context(), 200)
89- if err != nil {
90- http.Error(w, err.Error(), http.StatusInternalServerError)
91- return
92- }
93-
94- // Collect unique agent names from active jobs.
95- var agentNames []string
96- seenAgents := make(map[string]bool)
97-
98- var activeJobs []activeJobRow
99- for _, j := range allJobs {
100- if j.Status != store.JobPending && j.Status != store.JobRunning {
101- continue
102- }
103- if !seenAgents[j.Agent] {
104- seenAgents[j.Agent] = true
105- agentNames = append(agentNames, j.Agent)
106- }
107- activeJobs = append(activeJobs, activeJobRow{
108- Job: j,
109- AvatarURL: s.avatarFor(j.Agent),
110- })
111- }
112-
113- type indexData struct {
114- *config.Config
115- ActiveJobs []activeJobRow
{"command": "cd /project && grep -n \"Mount\\|Volume\\|/project\\|/event\" internal/agentrun/*.go | head -20"}
internal/agentrun/docker.go:43: WorkingDir: "/project",
internal/agentrun/docker_integration_test.go:76: containerID, err := rt.createContainer(ctx, "golang:latest", []string{projectDir + ":/project"}, "zoo-test-git")
internal/agentrun/run.go:3:// the triggering event written to /event, and the tool-calling loop
internal/agentrun/run.go:139: // and the container it's bind-mounted into (as /project) actually
internal/agentrun/run.go:154: projectDir + ":/project",
internal/agentrun/run.go:155: eventPath + ":/event:ro",
internal/agentrun/run.go:169: // /project is bind-mounted from the host, so it's owned by the host