Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ gh search issues --label agent-ready gh search prs --author <bot>
- [Embedded defaults](#embedded-defaults)
- [When it stops](#when-it-stops)
- [Control API](#control-api)
- [Web interface](#web-interface)
- [Discord notifications](#discord-notifications)
- [Safety boundaries](#safety-boundaries)
- [Deploying with systemd](#deploying-with-systemd)
Expand Down Expand Up @@ -375,7 +376,8 @@ This repository's own `config.json` is also **compiled into the binary** at buil
"env": { "PATH": "/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin" }
},
"server": {
"addr": "127.0.0.1:8787"
"addr": "127.0.0.1:8787",
"ui": true
},
"store": {
"path": "~/.agent-loop/state.db"
Expand Down Expand Up @@ -427,6 +429,7 @@ This repository's own `config.json` is also **compiled into the binary** at buil
| `verify.commands` | per-repo override, keyed `"owner/name": "shell command"` |
| `verify.env` | extra environment for the test command — mainly `PATH`, so the daemon can find language toolchains (see [Verification](#verification)) |
| `server.addr` | control API bind address; keep loopback-only |
| `server.ui` | mount the web interface at `/ui` (and redirect `/` to it); see [Web interface](#web-interface) |
| `store.path` | SQLite database path |
| `discord.enabled` | turn on Discord status notifications (see [Discord notifications](#discord-notifications)) |
| `discord.webhook_url` | the channel's incoming webhook URL; **required** if `discord.enabled` is true |
Expand Down Expand Up @@ -548,8 +551,37 @@ Loopback-only by default. It can pause and cancel work, so do not expose it.
| `GET /runs/{id}` | one run plus its event timeline |
| `GET /runs/{id}/log` | the raw JSONL transcript of the Claude run |
| `GET /sessions?repo=&issue=&limit=` | Claude session IDs recorded per repo/issue, newest first |
| `GET /config` | current configuration, with the Discord webhook URL redacted |
| `GET /models` | the model registry plus the plan/implement ladders and which models are cooled down |
| `POST /pause` `POST /resume` | stop / resume claiming new work |
| `POST /runs/{id}/cancel` | cancel an in-flight run |
| `POST /poll` | run a discovery pass now, instead of waiting for `github.poll_interval` |
| `GET /` | redirects to `/ui/` when `server.ui` is enabled |
| `GET /ui/` | the web interface (see [Web interface](#web-interface)) |

Mutating routes (`POST`) reject a request whose `Origin` or `Referer` header names a non-loopback
host, so a page loaded from another site cannot drive this API from a visitor's browser. A request
with neither header — `curl`, scripts, anything hitting the API directly — is unaffected; that
remains the documented way to use it.

## Web interface

A small browser console for the control API above, compiled into the binary via `go:embed` (see
`internal/web`) — no separate install, no network access at runtime, no build tooling required.
Open `http://127.0.0.1:8787/` (redirects to `/ui/`) once the daemon is running.

It has five views: a **dashboard** (gate state, active claims, in-flight runs with a Cancel button,
pause/resume, and a "Run discovery now" button), **runs** (filterable history with cost, tokens,
verification outcome, and PR links), a **run detail** page (full metrics, event timeline, and a
lazily-loaded transcript viewer — the transcript is not fetched until you ask for it, since it can
run to megabytes), **sessions** (Claude session IDs per repo/issue, with copy-to-clipboard), and a
read-only **config** view (current settings and the model ladders, with cooled-down models struck
through). It polls `/status` every few seconds (configurable, pauses automatically when the tab is
hidden) rather than holding a persistent connection.

It shares the same posture as the rest of the control API: **loopback-only and unauthenticated by
default.** Anything with local access to the port has full control, same as `curl`. Set
`"server": {"ui": false}` to disable the mount entirely and keep only the JSON API.

## Discord notifications

Expand Down Expand Up @@ -741,6 +773,7 @@ account, or with the same `--config`) so it resolves the same account and config
| `internal/verify` | detect and run the repo's tests |
| `internal/orchestrator` | the loop, prompts, PR reports |
| `internal/server` | Fiber v3 control API |
| `internal/web` | the browser console (`go:embed`), served by `internal/server` at `/ui` |
| `internal/proc` | process-group isolation |
| `internal/install` | embedded systemd unit, `--install`/`--uninstall` |

Expand Down
5 changes: 4 additions & 1 deletion cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,10 @@ func run(f flags) error {

errCh := make(chan error, 2)
if !f.noServer {
srv := server.New(cfg.Server.Addr, st, gateway, orch, log, notifier)
srv := server.New(server.Options{
Addr: cfg.Server.Addr, Store: st, Gate: gateway, Ctrl: orch,
Log: log, Discord: notifier, Config: cfg, Registry: registry,
})
go func() { errCh <- srv.Listen(ctx) }()
}
go func() { errCh <- orch.Run(ctx) }()
Expand Down
3 changes: 2 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
}
},
"server": {
"addr": "127.0.0.1:8787"
"addr": "127.0.0.1:8787",
"ui": true
},
"store": {
"path": "~/.agent-loop/state.db"
Expand Down
6 changes: 5 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ type VerifyConfig struct {
type ServerConfig struct {
// Addr should stay on loopback: this API can pause and cancel work.
Addr string `json:"addr"`
// UI mounts the browser console at /ui (and redirects / to it). It shares
// the same loopback-only, no-authentication posture as the rest of the
// control API.
UI bool `json:"ui"`
}

type StoreConfig struct {
Expand Down Expand Up @@ -243,7 +247,7 @@ func Default() Config {
UsageCachePath: "~/.agent-loop/usage-cache.json",
},
Verify: VerifyConfig{AutoDetect: true, Commands: map[string]string{}},
Server: ServerConfig{Addr: "127.0.0.1:8787"},
Server: ServerConfig{Addr: "127.0.0.1:8787", UI: true},
Store: StoreConfig{Path: "~/.agent-loop/state.db"},
Discord: DiscordConfig{Enabled: false},
Git: GitConfig{
Expand Down
20 changes: 20 additions & 0 deletions internal/orchestrator/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ type Orchestrator struct {
// botLogin caches the daemon's own GitHub login, used to scope PR
// discovery and to make sure the daemon never reacts to its own comments.
botLogin string

// poll carries operator-requested discovery passes in from Poll(), see Run.
poll chan struct{}
}

type repoMeta struct {
Expand All @@ -89,6 +92,7 @@ func New(opts Options) *Orchestrator {
activeRepos: map[string]bool{},
cancels: map[string]context.CancelFunc{},
repoInfo: map[string]repoMeta{},
poll: make(chan struct{}, 1),
}
}

Expand Down Expand Up @@ -116,10 +120,26 @@ func (o *Orchestrator) Run(ctx context.Context) error {
return nil
case <-ticker.C:
o.tick(ctx)
case <-o.poll:
o.log.Info("discovery pass requested by operator")
o.tick(ctx)
}
}
}

// Poll asks for a discovery pass as soon as Run's loop is next free, without
// waiting for the regular poll interval. It reports false when a pass is
// already queued, so an operator mashing the button in the web console does
// not pile up redundant ticks.
func (o *Orchestrator) Poll() bool {
select {
case o.poll <- struct{}{}:
return true
default:
return false
}
}

// RunOnce does a single discovery pass and waits for whatever it started.
func (o *Orchestrator) RunOnce(ctx context.Context) error {
o.tick(ctx)
Expand Down
165 changes: 148 additions & 17 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,22 @@ import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"

"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/static"

"github.com/ableinc/coding-agent-loop/internal/config"
"github.com/ableinc/coding-agent-loop/internal/discord"
"github.com/ableinc/coding-agent-loop/internal/gate"
"github.com/ableinc/coding-agent-loop/internal/models"
"github.com/ableinc/coding-agent-loop/internal/store"
"github.com/ableinc/coding-agent-loop/internal/web"
)

// Controller is the slice of the orchestrator the API needs. Keeping it an
Expand All @@ -27,40 +34,62 @@ import (
type Controller interface {
Cancel(runID string) bool
ActiveRepos() []string
// Poll asks for a discovery pass now, reporting false if one is already
// queued.
Poll() bool
}

// Options are the Server's dependencies.
type Options struct {
Addr string
Store *store.Store
Gate *gate.Gate
Ctrl Controller
Log *slog.Logger
Discord *discord.Notifier
Config config.Config
Registry *models.Registry
}

// Server wraps the Fiber app.
type Server struct {
app *fiber.App
addr string
store *store.Store
gate *gate.Gate
ctrl Controller
log *slog.Logger
start time.Time
discord *discord.Notifier
app *fiber.App
addr string
store *store.Store
gate *gate.Gate
ctrl Controller
log *slog.Logger
start time.Time
discord *discord.Notifier
cfg config.Config
registry *models.Registry
}

// New builds the API.
func New(addr string, st *store.Store, g *gate.Gate, ctrl Controller, log *slog.Logger, d *discord.Notifier) *Server {
func New(o Options) *Server {
log := o.Log
if log == nil {
log = slog.Default()
}
s := &Server{
app: fiber.New(fiber.Config{AppName: "coding-agent-loop"}),
addr: addr,
store: st,
gate: g,
ctrl: ctrl,
log: log,
start: time.Now(),
discord: d,
app: fiber.New(fiber.Config{AppName: "coding-agent-loop"}),
addr: o.Addr,
store: o.Store,
gate: o.Gate,
ctrl: o.Ctrl,
log: log,
start: time.Now(),
discord: o.Discord,
cfg: o.Config,
registry: o.Registry,
}
s.routes()
return s
}

func (s *Server) routes() {
s.app.Use(s.sameOrigin)

s.app.Get("/healthz", s.health)
s.app.Get("/status", s.status)
s.app.Get("/runs", s.listRuns)
Expand All @@ -70,6 +99,60 @@ func (s *Server) routes() {
s.app.Post("/pause", s.pause)
s.app.Post("/resume", s.resume)
s.app.Post("/runs/:id/cancel", s.cancelRun)
s.app.Get("/config", s.getConfig)
s.app.Get("/models", s.getModels)
s.app.Post("/poll", s.pollNow)

if s.cfg.Server.UI {
s.app.Get("/", func(c fiber.Ctx) error { return c.Redirect().Status(http.StatusFound).To("/ui/") })
s.app.Use("/ui", static.New("", static.Config{
FS: web.Assets,
IndexNames: []string{"index.html"},
CacheDuration: -1, // no stale console after an upgrade
}))
}
}

// sameOrigin rejects a mutating request whose Origin (or, lacking that,
// Referer) header names a non-loopback host. A page loaded from any other
// origin can still fire a simple cross-origin POST with no preflight, and
// shipping a browser console here makes it worth closing that off. A request
// with neither header — curl, a script, an operator's own tooling — is
// unaffected: those are the documented way to drive this API and carry no
// Origin at all.
func (s *Server) sameOrigin(c fiber.Ctx) error {
if c.Method() == fiber.MethodGet || c.Method() == fiber.MethodHead {
return c.Next()
}
origin := c.Get(fiber.HeaderOrigin)
if origin == "" {
origin = c.Get(fiber.HeaderReferer)
}
if origin == "" {
return c.Next()
}
if !isLoopbackOrigin(origin, s.addr) {
return s.fail(c, http.StatusForbidden, errors.New("cross-origin request refused"))
}
return c.Next()
}

// isLoopbackOrigin reports whether origin (an Origin or Referer header value)
// names localhost, a loopback IP, or the host this server itself is bound to.
func isLoopbackOrigin(origin, addr string) bool {
u, err := url.Parse(origin)
if err != nil || u.Hostname() == "" {
return false
}
host := strings.ToLower(u.Hostname())
switch host {
case "localhost", "127.0.0.1", "::1":
return true
}
if addrHost, _, err := net.SplitHostPort(addr); err == nil && addrHost != "" && strings.EqualFold(addrHost, host) {
return true
}
return false
}

// Listen serves until ctx is cancelled, then shuts down gracefully.
Expand Down Expand Up @@ -268,6 +351,54 @@ func (s *Server) cancelRun(c fiber.Ctx) error {
return c.JSON(fiber.Map{"cancelled": true, "run": id})
}

// getConfig returns the daemon's configuration for the web console, with the
// Discord webhook URL blanked: it is a secret, everything else here is not.
func (s *Server) getConfig(c fiber.Ctx) error {
cfg := s.cfg
webhookSet := cfg.Discord.WebhookURL != ""
cfg.Discord.WebhookURL = ""
return c.JSON(fiber.Map{
"config": cfg,
"discord_webhook_set": webhookSet,
})
}

// getModels reports the full model registry plus which models are currently
// cooled down, so the console can show the plan/implement ladders with
// sidelined models struck through rather than silently missing.
func (s *Server) getModels(c fiber.Ctx) error {
if s.registry == nil {
return c.JSON(fiber.Map{"models": []models.Model{}, "cooled_down": []string{}, "plan": []models.Model{}, "implement": []models.Model{}})
}
cooled, err := s.store.CooledDownModels(c.Context())
if err != nil {
s.log.Warn("cooldown lookup failed", "error", err)
cooled = nil
}
cooledList := make([]string, 0, len(cooled))
for id := range cooled {
cooledList = append(cooledList, id)
}
return c.JSON(fiber.Map{
"models": s.registry.Models,
"cooled_down": cooledList,
"plan": s.registry.Ladder(models.RolePlan, cooled),
"implement": s.registry.Ladder(models.RoleImplement, cooled),
})
}

// pollNow asks the orchestrator for a discovery pass right away, rather than
// waiting for the next tick of github.poll_interval.
func (s *Server) pollNow(c fiber.Ctx) error {
if s.ctrl == nil {
return s.fail(c, http.StatusServiceUnavailable, errors.New("no controller attached"))
}
if !s.ctrl.Poll() {
return s.fail(c, http.StatusConflict, errors.New("a discovery pass is already queued"))
}
return c.JSON(fiber.Map{"queued": true})
}

func (s *Server) fail(c fiber.Ctx, code int, err error) error {
if code >= 500 {
s.log.Error("api error", "path", c.Path(), "error", err)
Expand Down
Loading
Loading