Skip to content

Web Interface #11

Description

@ableinc

Create a web interface that uses the Control API to monitor and interact with the application. It should be simple and straightforward and should be baked directly into the binary as well.

It should have a modern, well-designed UI. It should be responsive. It should have everything I need to interface with the application.

Activity

  1. ableinc commented on Aug 25, 2026

    @ableinc
    OwnerAuthor

    The coding agent could not complete this issue (attempt 1).

    claude run failed: claude exited with error: exit status 1 (subtype "success"; terminal_reason "api_error"; api_error_status 401; after 1 turn(s); claude said: Failed to authenticate. API Error: 401 OAuth access token has expired. Re-authenticate to continue.)
    

    It will try again after Tue, 25 Aug 2026 10:29:21 UTC (the wait doubles with each consecutive failure). Remove the agent-ready label to stop it retrying.

    coding-agent-loop run e3a4a062-a4b8-4fbe-b912-10d42f55788a

  2. ableinc commented on Aug 25, 2026

    @ableinc
    OwnerAuthor

    The coding agent could not complete this issue (attempt 2).

    claude run failed: claude exited with error: exit status 1 (subtype "success"; terminal_reason "api_error"; api_error_status 401; after 1 turn(s); claude said: Failed to authenticate. API Error: 401 OAuth access token has expired. Re-authenticate to continue.)
    

    It will try again after Tue, 25 Aug 2026 11:00:20 UTC (the wait doubles with each consecutive failure). Remove the agent-ready label to stop it retrying.

    coding-agent-loop run 71a63804-3e3a-4c2d-918f-e8b139b16a10

  3. ableinc commented on Aug 25, 2026

    @ableinc
    OwnerAuthor

    The coding agent could not complete this issue (attempt 3).

    claude run failed: claude exited with error: exit status 1 (subtype "success"; terminal_reason "api_error"; api_error_status 401; after 1 turn(s); claude said: Failed to authenticate. API Error: 401 OAuth access token has expired. Re-authenticate to continue.)
    

    It will try again after Tue, 25 Aug 2026 12:02:20 UTC (the wait doubles with each consecutive failure). Remove the agent-ready label to stop it retrying.

    coding-agent-loop run 9c0dac31-6f5a-4da8-a6d5-1bb62bad9afd

  4. 15 remaining items

  5. ableinc commented on Aug 25, 2026

    @ableinc
    OwnerAuthor

    Plan

    Plan: embedded web UI for the control API

    Add a dependency-free single-page web console, compiled into the binary via go:embed, served by the existing Fiber control API at /ui (with / redirecting to it), plus the three small API additions it needs (GET /config, GET /models, POST /poll) and same-origin hardening on the mutating routes.

    Context

    internal/server (Fiber v3) already exposes everything the daemon knows — gate state, runs, events, transcripts, sessions, pause/resume/cancel — but only as JSON over curl. Issue #11 asks for a browser interface over that same API, "baked directly into the binary". The binary is genuinely standalone: internal/install copies only the executable to /opt/coding-agent-loop, and embedded.go already compiles config.json/models.json in for exactly this reason. So the UI has to ship as embedded assets, not as files on disk.

    Outcome: http://127.0.0.1:8787/ in a browser gives a modern, responsive dashboard for monitoring runs and driving the daemon, with no new build tooling, no npm, and no network access at runtime.

    Design decisions (made, not asked — flagging them for review)

    1. No frontend build step. Hand-written HTML/CSS/vanilla ES modules, no framework, no CDN. The Makefile and .github/workflows/ci.yml have no Node toolchain, the API is loopback-only and may be offline, and make build must stay a single go build. A React/Vite pipeline would mean a committed dist/ or a Node step in CI — not worth it for a console of this size.
    2. Hash routing (#/runs/<id>), so deep links never reach the static handler and no SPA-fallback rewrite is needed.
    3. Polling, not SSE. fiber/v3/middleware/sse exists, but a 5s fetch of /status (paused while document.hidden) is simpler and has no stream-lifecycle failure modes.
    4. Origin check added to mutating routes. Today any web page you visit can POST http://127.0.0.1:8787/pause — a simple cross-origin POST needs no preflight. Shipping a browser UI makes it fair to fix this now. The check rejects requests whose Origin/Referer is present and non-loopback, and allows requests with no Origin — so the documented curl -XPOST localhost:8787/pause keeps working unchanged.
    5. Existing JSON shape is not touched. store.Run/Event/Session/Claim have no struct tags, so the API emits ID, Repo, CreatedAt, … The UI adapts to that rather than the reverse; adding tags would silently break every existing curl | jq script.

    Files

    New:

    • internal/web/web.go — package web: //go:embed assets + exported FS embed.FS and Assets fs.FS (fs.Sub(FS, "assets")). The embed lives here because a go:embed pattern cannot escape its own directory.
    • internal/web/assets/index.html — shell: header, nav, five view containers.
    • internal/web/assets/app.css — design tokens + layout.
    • internal/web/assets/app.js — router, API client, renderers.
    • internal/web/web_test.go — asserts the embedded FS contains index.html, app.css, app.js and that index.html references the other two (catches a rename that silently ships a broken console).

    Modified:

    • internal/server/server.go — Options struct for New, static mount, three new routes, origin middleware.
    • internal/server/server_test.go — update testServer helper for the new New, add UI/origin cases.
    • internal/orchestrator/loop.go — Poll() and a poll channel in Run.
    • cmd/agent.go — one call site: server.New(server.Options{...}).
    • internal/config/config.go, config.json, config.example.json — server.ui toggle.
    • README.md — Control API table rows, a Web interface section + TOC entry, internal/web in the layout table, server.ui in the config reference.

    Step 1 — internal/web

    // Package web is the browser console for the control API, compiled into the
    // binary so an installed daemon (which is only the executable) still serves it.
    package web
    
    //go:embed assets
    var FS embed.FS
    
    // Assets is FS rooted at the asset directory.
    var Assets = must(fs.Sub(FS, "assets"))

    Mirror the doc-comment style of embedded.go, which explains the same "why is this file here" constraint.

    Step 2 — internal/server wiring

    Refactor New to an options struct. It already takes six positional args and this change adds config.Config and *models.Registry; orchestrator.New(orchestrator.Options{...}) is the established pattern in this repo.

    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
    }
    func New(o Options) *Server

    internal/server does not import internal/config today but server_test.go already does, and config imports nothing from server, so there is no cycle. Same for internal/models.

    Routes, appended to s.routes() after the existing ones so API paths always win:

    s.app.Get("/config", s.getConfig)    // redacted
    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(302).To("/ui/") })
        s.app.Use("/ui", static.New("assets", static.Config{
            FS:            web.FS,
            IndexNames:    []string{"index.html"},
            CacheDuration: -1,   // no stale console after an upgrade
        }))
    }

    static.New derives its strip-prefix from c.Route().Path, so mounting with Use("/ui", …) and root: "assets" resolves /ui/app.css → assets/app.css inside the embed. The static middleware calls c.Next() for non-GET/HEAD, so it never swallows the POST routes.

    Handlers:

    • getConfig — returns s.cfg with Discord.WebhookURL blanked and a separate "discord_webhook_set": bool. config.Config is fully json-tagged, so this serializes cleanly; take a shallow value copy and overwrite the one string (no maps mutated).
    • getModels — {"models": registry.Models, "plan": registry.Ladder(models.RolePlan, cooled), "implement": registry.Ladder(models.RoleImplement, cooled)}, where cooled comes from the existing store.CooledDownModels(ctx). Nil-guard Registry so tests can pass none.
    • pollNow — s.ctrl.Poll(); 200 {"queued": true} or 409 when a pass is already pending.

    Origin middleware, registered as the first s.app.Use in routes():

    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 == "" { return c.Next() }                     // curl, scripts
        if !isLoopbackOrigin(origin) {
            return s.fail(c, http.StatusForbidden, errors.New("cross-origin request refused"))
        }
        return c.Next()
    }

    isLoopbackOrigin parses the URL and accepts host localhost, 127.0.0.1, [::1], or a host matching s.addr's host. Keep it a small unexported helper with its own table test — this is the security-relevant part of the change.

    Step 3 — Poll() on the orchestrator

    Orchestrator.Run currently only ticks on time.Ticker. Add poll chan struct{} (buffered, size 1) created in New, a select case in the Run loop that calls o.tick(ctx), and:

    // Poll asks for a discovery pass now. It reports false when one is already queued.
    func (o *Orchestrator) Poll() bool {
        select { case o.poll <- struct{}{}: return true; default: return false }
    }

    Add Poll() bool to server.Controller and to the fakeController in server_test.go. This is the only genuinely new capability in the change — the "Run discovery now" button. It is severable: drop step 3 and the button if a reviewer would rather not touch the loop.

    Step 4 — the console

    index.html is a static shell (header, nav, five <section>s); app.js fills them. Structure of app.js:

    • API client — get(path) / post(path, body) wrappers around fetch, one error banner on failure.
    • Formatters — fmtTime, fmtDuration, fmtUSD, fmtTokens, statusBadge, issueURL(repo, issue). Gotcha to encode once: Go zero time.Time serializes as "0001-01-01T00:00:00Z"; fmtTime must render that as —. Same for empty PRURL/Branch/SessionID.
    • Router — hashchange → one of five renderers.
    • Poller — setInterval 5s hitting /status, suspended on visibilitychange, interval selectable (2s/5s/15s/off) and persisted in localStorage.

    Views:

    Route Source Contents
    #/ dashboard GET /status, GET /healthz stat tiles (in-flight, active repos, usage %, 24h spend computed client-side from /runs); gate cards with blocked_until countdown; claims table; in-flight runs with a Cancel button (POST /runs/{id}/cancel); Pause/Resume and Run discovery now in the header
    #/runs GET /runs?limit=&repo= table: status badge, repo#issue (links GitHub), kind, model, attempt, cost, tokens, verify, duration, PR link. Repo/limit go to the server; status and kind filters are client-side, since ListRuns has no status parameter
    #/runs/:id GET /runs/:id run header, full metric grid, error block, event timeline from events, and a Load transcript button that lazily fetches GET /runs/:id/log, splits the NDJSON, and renders each record as a collapsible card with a raw-JSON toggle. Lazy on purpose: transcripts run to megabytes
    #/sessions GET /sessions?repo=&issue=&limit= table with copy-to-clipboard on the session ID
    #/config GET /config, GET /models read-only config (webhook redacted) and the plan/implement ladders with cooled-down models struck through

    Styling (app.css): CSS custom properties for a small token set, prefers-color-scheme plus a manual light/dark toggle in localStorage, system font stack, CSS grid, and tables that reflow into stacked cards under 720px. Status colour is never the only signal — every badge carries its text. aria-live="polite" on the status pill and the error banner; visible focus rings; every action is a real <button>.

    Step 5 — config, and docs

    • config.ServerConfig gains UI bool \json:"ui"`; config.Default()sets ittrue. Because Loadunmarshals overDefault(), an existing config.jsonwith nouikey keeps the UI on, and"ui": falseturns it off. Add"ui": truetoconfig.example.jsonandconfig.json`. No validation rule needed.
    • README: add GET /config, GET /models, POST /poll and the /ui mount to the Control API table; a short Web interface section (open http://127.0.0.1:8787/, what each view shows, the loopback/no-auth warning restated, how to disable via server.ui) with its TOC entry; internal/web in the layout table; server.ui in the config reference table.
    • No Makefile change: assets are tracked files, so embed-ready (which exists only because config.json is gitignored) does not need a counterpart.

    Verification

    make lint && make test          # gofmt + vet + go test -race ./...
    make build                      # proves go:embed picked the assets up
    bin/coding-agent-loop --config config.json --once --dry-run   # unchanged path still boots
    bin/coding-agent-loop --config config.json                    # then open http://127.0.0.1:8787/

    New Go tests in internal/server/server_test.go (reusing the existing do helper and testServer fixture):

    • GET /ui/ → 200, Content-Type: text/html, body contains app.js.
    • GET /ui/app.js → 200 with a JavaScript content type; GET /ui/nope.js → 404.
    • GET / → 302 with Location: /ui/.
    • GET /status still returns JSON (static mount did not shadow the API).
    • server.ui: false → /ui/ 404 and /status unaffected.
    • POST /pause with Origin: https://evil.example → 403; with no Origin → 200; with Origin: http://127.0.0.1:8787 → 200.
    • GET /config redacts a set discord.webhook_url.
    • POST /poll → 200 when the fake controller accepts, 409 when it does not.

    Manual pass in a browser: pause → status pill flips to Paused within one poll and /status shows claiming_work: false; resume; open a finished run and load its transcript; cancel an in-flight run; narrow the window to ~380px and confirm every table reflows and stays operable.

    Risks and open points

    • Untestable in CI by inspection only. Go tests can prove the assets are served and the routes are wired, but not that the console looks right. Whoever implements this must actually open it in a browser against a live daemon; the plan above assumes that.
    • No authentication, by design. The origin check stops cross-site POSTs and DNS-rebinding, but anything with local access to port 8787 still has full control. That matches the API's existing posture and the README's "do not expose it"; if a reviewer wants a token, that is a separate, larger decision (it would change the documented curl workflow).
    • Scope calls a reviewer may want to overturn: the New(Options) refactor (mechanical, one production call site), the Poll() addition to the orchestrator (severable), and GET /config returning the whole config with only the webhook redacted (it exposes paths and owner names to anything that can already read /status).

    Reply with exactly implement to approve this plan and start the change. Reply with anything else and the plan will be revised to address it.

    coding-agent-loop run 0d1aa942-0f79-4932-859e-74f4c6027cd3, model claude-opus-5, cost $1.5798

  6. ableinc commented on Aug 25, 2026

    @ableinc
    OwnerAuthor

    implement

  7. ableinc commented on Aug 25, 2026

    @ableinc
    OwnerAuthor

    Opened a draft pull request for this issue: #12

    Tests failed (make test) — see the PR for output.

    Comment implement again if you want another attempt at this issue.

    coding-agent-loop run 771a2bac-6db6-4aeb-b91c-194e00450b78

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions