Repository navigation
Web Interface #11
Description
Activity
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-readylabel to stop it retrying.coding-agent-loop run
e3a4a062-a4b8-4fbe-b912-10d42f55788aThe 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-readylabel to stop it retrying.coding-agent-loop run
71a63804-3e3a-4c2d-918f-e8b139b16a10The 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-readylabel to stop it retrying.coding-agent-loop run
9c0dac31-6f5a-4da8-a6d5-1bb62bad9afd15 remaining items
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 overcurl. Issue #11 asks for a browser interface over that same API, "baked directly into the binary". The binary is genuinely standalone:internal/installcopies only the executable to/opt/coding-agent-loop, andembedded.goalready compilesconfig.json/models.jsonin 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)
- No frontend build step. Hand-written HTML/CSS/vanilla ES modules, no framework, no CDN. The Makefile and
.github/workflows/ci.ymlhave no Node toolchain, the API is loopback-only and may be offline, andmake buildmust stay a singlego build. A React/Vite pipeline would mean a committeddist/or a Node step in CI — not worth it for a console of this size. - Hash routing (
#/runs/<id>), so deep links never reach the static handler and no SPA-fallback rewrite is needed. - Polling, not SSE.
fiber/v3/middleware/sseexists, but a 5sfetchof/status(paused whiledocument.hidden) is simpler and has no stream-lifecycle failure modes. - 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 whoseOrigin/Refereris present and non-loopback, and allows requests with noOrigin— so the documentedcurl -XPOST localhost:8787/pausekeeps working unchanged. - Existing JSON shape is not touched.
store.Run/Event/Session/Claimhave no struct tags, so the API emitsID,Repo,CreatedAt, … The UI adapts to that rather than the reverse; adding tags would silently break every existingcurl | jqscript.
Files
New:
internal/web/web.go— packageweb://go:embed assets+ exportedFS embed.FSandAssets fs.FS(fs.Sub(FS, "assets")). The embed lives here because ago:embedpattern 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 containsindex.html,app.css,app.jsand thatindex.htmlreferences the other two (catches a rename that silently ships a broken console).
Modified:
internal/server/server.go—Optionsstruct forNew, static mount, three new routes, origin middleware.internal/server/server_test.go— updatetestServerhelper for the newNew, add UI/origin cases.internal/orchestrator/loop.go—Poll()and a poll channel inRun.cmd/agent.go— one call site:server.New(server.Options{...}).internal/config/config.go,config.json,config.example.json—server.uitoggle.README.md— Control API table rows, aWeb interfacesection + TOC entry,internal/webin the layout table,server.uiin 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/serverwiringRefactor
Newto an options struct. It already takes six positional args and this change addsconfig.Configand*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/serverdoes not importinternal/configtoday butserver_test.goalready does, andconfigimports nothing fromserver, so there is no cycle. Same forinternal/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.Newderives its strip-prefix fromc.Route().Path, so mounting withUse("/ui", …)androot: "assets"resolves/ui/app.css→assets/app.cssinside the embed. The static middleware callsc.Next()for non-GET/HEAD, so it never swallows the POST routes.Handlers:
getConfig— returnss.cfgwithDiscord.WebhookURLblanked and a separate"discord_webhook_set": bool.config.Configis 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)}, wherecooledcomes from the existingstore.CooledDownModels(ctx). Nil-guardRegistryso 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.Useinroutes():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() }
isLoopbackOriginparses the URL and accepts hostlocalhost,127.0.0.1,[::1], or a host matchings.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 orchestratorOrchestrator.Runcurrently only ticks ontime.Ticker. Addpoll chan struct{}(buffered, size 1) created inNew, aselectcase in theRunloop that callso.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() booltoserver.Controllerand to thefakeControllerinserver_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.htmlis a static shell (header, nav, five<section>s);app.jsfills them. Structure ofapp.js:- API client —
get(path)/post(path, body)wrappers aroundfetch, one error banner on failure. - Formatters —
fmtTime,fmtDuration,fmtUSD,fmtTokens,statusBadge,issueURL(repo, issue). Gotcha to encode once: Go zerotime.Timeserializes as"0001-01-01T00:00:00Z";fmtTimemust render that as—. Same for emptyPRURL/Branch/SessionID. - Router —
hashchange→ one of five renderers. - Poller —
setInterval5s hitting/status, suspended onvisibilitychange, interval selectable (2s/5s/15s/off) and persisted inlocalStorage.
Views:
Route Source Contents #/dashboardGET /status,GET /healthzstat tiles (in-flight, active repos, usage %, 24h spend computed client-side from /runs); gate cards withblocked_untilcountdown; claims table; in-flight runs with a Cancel button (POST /runs/{id}/cancel); Pause/Resume and Run discovery now in the header#/runsGET /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 ListRunshas no status parameter#/runs/:idGET /runs/:idrun header, full metric grid, error block, event timeline from events, and a Load transcript button that lazily fetchesGET /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#/sessionsGET /sessions?repo=&issue=&limit=table with copy-to-clipboard on the session ID #/configGET /config,GET /modelsread-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-schemeplus a manual light/dark toggle inlocalStorage, 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.ServerConfiggainsUI bool \json:"ui"`;config.Default()sets ittrue. BecauseLoadunmarshals overDefault(), an existingconfig.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 /polland the/uimount to the Control API table; a short Web interface section (openhttp://127.0.0.1:8787/, what each view shows, the loopback/no-auth warning restated, how to disable viaserver.ui) with its TOC entry;internal/webin the layout table;server.uiin the config reference table. - No Makefile change: assets are tracked files, so
embed-ready(which exists only becauseconfig.jsonis 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 existingdohelper andtestServerfixture):GET /ui/→ 200,Content-Type: text/html, body containsapp.js.GET /ui/app.js→ 200 with a JavaScript content type;GET /ui/nope.js→ 404.GET /→ 302 withLocation: /ui/.GET /statusstill returns JSON (static mount did not shadow the API).server.ui: false→/ui/404 and/statusunaffected.POST /pausewithOrigin: https://evil.example→ 403; with noOrigin→ 200; withOrigin: http://127.0.0.1:8787→ 200.GET /configredacts a setdiscord.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
/statusshowsclaiming_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
curlworkflow). - Scope calls a reviewer may want to overturn: the
New(Options)refactor (mechanical, one production call site), thePoll()addition to the orchestrator (severable), andGET /configreturning the whole config with only the webhook redacted (it exposes paths and owner names to anything that can already read/status).
Reply with exactly
implementto 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, modelclaude-opus-5, cost $1.5798- No frontend build step. Hand-written HTML/CSS/vanilla ES modules, no framework, no CDN. The Makefile and
- addedagent-plannedManaged by coding-agent-loopManaged by coding-agent-loopand removed
on Aug 25, 2026 implement
Opened a draft pull request for this issue: #12
Tests failed (
make test) — see the PR for output.Comment
implementagain if you want another attempt at this issue.coding-agent-loop run
771a2bac-6db6-4aeb-b91c-194e00450b78- added and removedagent-plannedManaged by coding-agent-loopManaged by coding-agent-loop
on Aug 25, 2026
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.