You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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
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
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
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)
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.
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/sse exists, but a 5s fetch of /status (paused while document.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 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.
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.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{...}).
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 assetsvarFS embed.FS// Assets is FS rooted at the asset directory.varAssets=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.
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) // redacteds.app.Get("/models", s.getModels)
s.app.Post("/poll", s.pollNow)
ifs.cfg.Server.UI {
s.app.Get("/", func(c fiber.Ctx) error { returnc.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():
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 { caseo.poll<-struct{}{}: returntrue; default: returnfalse }
}
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
Activity
ableinc commentedon Aug 25, 2026
The coding agent could not complete this issue (attempt 1).
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-10d42f55788aableinc commentedon Aug 25, 2026
The coding agent could not complete this issue (attempt 2).
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-e8b139b16a10ableinc commentedon Aug 25, 2026
The coding agent could not complete this issue (attempt 3).
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
ableinc commentedon Aug 25, 2026
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)
.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.#/runs/<id>), so deep links never reach the static handler and no SPA-fallback rewrite is needed.fiber/v3/middleware/sseexists, but a 5sfetchof/status(paused whiledocument.hidden) is simpler and has no stream-lifecycle failure modes.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.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/webMirror 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.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: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():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: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:get(path)/post(path, body)wrappers aroundfetch, one error banner on failure.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.hashchange→ one of five renderers.setInterval5s hitting/status, suspended onvisibilitychange, interval selectable (2s/5s/15s/off) and persisted inlocalStorage.Views:
#/dashboardGET /status,GET /healthz/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=ListRunshas no status parameter#/runs/:idGET /runs/:idevents, 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=#/configGET /config,GET /modelsStyling (
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.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.embed-ready(which exists only becauseconfig.jsonis gitignored) does not need a counterpart.Verification
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
curlworkflow).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.5798ableinc commentedon Aug 25, 2026
implement
ableinc commentedon Aug 25, 2026
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