Skip to content

Live run view

You can watch an agent run in real time from a browser. When a run starts, the bot mints an unguessable link (<PUBLIC_BASE_URL>/runs/<id>?t=<token>) and puts it on the in-channel status card; opening it streams the SAME redacted run events the card shows — each tool call and its ✓/✗ result summary — as they happen, over Server-Sent Events. This is the external live UI (0013), built on the run-visibility stream (run-visibility.md).

Live in memory, finished runs from history (locked design). A run's events live in the in-memory RunRegistry while the run is active, plus a bounded per-run backlog so a viewer who opens the link mid-run sees what already happened; a finished run stays in the registry for a short TTL, then is evicted. That in-memory state is intentional under AGENTS.md invariant 6 — a restart ends the runs it was streaming and nothing durable is lost — because the durable record is elsewhere: every finished run is written to the run store (run-history.md) and /runs/:id serves it from there, through the same run page (the shell + seed; see the Rendering paragraph below), for the configured retention window. Every read goes through the one RunsService (registry first, then the store); this handler holds no run state of its own.

Auth = per-run capability token for a LIVE run (locked design). A plain browser navigation can't send an Authorization header, so auth for a live run is an unguessable per-run token carried in the URL, validated (constant-time) by the registry — via RunsService.authorizeLive — for the page, the SSE stream and the stop control. A wrong/missing token on a live run — or an unknown/expired run — is a 404 (existence is never revealed; unknown, expired and wrong-token share one body). The token is the capability: unguessable, scoped to one run, never logged, and never in any finished row, page or persisted record. A finished run (in the registry or the store) is served tokenless to the Access-authenticated viewer whose actor the policy table lets read it (authorization.md items 5–7): src/index.ts resolves the verified Access identity with the same accessActor the /api/* adapter uses and hands it to the handler as ctx.actor; a run the table denies is the same 404 as an unknown id — on the page, its events and friction routes, and the 409 a tokenless stop would otherwise give. The card link outlives the TTL, and the index links finished rows as bare /runs/<id>. The capability-token live page is unchanged: the token IS the capability, and the viewer's actor is not consulted on it.

Transport = SSE (locked design). The flow is strictly one-directional (server → page), EventSource auto-reconnects, and it needs no handshake or extra dependency — so SSE over a WebSocket. The SSE transport itself (src/channels/liveView/sse.ts) survived the Vue port byte-for-byte. Event summaries render as text (Vue text bindings / textContent), never raw markup — v-html appears nowhere in the web app. Events are already redacted + capped upstream (runEvents.ts); this layer adds no data and re-exposes nothing.

Rendering = the web app (revised by the Vue port). Every HTML route (/runs, /runs/scheduled, /runs/:id, the run 404, /residents, /residents/:owner/:name, /costs, /costs/:group) serves ONE shared shell (src/channels/webShell.ts, renderShell): a <div id="app"> mount, a <script type="application/json" id="sb-seed"> JSON island carrying that page's data (seed types in src/channels/webSeed.ts: RunsIndexSeed, ScheduledSeed, RunLiveSeed, RunHistorySeed, RunNotFoundSeed, ResidentsIndexSeed, ResidentDetailSeed, CostsSeed; serializeSeed \uXXXX-escapes every <, >, & and U+2028/29, so hostile text can never close the island), and hashed /assets/* module references served immutable by src/channels/webAssets.ts (loaded in-memory at startup from SWITCHBOARD_WEB_DIST or web/dist under the package root — code only, no data; the Docker image builds web/ and copies web/dist, the npm package's build ships it under dist/assets/web/distpackaging.md item 8). The rendering lives in web/ — its own npm package: Vue 3 + Vite + Nuxt UI v4 on Reka UI + Tailwind v4, tested with vitest + happy-dom + @vue/test-utils — which imports the shared pure modules (runTimeline.ts, markdownLite.ts, indexFormat.ts, localIso.ts, the scheduledPanel.ts model, the webSeed.ts types) straight from src/, so the fold/format logic has exactly ONE implementation. There are NO inline scripts anymore: the old String(fn) inlining and the __name shim are gone — Vite bundles precompiled components, and the seed island is a non-executing data block. The CSP got STRICTER with the port (WEB_HTML_HEADERS): default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' + X-Frame-Options: DENY + no-store — no inline JS executes at all (no unsafe-inline remains for scripts; the style allowance is for Reka UI's floating-element positioning), and img-src 'self' data: covers the dot favicon plus bundled assets. First paint is the client's render of the embedded seed — no second round trip; live pages then attach their SSE feeds. The handlers keep every routing/auth/SSE semantic and only emit seeds; rendering-behavior tests live in web/src/**/*.test.ts, server semantics in src/channels/liveView.test.ts (+ webShell.test.ts, webAssets.test.ts). npx tsx scripts/web-preview.ts previews the built app over fixtures. New with the port: a light/dark/system theme toggle (VueUse color mode; dark is the server default and the original palette), fully responsive/mobile layouts, and semantic status color tokens (--sb-ok/bad/warn/info/skill/review/research in web/src/assets/main.css).

Runs index = Access-gated, NOT token-gated (locked design). GET /runs (no id) is the home page a signed-in team member lands on to see currently-active runs and click into any of them. It has no per-run capability-token gate — Cloudflare Access is the "who" gate in front of it (only the team reaches /runs), and the index itself LISTS the active runs the viewer's actor may readpredicateFor(actor, "runs:read", "run") over the registry's rows and the feed (authorization.md item 6) — and renders each per-run link with its token (/runs/<id>?t=<token>), since an authenticated viewer is trusted with the full link to a run it may read. The per-run pages and SSE streams stay token-gated exactly as before (defense in depth). This means the index intentionally hands run links to anyone who can load it and may read those runs — so it must only ever be exposed behind Access; without Access it would hand every live-run capability link to any visitor. The index's only dynamic payload is the JSON seed island: serializeSeed escapes every angle bracket and ampersand, so a hostile label or id (a run label may derive from a thread/repo name, and thus from user input) is data by construction, and the client builds hrefs with the id/token URL-encoded (runHref in web/src/lib/indexRow.ts). Same strict CSP + frame-ancestors 'none' + X-Frame-Options: DENY + no-store as the per-run page (the shared WEB_HTML_HEADERS).

Runs index is LIVE over SSE (locked design). The index is not a static snapshot: as runs are kicked off — from ANY channel (Slack, HTTP, MCP), which all register in the shared RunRegistry via dispatch() — rows appear, update (activity/finish), and disappear (eviction) on the open index page without a refresh. Transport is SSE, not WebSockets — same rationale as the per-run page (one-directional server→page, EventSource auto-reconnect, no extra dependency). The seed carries the initial snapshot (first paint is the client's render of it — no second round trip), each row keyed data-run-id; the page then opens EventSource("/runs?stream=1") and reconciles: an IndexEvent of { type:"upsert", run } creates or updates the matching row in place (new runs prepend, newest-first), and { type:"removed", id } drops it. ?stream=1 is a query flag, not a new path, so it never collides with /runs/<id> where an id could legitimately be events/stream. The SSE response reuses the same text/event-stream headers as the per-run stream and, like the index page, is Access-gated (no token gate). Feed rows render through the SAME RunRow component as seeded rows, and labels/ids render as text (no v-html anywhere), so a hostile label/id is inert data on the live path too.

  • Code: src/core/runRegistry.ts (RunRegistry: create(label?)/publish/finish/subscribe/has/listActive/subscribeIndex/requestStop, the IndexEvent model, per-run label + startedAt + eventCount + stop, bounded backlog, TTL eviction, constant-time token gate, defaultRunRegistry singleton); src/core/runRegistry/ (the registry's parts as sibling modules: runControl.tsRunControl, the per-run soft/hard stop + hard AbortSignal; activity.tsactivityOf/activityOfEvents, the one-line activity rule the summary and the record share; state.tsRunState, the row a live run occupies, RunMeta, and the per-run subscriber contract (RunSubscriber, FinishedFrame, SealedFrame, Unsubscribe); projections.ts — the read shapes (RunSummary, RunSnapshot, SealResult, RunStopStatus) and the one projection each (summaryOf, snapshotOf, sealResultOf, sealedFrameOf); backlog.ts — the bounded per-run backlog with its protected head (appendToBacklog, DEFAULT_BACKLOG_LIMIT/DEFAULT_BACKLOG_BYTES, HEAD_BUDGET_BYTES) and the budgeted replay window a late subscriber gets (replayWindow, DEFAULT_REPLAY_LIMIT/DEFAULT_REPLAY_BYTES); indexFeed.ts — the IndexEvent model, IndexSubscriber, and IndexFeed, the runs-index feed's subscribers, replay-on-subscribe and isolated fan-out; testing.ts — the shared test fixtures); src/channels/liveView.ts (the router + handler: parseRunRoute incl. the index, scheduled and stop routes, parseStopMode, createLiveViewHandler — takes the bound shell and emits seeds; re-exports the modules below so it stays the one import path), src/channels/scheduledPanel.ts (the Scheduled tab's model, item 14), src/channels/webShell.ts (renderShell/makeShellRenderer, WEB_HTML_HEADERS), src/channels/webSeed.ts (the seed types, serializeSeed, SEED_ELEMENT_ID, retentionSentence), src/channels/webAssets.ts (loadWebAssets — the manifest read + immutable /assets/* server), src/channels/liveView/sse.ts (SseSink, serveEvents with the REPLAY_LIMIT ring, serveIndexEvents, serveHistoryEvents + withOmittedMarkers, the node sink and keepalive heartbeat — byte-for-byte unchanged by the Vue port), src/channels/liveView/html.ts (escapeHtml), src/channels/favicon.ts (the dot favicon SVG/data URIs); the shared pure models the web bundle imports: src/channels/runTimeline.ts, src/channels/markdownLite.ts, src/channels/indexFormat.ts (formatElapsed, formatRelative, formatDateTime, splitRunLabel), src/channels/localIso.ts; the rendering in web/: web/src/pages/{RunsIndexPage,RunPage,ScheduledPage,NotFoundPage}.vue (+ RunRoutePage.vue, which dispatches /runs/:id between RunPage and NotFoundPage by the seed's page), web/src/components/{AppShell,AppNav,ThemeToggle,StatusDot,MarkdownText,SlackMark,GithubMark}.vue (below sm the site nav + theme toggle collapse into AppShell's hamburger menu; navSections in AppNav.vue is the one list of sections and the one rule for which exist), web/src/components/runs/{RunRow,RunsTabs,SourceMark}.vue (runsTabs in RunsTabs.vue, the same rule for the tabs), web/src/lib/capabilities.ts (useCapabilities — the seed's capabilities, what the nav, the tabs and the docs link paint from), web/src/components/run/{StepBlock,StepItems,CallCard}.vue, web/src/lib/indexRow.ts (the row model: statusLabel/statusDot/agentHue/runHref/stopHref/dotTip/whenTip/sourceTip/feedAction/mergeRow — the port of the old isomorphic indexRowRenderer), web/src/lib/timelineVm.ts (buildTimeline, the timeline's pure view-model, item 25), web/src/components/run/TimelineSection.vue, src/core/runOwner.ts, web/src/lib/runPageModel.ts (createRunPageModel — the ONE fold for seeded history AND live SSE frames), web/src/lib/durationTone.ts (durationTone/heatStyle/isTimedOutExit — the duration heat scale, item 24), web/src/lib/{seed,eventSource,browser}.ts; src/core/dispatch/reply.ts (composeRunLabel — the pure, exported, channel-agnostic run-label composer; humanizeMessageText, attachmentSuffix, liveViewLink); src/core/dispatch/provision.ts (registerRun: registers the run with that human label before the attach, adds the link to the status card when PUBLIC_BASE_URL is set); src/core/dispatcher.ts (publishes events in onEvent, finishes in the run-loop finally); src/core/types.ts (optional channelName/userName display hints on IncomingMessage); src/channels/slack/lookups.ts (resolveChannelName/resolveUserName — best-effort, cached name lookups that populate those hints); src/index.ts (loads the web assets, serves /assets/*, routes GET /runs + /runs?stream=1 + /runs/:id + /runs/:id/events, sharing defaultRunRegistry with the dispatcher — the /runs* Access gate already covers /runs?stream=1); scripts/web-preview.ts (fixture preview server).
  • Tests: server side (routing, token/Access gates, seed contents incl. the token rules and the 404 seed, SSE transport, paging, audit): src/core/runRegistry.test.ts, src/core/runRegistry/runControl.test.ts, src/core/runRegistry/activity.test.ts, src/core/runRegistry/projections.test.ts, src/core/runRegistry/backlog.test.ts, src/core/runRegistry/indexFeed.test.ts, src/channels/liveView.test.ts, src/channels/webShell.test.ts, src/channels/webAssets.test.ts, src/channels/indexFormat.test.ts, src/core/dispatcher.test.ts (live run-view wiring (Area 2)), src/core/dispatch/reply.test.ts (composeRunLabel, attachmentSuffix), src/channels/slack/lookups.test.ts (resolveChannelName / resolveUserName); rendering behavior (web/src/**/*.test.ts, run with npm test in web/): web/src/lib/runPageModel.test.ts, web/src/lib/timelineVm.test.ts (fold/group/fold rules), web/src/lib/durationTone.test.ts (the heat scale), web/src/lib/indexRow.test.ts (row vocabulary + feed rules), web/src/components/runs/RunRow.test.ts, web/src/pages/runsIndex.test.ts (live feed, divider, title/favicon, reconnect reload), web/src/pages/runPage.test.ts (history + live + stop + dedupe), web/src/pages/{scheduled,notFound}.test.ts, web/src/components/AppNav.test.ts (the nav and the shell under each capability shape), web/src/components/runs/RunsTabs.test.ts, web/src/stack.test.ts.
  • Docs: AGENTS.md invariants 1, 2, 6, run-visibility.md. The whole /runs* surface sits behind the dashboard auth strategy — Cloudflare Access (SSO) re-verified fail-closed in our own code, a bearer token, or loopback-only none — see access-gate.md; that identity gate runs before the per-run capability-token check here (defense in depth). "Access-gated" below means "behind that gate", whichever strategy an installation runs.

Behavior

  1. Per-run capability. RunRegistry.create() mints a random run id and a random view token (default: randomUUID + 32 random bytes as hex). The dispatcher creates one run per agent run — at its ledger reservation, before the workspace attach (run-history item 42) — and finishes it when the run ends; a dispatch that ends before its run loop discards it (discard: no finished frame, the index feed's removed).

  2. Live stream + one bounded backlog. publish(id, event) stamps the event with a monotonic per-run seq, appends it to the run's backlog and fans it out to live subscribers — each subscriber isolated in try/catch (like index sinks), so a dead SSE sink can neither stop the others nor throw into the runner. The registry backlog is the only per-run event store (the dispatcher has no ring of its own — its friction diagnosis reads registry.snapshot), bounded by count (8000) and bytes (4 MiB, measured as each event's UTF-8 JSON): past either, the oldest events AFTER the protected head drop until under both — the newest always survives. The protected head (tracing.md): the events that say what a run is — input, context, run_meta, the root's start, the slack.receive and dispatch.* span pairs, and the mcp_unavailable/spans_dropped notes — published while the backlog holds nothing else are never trimmed, up to HEAD_BUDGET_BYTES (512 KiB), and are capped to MAX_EVENT_BYTES at publish so one giant context cannot spend the budget; head material after any other event, or past the budget, is ordinary. stepCount counts the content events (span records excluded) beside eventCount, on the summary, the snapshot and the record. The seq is the event's 1-based position in the run's stream (the registry's monotonic eventCount at publish — ONE counter, carried both on the event object and as the second argument to every subscriber); it is the SSE id: a client resumes from. A subscriber that arrives mid-run replays the backlog in order, then live-forwards new events; subscribe(…, afterSeq) replays only events with a higher seq. A fresh subscribe (afterSeq 0) replays the protected head first and then the newest events within the replay budget (item 5), eliding the range between; a resume re-sends nothing from the head.

  3. Constant-time token gate, 404 on failure. subscribe/has compare the presented token against the run's token with crypto.timingSafeEqual over equal-length buffers (length guarded first). A wrong/missing token or unknown run yields null/false → the handlers answer 404 for both the page and the stream. The token is never logged.

  4. Finish, seal, TTL eviction. A run ends in two steps (tracing.md). finish(id, status) — the agent stopped — stamps finishedAt, sends every attached subscriber the named SSE finished frame ({ finishedAt }; the page freezes its header duration at that stamp) WITHOUT detaching it, upserts the index, and stops content events; span records still publish until the seal (counted and forwarded, never an index repaint). seal(id, { replyOk }) — the stream closed: the first reply attempt completed (replyOk true or false) or the run's branch was abandoned without one (absent) — stamps sealedAt, detaches every subscriber with the terminal end frame ({ sealedAt, replyOk? } → the page closes its EventSource), upserts the index once, and returns a SealResult (the events published since finish, eventCount, the two stamps); it is idempotent and re-readable (a second seal returns the same result and changes nothing), a no-op on a live run (no stamps) and the empty result for an unknown run. sealAllFinished() seals every finished-unsealed run (the drain). The subscriber set is copied and cleared before any callback fires, so a re-entrant seal is a no-op, and a throwing subscriber is isolated. The dispatcher seals a run once its first reply attempt has completed — replyOk true or false — through one RunEnding per dispatch (src/core/runEnding.ts): the card close, then the reply, then a drain that seals every finished run of the dispatch and writes every registered record after the seal. A command run that fell through to the agent is sealed with no reply attempted; a fenced run (another generation's) by the outer finally's backstop; the shutdown drain seals whatever finished runs remain before exit, and one event-loop turn hands their end frames to the sockets. So a run has two index events at its end — finish's, then the seal's carrying sealedAt/replyOk — and the wire reads finished when the agent stops and end after the reply. A viewer who opens the link after finish but within the TTL sees the full backlog, finished, then end; one who opens it between finish and seal stays attached until the seal. The eviction TTL (default 60 s) runs from sealedAt; a finished run left unsealed for UNSEALED_HOLD_MS (15 min — a reply that never settled) is sealed by the sweep with no replyOk, then evicted. After the TTL the run is evicted (subscribe → 404). Unfinished runs are never evicted by age. Eviction is lazy (swept on registry activity) — no background timer keeps the process alive.

  5. SSE handler. GET /runs/:id/events?t=… sets content-type: text/event-stream (+ no-cache, x-accel-buffering: no), writes the 200 head, then flushes the replayed backlog and live events as id: <seq>\ndata: <json> frames; the terminal end event closes the stream. A client disconnect unsubscribes. The backlog replay (synchronous during subscribe) is buffered until after the 200 head so no frame is written before the status line. The replay budget lives in the registry (RunRegistry.subscribe(id, token, { onEvent, onFinish, afterSeq, limit, byteLimit })): of the retained events after the cursor, the NEWEST are replayed — at most 2000 (DEFAULT_REPLAY_LIMIT) and at most 1 MiB of UTF-8 JSON (DEFAULT_REPLAY_BYTES), the newest event always — and the result reports replayed and, when the budget left retained events out, elided: { fromSeq, toSeq }, the contiguous seq range skipped. The handler writes that range as one leading named frame, event: replay_elided / data: {"fromSeq","toSeq"}: a transport frame, never a run event, never in the registry or the record, and with no id: so it never moves a resuming client's cursor. It is distinct from replay_note, which marks records MISSING from a stored stream (withOmittedMarkers, item 12): elided events still exist in the registry and on the record, so the page shows the range as a replay row reading N events not loaded (a–b) — the record has them and keeps it on state.elided for the partition's not loaded term (docs/reference/specs/tracing.md); a malformed frame marks nothing. Events the backlog itself dropped are not elided — they are a seq gap. A subscriber that must see every retained event (the run ledger, a store) passes REPLAY_EVERYTHING. Live frames after the replay are never capped; snapshot still returns the whole backlog; the index feed is unchanged. Resumable: the browser's EventSource reconnects after a drop (proxy kill, deploy, laptop sleep — retry: 3000) sending the last id as Last-Event-ID; the handler parses it (parseLastEventId — a positive integer, else 0) and the registry replays only the events after it, so a reconnect never re-appends the whole backlog to the page as duplicates. The budget and the cursor compose: the newest 2000 of the events after the cursor are replayed, and the elided range, if any, starts after the cursor. The page also drops any run-event frame whose id is at or before the last one it applied, for a proxy that strips the header (transport frames carry no id and are exempt). Each event is JSON-serialized once per process however many viewers a run has (serializedOnce, a WeakMap over the event object the registry shares with every subscriber); index frames use the same memo and carry no id (the index has no resume semantics).

  6. The live run page. GET /runs/:id?t=… serves the shared shell (strict CSP, no-store, noindex — the Rendering paragraph) with a RunLiveSeed{ mode:"live", eventsUrl, stopUrl }, the capability token riding only inside those two URLs; the page (web/src/pages/RunPage.vue) opens the token-scoped EventSource and renders tool_call → a running row and tool_result → ✓/✗ + summary, as text only (no raw markup anywhere). Both routes are GET-only (405 otherwise). The page also sends frame-ancestors 'none' (CSP) + X-Frame-Options: DENY — this public surface cannot be iframed (clickjacking), which default-src 'none' does not cover. The header carries a "← All runs" back link to the Access-gated index (/runs, no token — the index is Access-gated, not token-gated) and a connection status mark + label (green = live, amber = connecting, red = disconnected, grey = finished) driven by the stream's onopen/onerror/end state.

  7. Graceful degradation. The dispatcher reads PUBLIC_BASE_URL from the env. Set → the live link (<base>/runs/<id>?t=<token>, trailing slash trimmed) is added to the status card while the run is live. Unset/blank → no link is added; the run, the card, and every other surface work unchanged. The registry always runs (so a run is always streamable if someone has the link) — only the surfaced link depends on the base URL.

  8. Runs index (Access-gated home page), live over SSE. GET /runs (and /runs/) serves the shared shell whose RunsIndexSeed lists every non-evicted run the viewer's actor may read (its runs:read predicate — authorization.md item 6), newest-first (rendered by web/src/pages/RunsIndexPage.vue). Each row is a single full-row link to its per-run page carrying that run's token (/runs/<id>?t=<token>) — the whole <li> content is wrapped in one <a class="row"> with a clear :hover background so the entire row reads as clickable, not just the label. A row leads with a status dot (green = live, grey = finished; role="img" + aria-label/title so it is not color-only), then the run's label (or a short id fallback), then the event count. No active runs → an empty-state message. The header carries the same connection status mark + label as the per-run page. The initial snapshot rides the seed (first paint is the client's render of it — no second round trip); the page then opens EventSource("/runs?stream=1") and stays live — rows appear/update/finish/disappear without a refresh, and feed-added rows render through the identical RunRow component as seeded ones (the full-row + status-dot shape is one implementation, never raw markup). The feed is the same predicate's view (visibleIndexFeed): an upsert for a run outside it is dropped, and so is that run's later removed, so the page never learns a hidden run's id. RunRegistry.listActive() returns the snapshot ({ id, token, label?, finished, startedAt, eventCount } per run) that backs the first paint; startedAt is the clock time at create() and eventCount is the monotonic total published (not the bounded-backlog length). The index (page AND feed) is GET-only (405 otherwise), carries the same CSP + clickjacking + no-store headers as the per-run page, and — unlike the per-run routes — has no token gate: it is fronted by Cloudflare Access, bound to that identity's actor, and must only be exposed behind Access (see the locked-design note above). The dispatcher labels each run at create() via composeRunLabel — human-first: agent-led, then owner/repo for repo runs or #<channel> · <user> for chat runs (display names when the adapter resolved them, else the prefix-stripped ids), then a short quoted snippet of the request; capped to ~120 chars. The label is additive — inert everywhere except the index.

  9. Index event model + live feed. RunRegistry.subscribeIndex(onEvent) backs the live index. On subscribe it replays the current active set as { type:"upsert", run } events in listActive() order (newest-first) — mirroring the per-run backlog replay — then registers the callback and returns an idempotent unsubscribe. Thereafter every lifecycle transition emits an IndexEvent: create(), each content publish() (live event count / running state — a span record's publish emits none), finish() (an upsert with finished:true) and seal() (an upsert carrying sealedAt/replyOk) emit { type:"upsert", run }; a TTL eviction during sweep() emits { type:"removed", id } — the only removal signal (a finished-but-unevicted run stays listed as finished, and eviction stays lazy/timer-free). Because all channels (Slack, HTTP, MCP) register their runs in the one shared defaultRunRegistry via dispatch(), the feed reflects runs from every channel with no dispatcher hook. Notification is defensive: an index subscriber that throws is isolated and never corrupts registry state or the create/publish/finish/sweep caller. serveIndexEvents(subscribeIndex, sink) mirrors the per-run serveEvents — it buffers the synchronous replay, writes the 200 text/event-stream head, flushes the buffered frames, then live-forwards, and unsubscribes on client close — but always 200s (no token gate) and has no terminal end frame (the whole-registry feed stays open; finish is an upsert, eviction a removal). Each frame is data: ${JSON.stringify(indexEvent)}\n\n.

  10. Run control from /runsPOST /runs/:id/stop?t=…&mode=soft|hard. The ONE write route on this surface. It sits behind both gates: Cloudflare Access at the edge (index.ts gates every method under /runs*, fail-closed) and the run's capability token here — a wrong/missing token or unknown run is a 404 (existence never revealed), a malformed/missing mode is a 400 (checked before any registry lookup), an already-finished run is a 409, success is 200 JSON { id, mode, state: "stopping" } with no-store. POST-only (GET → 405 allow: POST); every other run route stays GET-only. The handler calls RunRegistry.requestStop(id, token, mode), which drives the run's RunControl (see run-loop.md item 8: soft = wrap up via the finale, hard = abort now), publishes a typed stop_requested run_note (with mode) to the run's stream so every open viewer sees the request, and upserts the index. RunSummary.stop = { mode, state }stopping from the request until the run finishes, then stopped; absent when no stop was ever requested (additive). A soft request escalates to hard; hard never de-escalates. UI: each live index row carries Stop (soft) and Kill (hard) buttons as a sibling <span class="actions"> of the full-row anchor (a button may not nest in an anchor); they disappear once the run is finished or already asked to stop, and a stopping (mode) / stopped (mode) badge appears inside the anchor. The per-run page has the same two buttons in its header; on stop_requested/stopped notes the connection label reads stopping (mode), and the end frame then reads stopped (mode) instead of finished. Kill asks for a confirm() first (it is destructive: no summary, sandbox torn down). Both pages POST via fetch (CSP connect-src 'self' already allows it), keep the id/token URL-encoded, and render rows/labels as text through the components.

  11. The run record is the source of truth; Slack and GitHub are projections of it. When the run loop returns, the dispatcher publishes the final answer into the run's stream as an answer event ({ type:"answer", text } — redacted with the same redactSecrets as every event, but not capped: the summaries are the digest, this is the record) before finish() and before the channel reply or the PR post (a publish on a finished run is a silent no-op, so the order is load-bearing and pinned by a test). The run page renders it as a dedicated Reply block under the log (through the safe markdown renderer, item 12), so a soft stop's ⏹ … findings so far write-up — or any run's answer — is readable on /runs without opening Slack. A run that threw publishes no answer (the card shows ❌). The run page can be seeded with a run's events for a page that has no stream to replay from (the history page, run-history.md): the record's events ride a \u003c-escaped RunHistorySeed JSON island (serializeSeed) and is fed through the SAME fold (createRunPageModel, web/src/lib/runPageModel.ts) the EventSource frames go through, so a seeded page and a live page render identically by construction and event text can never close the island. The live page seeds no events (RunLiveSeed), because its SSE replay paints the backlog. The index's empty-state sentinel shows only while no rows exist, per view.

  12. The run page is a timeline of the whole run, not a tool log. Two narrative events join the stream (run-visibility.md item 1): input ({ type:"input", text } — the request as received: the directive-stripped text, humanized by humanizeMessageText (Slack <url|label> → the url or label (url), <@U…>/<#C…|name>@user/#name, &amp; &lt; &gt; unescaped once — channel-authored mrkdwn, so the Request block never shows raw <https://…|…>), plus a one-line attachment note like [+2 images, 1 document], redacted, uncapped) is published by the dispatcher directly to the registry right after create() — at the ledger reservation, before the workspace attach (run-history item 42) — so it is the first content event of the record (the setup spans streamed ahead of it are span records, and head material) and never goes through onEvent (no card refresh, no friction input); assistant ({ type:"assistant", text } — the model's prose that rode alongside tool_use in one completion, redacted, uncapped) is emitted by the runner before that turn's tool_call rows, and never for the final text-only completion (that is the answer, published by the dispatcher — nothing appears twice). The status card shows an assistant turn as a one-line 💬 <first 80 chars>… activity trace (one line, replaced by the next event — the card is a digest, the page is the record). A third narrative event, context ({ type:"context", text } — one thread turn the model was given, user:/assistant:-prefixed, humanized and redacted like input, bounded per run-visibility.md item 6), is published by the dispatcher right after input. The page renders input as a headed Request block above the log, context turns inside a collapsed Earlier in this thread <details> block between the Request block and the This run heading (each turn [ts] + markdown through the same guard as the request — secondary by design, one click away), assistant as the narration that opens a step in the log (item 13), and answer as the headed Reply block below (the product's word — a reply is not always an answer: a review's is the verdict, a coding run's the PR); every row and both blocks carry the event's at at their right edge (omitted when at is absent). Call cards stay monospace; the markdown surfaces (Request, Earlier in this thread, narration, Reply) use a proportional face. The Request folds to its first five lines under a fade with Show more (web/src/components/ExpandableText.vue, generic: lines prop, slot content, the fade's colour from --expandable-surface). The Reply's heading carries a muted caption saying what the reply IS, from the run's facts and never its text (replyCaption): verdict for owner/repo#N (a review with a resolved PR, linked), verdict, Slack only (the request opted out of the GitHub post — reviewPostOptedOut, the dispatcher's own parser), verdict (a review with no PR), pull request opened owner/repo#N / pull request updated … (a coding run's pr_opened, created deciding the verb), output (a command run), else answer; the record carries no fact about whether a verdict reached GitHub (the post-step runs after the seal), so the caption says what the verdict is for, not where it landed. The Waiting for activity… placeholder disappears on the first painted change of any kind — input and answer clear it too, so a no-tool run (request → answer, no cards) never keeps it. Markdown, safely (safety contract): Request, Reply and assistant rows are rendered by src/channels/markdownLite.ts — ONE self-contained renderMarkdownInto(root, text) supporting paragraphs, ####### headings (h1–h6; seven+ markers degrade to text), **bold**, _italic_/*italic*, `code`, fenced code blocks, -/*/1. lists (one nested level), > quotes, [text](http(s)://…) links, and a GFM table subset (header row + |---| separator (alignment colons accepted) + body rows → table/thead/tbody/tr/th/td via createElement, cells through the inline renderer; a pipe row without the separator stays text; body rows are squared to the header width). It builds DOM only via createElement/createTextNode/textContent/setAttribute: angle brackets are text (no HTML passthrough — <img onerror> is literal), only http:///https:// hrefs become anchors (with rel="noopener noreferrer"; javascript:, data:, protocol-relative and every other scheme stay plain text), a </script> inside a code block is text, and unknown/unbalanced syntax degrades to plain text. It ships into the page through the web bundle: web/src/components/MarkdownText.vue imports the ONE renderMarkdownInto from src/channels/markdownLite.ts (no second copy; the module stays a self-contained function with no imports or module-scope closures — a property its own test still pins) and renders every markdown surface through the same try/catch guard, falling back to textContent. The old String(fn) inlining and its __name shim are gone with the inline <script> itself. The CSP is the strict shell policy (script-src 'self' — no inline JS executes, frame-ancestors 'none'); nothing on the page assigns raw markup and v-html appears nowhere. Robustness contract: the block parser always consumes at least one line per iteration (a bare # heading marker or any other block-looking line that matches no block degrades to text instead of looping forever), quote nesting is capped at 8 levels (deeper > runs become text — no stack overflow), and every markdown surface on the page renders through a try/catch guard that falls back to textContent — a renderer bug can cost the formatting of one event, never the event or the stream. runFriction.ts ignores input/context/assistant/answer (narrative, not friction or steps — they count toward neither runMs nor eventCount); parseRunEventLines (the input of friction analyze) accepts them in captures. Span rows (tracing.md): a span_start/span_end pair whose name is a dispatch.*, run.*, post.* or ship.round span renders through the same fold as ONE row — its display name (displayNameOf, never the raw name), ◌ … while open, ◷ <duration> once ended ( on error), the start's stamp — while a tool.* span decorates its call card and a model.turn span its step (item 13); an mcp.* span draws nothing of its own.

  13. Call cards, steps and a live tail — the page is grouped the way a person reads a run. The flat stream is folded client-side by src/channels/runTimeline.ts — ONE self-contained createRunTimeline() (no imports, no module-scope closures — still pinned by its own test), imported by the web bundle and driven by createRunPageModel (web/src/lib/runPageModel.ts) — into steps and call cards, deterministically (no heuristics): an assistant event opens a new step with that prose as its narration; a tool_call joins the current step (calls before any prose form one un-narrated leading step); a tool_result attaches to its call by callId (the provider's tool_use id, on both events since this item), and a result whose call was trimmed from the backlog — or that carries no id — becomes a finished call of its own — nothing is dropped. A call card is a native <details>: the <summary> header is [HH:MM:SS] · status glyph (CSS spinner while running; ✓ / ✗ / ⚠ for ok / failed / infra) · a dim $ for shell calls or a tool chip otherwise · the command · right-aligned facts (exit N — red when nonzero — or error / sandbox error, N lines from the summary's size note (the FULL output's count, since the event's output is capped), and the duration from atat, e.g. 1.2s, 2m 35s) · a chevron. Collapsed, the header shows the one-line headline: the command's first line with its leading cd … &&/; hops removed (they say where, not what) and a trailing when more follows; open, it shows the full command (pre-wrap, hanging indent — continuation lines align under the command's first character, not the timestamp) and the body: the result's redacted output in a scrollable <pre> (the summary if no output rides on the event; no output; no output yet while it runs — the spinner is the card's one live mark). Status is truthful: ok is now "the tool succeeded" — a bash command that exited nonzero renders ✗ with its exit code (it rendered ✓ before this item). Open by default = classification: every call carries tags (shell: the first matching of tests, build, install, git, network, read, else shell, from the command text after the cd hops; other tools: the tool name; plus failed / infra after the result) and the page opens a card when any tag is in OPEN_BY_DEFAULT["failed", "infra"] — overridable per view with ?open=tests,build (or all); an Expand all / Collapse all header toggle flips every card and applies to cards added later. update_status renders as one muted line, not a card. Layout: one left edge — timestamps line up down the page for prose and cards alike; steps are separated by space, not rails or borders; the body has room at the bottom so the tail never sits on the viewport edge. What is happening now (no separate tail row): in-progress work draws where it will end up — a running card ticks its elapsed in its facts slot, and a silent model is a pending-turn row at the foot of the log — ∿ · <model name> · <rotating verb>… ……… <elapsed> in a dashed card outline (item 18 has the clock); both are gone on end / disconnect. Request block source: the input event carries source ({ url?, channel?, user? } — the Slack permalink slackPermalink(teamUrl, channel, ts, threadTs) built from the cached auth.test URL, plus the display names the adapter already resolves; IncomingMessage.sourceUrl), rendered as #channel · user · open thread ↗ — only http(s) URLs become anchors (rel="noopener noreferrer"). Autoscroll follows the stream only when the viewer was already at the tail (sampled once per event, before anything is added). Everything still renders as text through the components (web/src/components/run/{StepBlock,StepItems,CallCard}.vue — no v-html, no raw-markup assignment); the CSP is the strict shell policy. Data changes behind this: tool_result.output (control-stripped → redacted → capped at TOOL_OUTPUT_CAP = 8000 chars with a …[N more chars] note; the registry's byte-bounded backlog, item 2, trims the oldest events before an output-heavy run can grow without bound), tool_result.exitCode (bash only, parsed by parseExitPrefix from the executors' shared exit N: first line — see run-visibility.md item 1), callId on both tool events, and describeToolCall naming non-bash targets (use_skill code-review-and-quality, web_fetch <url>). The status card and the friction analyzer keep reading summary. One fold for every frame: the seeded history of run-history.md and the live EventSource frames both go through the same createRunPageModeltimeline.push(e) → apply-change path, so a seeded page and a live page render identically by construction; context frames become the Earlier in this thread block (a context change) and the transport's replay_note a muted notice (a replay_note change) through that same fold — createRunTimeline knows both kinds, and an unknown frame is ignored, never thrown on. Span cases (tracing.md): a model.turn span_end draws the turn row from its attrs (model, inputTokens/outputTokens/cacheReadTokens) and is the step boundary; the history seed of a span-schema record is normalizeSpans-ed before withOmittedMarkers (whose count and cursor skip the synthesized, seq-less spans), a record below SPAN_SCHEMA arrives as stored, untimed, and any event kind the fold does not know draws nothing; a tool.* span_end whose callId attr names a call the result could not time (no at) gives that card its duration (the twin rule) and never opens a row; every other streamed span is a span change (open at the start, closed by the end). run_meta without a model (a command run) is a meta change with none.

  14. "Scheduled" tab — the jobs that run without a human, visible without wrangler access. GET /runs/scheduled (its own tab on the runs page since item 18 — the run list stays a run list; same Access gate, GET-only, no token, no SSE: a snapshot per load; scheduled is a reserved path word, never a run id — ids are UUIDs). The same view is the registry command schedule list on every surface (src/core/commands/schedule.ts; command-registry.md item 20: cron, worker, command/identity, next firing UTC, newest firing per schedule, firingsUnavailable when no store is configured; internal entries are hidden there too). The tab's seed carries one row per non-internal entry of the schedule registry (src/core/schedules.ts — the single catalog of every cron any of our Workers runs: each entry names its worker (bot = deploy/cloudflare, resident = deploy/cloudflare-resident) and its action (run | healthz | watchdog); a unit test fails when a Worker's wrangler.jsonc triggers.crons differs from that worker's entries, and each Worker's scheduled() looks its firing up in the registry, so a cron cannot exist in one place only). internal: true marks plumbing nobody operates — the per-minute container keep-alive (healthz) — which is neither listed nor recorded. Each row: name (description on hover), cron expression (UTC), worker, what it runs — <command> as <identity> for a run schedule, "resident watchdog — not a run" for the resident's sweep (its firing record is the pass summary: <n>/<cap> residents · <n> re-armed · <n> timed out · <n> errors, failed when any resident errored) — next fire computed from the expression (nextFire: five-field Vixie cron incl. lists/ranges/steps and the dom/dow OR rule, UTC), and the last firing: fired-at, outcome (completed | failed | stopped (soft|hard) | no run created | ingress error | misconfigured — nothing ran), a link to the run (/runs/<id>?t=… while the run is live in the registry AND the viewer's actor may read it — the panel reads the same predicate-filtered live rows as the index, so the capability link is as safe here as on the run rows — else a bare /runs/<id>, which the bound run page resolves), and the reply's first line. Firings come from the ScheduleStore seam (src/core/scheduleStore.ts): the production WorkerScheduleStore reads the state Worker's single ScheduleDO (POST /schedules/latest; the shim writes POST /schedules/record after every firing — including firings that produced no run — bounded at 100 per schedule, invariant 6), InMemoryScheduleStore for tests. Config schedules.worker selects the store; without it, or when the store fails, the panel still lists the schedules and says why the history is unavailable — it never shows "never fired" for a store it could not read. Pure model in src/channels/scheduledPanel.ts (buildScheduledRows + the outcome/action label maps; its HTML renderer was deleted with the Vue port — the run id is URL-encoded into the row's href); the page renders in web/src/pages/ScheduledPage.vue from the ScheduledSeed, hostile strings as text.

  15. Model turns are visible — "Thought for 5m 04s" above what the thinking produced. Between a tool_result and the model's next output the page could only say thinking…; a five-minute model turn and a dead stream looked identical, and nothing on the record said how long the model took or what it cost. Every provider call the runner makes (loop steps, the final answer, the finale) is one model.turn span (tracing.md item 17) under the run's run.agent: its span_end lands the moment the provider returns and before the assistant/tool_call events that completion produced (a call that throws — hard stop, finale deadline, provider error — ends the span error and is not a turn; a refusal is a turn with stopReason: other), startedAt and durationMs from the runner clock (at - startedAt === durationMs exactly), and attrs stopReason (end_turn/tool_use/max_tokens/other), the usage when the provider reported it (inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens? — Anthropic message.usage via usageFromAnthropic, OpenAI-compatible usage incl. prompt_tokens_details.cached_tokens via usageFromOpenAI; malformed/absent usage never fails the completion), ttftMs when the provider streams (the Anthropic adapter's CompletionRequest.observer.onFirstToken), and when it streams block boundaries (observer.onBlockStart/onBlockEnd, the raw content_block_start/stop events by kind and index) blocks, thinkingMs (thinking and redacted-thinking blocks) and textMs — the turn's thinking and writing time, a block still open at the return ending there — which the turn row prints as first token 800ms · thinking 3.2s · writing 1.1s, and model — the <provider>/<model> that took the turn, the SAME ref run_meta carries (the runner composes it as provider.name/opts.model; the registry keys providers by the ref's prefix, opts.model is the bare id the API takes). A run is pinned to one model, but a ship run's children answer on their own models on the one stream, so the page badges a silent model by name and makes a switch stand out per step; the page's sameModel treats a bare id and a ref with that name as one model (two refs with different providers are never the same), which is also how records stamped with a bare id still read correctly. There is no turn event kind: a record written before spans carries its turns as objects no reader knows, and its page states no timing data (item 25). The span's shape follows the OpenTelemetry GenAI chat span (duration, stop reason, input/output tokens) so it can be exported without translation. Rendering follows the convention every comparable product (Claude Code, ChatGPT/Codex, Cursor) already uses — no boundary markers; the finished turn collapses to a one-line header over its output: the timeline folds the span end into { kind:"turn", label:"Thought for 5m 04s", facts:["12.3k in","800 out","11.2k cached"], durationMs, model?, at } (tokens compacted 800 / 12.3k / 1.3M; cached only when present; a span end without a numeric durationMs is ignored and the span's start draws nothing) and the page renders it as a muted [HH:MM:SS] 💭 Thought for … · facts row (text-only rendering, CSP unchanged). A turn is a step boundary: it clears the current step, so a tool-only completion opens a new un-narrated step and the row always sits above its output, never inside the previous step. The status card shows 💭 thought for 5.1s as its activity line — the runner's onProgress note at each turn's end, which the dispatcher paints like every trace (replaced by the next event). runFriction.ts takes model time from the model.turn spans on a stream that carries them (summed; slow_model_turn at the span's index) and a stream without model.turn spans has no model time (run-friction.md). Live elapsed time on the thinking… tail is a separate change.

  16. History mode. GET /runs/:id with no token (or a token the registry refused) asks RunsService.getRun(id, { include: "messages" }): a live unfinished run of THIS process → 404; a run live under another generation — the ledger's row, ownerGen set (run-history item 41) — is admitted on the attribute decision alone (its token is the other generation's) and renders in history mode with the ledger's events, no stream and no stop controls, its events route a replay that ends, its friction the live diagnosis, its tokenless stop through the ledger — the tokenless stop being a write, gated on runs:write like runs.stop on the command surface (authorization.md item 5), while the capability-token stop stays the token's; a finished run the viewer's actor may not read → the same 404 (authorize(actor, "runs:read", run) on the record's own channel, user and stamped visibility, after the runs:read command admission /api/runs.get asks first — authorization.md item 5; the reason goes to the audit line only); a finished run it may read — still in the registry or persisted — renders through the SAME run page, seeded with the record's events (RunHistorySeed: the events through withOmittedMarkers plus status/eventCount/durationMs), in history mode: no EventSource is opened (the seed is the stream; opening one would paint every row twice), the Stop/Kill controls are hidden, and the header is a grey finished · <status> (completed, stopped (soft), stopped (hard), failed; bare finished before the store confirms a status). A truncated record shows one … N events omitted note row at the first seq gap (withOmittedMarkers; N = published − stored; a gap at the start puts it first, a cut tail puts it last). GET /runs/:id/events tokenless serves the events of that same ONE getRun(id, { include: "messages" }) read — the whole stream is known before the head is written and the record is never re-read page by page — then writes the 200 head, the prelude, every frame (marker included) and the terminal end. GET /runs/:id/friction tokenless returns the stored diagnosis to a viewer who may read the run (404 otherwise). POST /runs/:id/stop tokenless is 409 for a finished/persisted run the viewer may read (404 otherwise — the 409 would confirm existence) and 404 for a live one; with a valid token the stop is the registry's token-gated requestStop via LiveRunAccess, unchanged. Each allowed history page/events read emits one audit line { route, runId, identity } (identity is the viewer's actor id, access:<sub>) and each refused tokenless read one { route, identity, denied } (denied is authorize's reason token, e.g. not-member; no run id, so the log reveals no more existence than the 404) — never content. Dev bypass: under ACCESS_DEV_BYPASS, history reads (?all=1, tokenless page/events/friction) are 403 unless the client is loopback and PUBLIC_BASE_URL is unset/localhost; the token path is unaffected.

  17. Index toggle. GET /runs lists the active runs from registry.listActive() plus, with the run ledger on, the runs live under other generations (RunsService.liveElsewhere, run-history item 41: tokenless rows named by their ownerGen, static — the index feed stays the registry's) — never a store read — with a Show all toggle to /runs?all=1, which lists one full page of RunsService.listRuns({ status: "all", visibleTo: predicateFor(actor, "runs:read", "run"), limit: INDEX_PAGE_SIZE }) (live ∪ finished ∪ persisted the viewer may read, newest first — the viewer's predicate is the store's own filter, authorization.md item 6; a page's worth, item 20) with Active only to go back. When the page was full the service's nextBefore cursor renders as an Older runs → link to /runs?all=1&before=<finishedAt>&beforeId=<id>, which the index route parses (parseIndexCursor; a malformed pair is ignored → first page) and passes back to listRuns; a short page has no link. The toggle (Show completed / Active only, item 18) carries the truthful retention sentence as a real tooltip (a UTooltip on the ? help, with a screen-reader copy): Finished runs are kept for N days, then deleted from runHistory.retentionDays, or Run history is off; finished runs are kept about a minute. when there is no store. Rows are one IndexRow projection (a RunView plus an optional tokenRunIndexRowSeed in webSeed.ts) rendered by ONE component (web/src/components/runs/RunRow.vue over the row model in web/src/lib/indexRow.ts — the port of the old isomorphic indexRowRenderer) for seeded rows and feed repaints alike; the old server/client mirror holds by construction because only the client renders. Live rows link with their token; finished rows link tokenless — and mergedRows attaches the registry token ONLY to unfinished rows, so a finished-but-still-in-registry run's token never reaches the seed. Finished rows carry a status dot (grey completed, amber stopped, red failed) with an accessible label; since item 18 the dot's hover carries the status word plus when the run started and (once finishedAt is known) finished, and the duration sits in the row's facts as the fixed stopwatch (10s, 1h 02m) — there is no separate status line. The feed (?stream=1, &all=1 echoed) is reconciled by feedAction: the default view drops a finished upsert (the row leaves as the run ends) and honors every removed; ?all=1 keeps finished rows and ignores removed only for a row flagged data-persisted (the registry's markPersisted upsert), so a run the writer lost still disappears at eviction and no ghost row survives a reload. A finished row keeps the record's finishedAt/status; the ?all=1 feed's upsert for it carries the registry's RunSummary (no such fields), so the client repaints through mergeRow(prev, run) (web/src/lib/indexRow.ts) — the summary overrides only what it carries and never wipes the status, duration or dot.

  18. UX papercuts — the runs page and the run page read at a glance. Runs page shell: /runs and /runs/scheduled are two tabs of one page (the shared shell plus AppShell/AppNav/RunsTabs in web/src/components/: header, site nav, nav.tabs with aria-current="page" on the current tab — Runs · Scheduled). What the chrome lists follows the installation's capabilities (the seed's capabilities, src/core/capabilities.ts, read through web/src/lib/capabilities.ts — never a page's own data): the site nav lists Runs always, Residents only when residents is on and Costs only when costs is; the Scheduled tab exists only when schedules (firing history) is on, and a bar with one tab is not drawn at all; the docs link — in the header and as the phone menu's own group — is always there: it opens the project's published site, which no capability gates (docs-site.md item 11). The section or tab the viewer is on is always listed, so the way back never disappears; a page without a seed lists Runs alone. Open-closed: adding a section is one entry in navSections' list with its capability predicate; nothing else changes. The routes themselves are unchanged — /residents and /costs still answer 503 when their subsystem is off; with the tab gone, nothing links there. The header reads Live runs ● connected — the connection indicator sits beside the title (its label is connected / connecting… / disconnected, never live, which is a run state) — and the site nav is pushed right. The same order and label apply to the run page header. Index (Runs tab, on top of item 17): every row carries a stopwatch in its right-hand facts — a live row's elapsed since start, painted from the server clock (the seed's now) and ticked once a second by the page from the row's start stamp (client clock), a finished row's start→finish fixed — via src/channels/indexFormat.ts formatElapsed (38s / 4m 12s / 1h 03m; garbage → 0s); a paint with no clock leaves the cell empty until the first tick, so the row model stays clock-free. The dispatcher's label is split by splitRunLabel into an agent chip (hue per built-in agent — coding/review/research/general, else neutral; the class is allow-listed, never the raw name), the scope (repo or #channel · user, the row's anchor), and the request snippet (muted, ellipsized, no quote marks); a label not in that shape renders whole as the scope. The status dot's hover reads <status> · started <ISO> and, for a finished row, · finished <ISO> (formatLocalIso — the renderer's zone: the server's on first paint, the viewer's on the feed's repaint). Rows are newest-first by startedAt (unchanged). Live rows breathe (dot halo, off under prefers-reduced-motion), finished rows sit back (muted). The row model (web/src/lib/indexRow.ts) imports its three formatters (formatElapsed/formatRelative/splitRunLabel from indexFormat.ts) directly — the old RowFormatters parameter existed only because a bundler rewrites an imported binding inside an inlined String(fn) body (seen under vitest as __vite_ssr_import_4__), and that constraint died with the inlining: Vite bundles the imports. The toolbar counts N running; the item-17 toggle reads Show completed / Active only with a real tooltip (see item 17). Run page — one step is one block, read top to bottom: a turn is held for the step it produced; the step's first row is its head — [when] 💭 <how long the model thought> <what it then said> … <tokens> — the narration IS what the thinking produced, so the chip and the prose share a line (the chip turns amber past 2 min; a tool-only completion shows went straight to tools in the prose slot; a stream from before turns existed has no chip). A 2 px rail on the step's left edge marks where it starts and ends (green while it is the live step), and every row inside a step shares one column grid — rail → 1.5rem → timestamp → marker → text … facts: text rows (head, group tally) pad 1.5rem, cards sit .75rem in and pad .75rem inside their border, so a card's timestamp lands in the head's column and the right-hand facts end on one line. From the second call on, a step's cards fold into one <details class="group"> whose summary is a muted text-xs sentence, not a header competing with the cards — ❯ 7 tool calls, 1 failed, 1 sandbox error, 2 still running … 9.4s (callSummary, every state: all succeeded; the non-success counts — N failed, N sandbox error(s), N still running — with successes implied; all running; the calls-began time on hover; total = sum of call durations; the tally cells are kept by name on the step node, never addressed by child index); groups stay OPEN by default and are never auto-folded (revised with the Vue port — an all-collapsed page did not read; the tally bars are the narrative): only a viewer's manual toggle (click on the summary) closes one, and it sticks; a failure/sandbox error or a still-running call forces a group open even past a superseded manual close; the CARDS inside stay collapsed except failed/infra (item 13, unchanged); Expand all keeps everything open. Single-call steps stay a bare card. A turn with no step after it (the answer's own thinking, or the run ended mid-thought) is flushed as its own head row reading wrote the reply below / the run ended here. There is no tail row (a dashed-rule row rotating a verb — Noodling… — under a card that spins AND says running… is three loading states at once, and a clock counted from the last received frame reads 57s for a 20-minute command after a reconnect's replay notice). In-progress work draws where it will end up, looking like what it becomes: a running card ticks its elapsed in the facts slot its settled duration lands in (CallCard reads the page's RunnerClockKey; a history page provides none, so nothing there ticks); a pending model turn — every call settled, the model silent — is a pending-turn row at the foot of the log (#thinking): the dashed outline of the card that has not landed yet, reading ∿ · claude-fable-5 · <verb>… ……… <elapsed> — the ∿ pulse (item 19's glyph, reused), a badge naming the model the run is on (modelName: the part after the provider slash, the full <provider>/<model> on hover; run_meta's model until a stamped turn names one, item 15; model when nothing is known), a verb from a fixed list (Thinking, Pondering, Mulling it over, …) DERIVED from how long this silence has lasted — a new word every 6 s, every silence starting at Thinking, nothing rotating while no one waits — and the elapsed since the last stamped event, amber past the same minute the finished thought head turns amber (a first cut painted it as a bare thinking <span> head on a rail; it read as nothing, so the fun words and the pulse came back as a row shaped like the others); the real step replaces it when the turn lands, so a silent model is never a blank page; the model badge is worn where a reader learns something (TurnVm.showModel): the run's first thought head, and every head where the model changed — a run on one model names it once, and the heads between carry only their cost facts; the badge is the pending-turn row's, first in the head's meta row, naming the model that took the turn — the turn's stamp, else the model the run was on (TurnVm.model falls back to run_meta's, so a record from before per-turn stamps still names its model on its first head; a turn with nothing known wears none); a model switch stands out: a stamped turn whose model differs from the model the run was on (the previous stamped turn's, else run_meta's — TurnVm.switched) wears a loud ⇄ <model name> chip INSTEAD of the badge (amber border and fill, full ref on hover) in its step head or turn row, the pending-turn badge then names the new model, and a turn on the same model, an unstamped turn, or the first stamped turn of a meta-less run is never a switch; before the first stamped event the live page shows the same Waiting for activity… placeholder a history page does. Every stopwatch on the page reads one projected runner clockrunnerNow in runPageModel.ts: the newest stamped event's at plus the wall time since it arrived; only a stamped frame moves that anchor, so replay notices and reconnects cannot restart a count, and browser and runner clocks are never subtracted from each other (liveWait names the current wait: starting / call / thinking). The card's spinner is the one executing mark; the ∿ pulse stays the connection's alone (item 22); a running card's body says no output yet, never a second running. Slack bold: humanizeMessageText (item 16's ingress-side humanizing of Slack-authored record text) also maps mrkdwn *bold* to Markdown **bold** — only when the asterisks delimit a non-space-edged run on word edges, so globs (src/*.ts) and arithmetic (2 * 3 * 4) are untouched, and code spans/fences are left byte-for-byte; block-level mrkdwn ( bullets) cannot survive because parseDirectives has already collapsed the request to one line. Scheduled tab: the panel moves off the index to /runs/scheduled (see item 14; without a schedule registry the tab says so with a 200, not a 404). The firing detail is the reply's first non-empty line (interpretIngressResponse), the self-improvement head line carries the tally (· 1 filed · 1 already open · N failed to file), and each schedule renders as two flowing lines, no table columns: the definition — name · cron UTC · <command> as <identity> · NEXT <fire time> (in …) — and under it the last firing as one ellipsized line — LAST <outcome> · <how long ago, exact UTC on hover> · run <id> · <facts> — where firingDetailSummary drops a leading emoji and a leading *Title* —, caps at 120 chars with , and keeps the full text on the span's title. Count cell: the row's N events prints stepCount — the content events, span records excluded (tracing.md) — when the row carries it, else eventCount, always under the word events; its tooltip says what it counts (countText/countTip, web/src/lib/indexRow.ts; the tooltip says span records included when a row has no stepCount and the fallback total counts them).

  19. Run page — gutter layout, run context, a real 404. Layout: the header is a full-width band (back link · title · ∿ connected at the left, Stop/Kill in the middle, site nav at the right); the connection mark is the pulse glyph colored by state (green connected, amber connecting/stopping, red disconnected, grey finished) and the tail reuses it. Each step's timestamp lives in a fixed left gutter (--gutter: 9.5rem) beside the rail, padded .75rem off it — the short local clock [HH:MM:SS] with the full ISO-with-offset on hover (the Request block already dates the run); the head row is <thought chip> <narration> (the chip is amber by default — thinking time is the thing to notice — and quiet under a minute), the turn's token facts sit on their own dotted line under the prose (8.4k in · 310 out · 7.9k cached), the call tally is a bordered bar like the cards (6 calls ✓ 6 … 7s, calls-began time on hover), and card headers carry no timestamp — the call's start rides on the card's hover title. Facts everywhere read as ·-separated lists (a CSS \00b7 escape, not a JS one). Expand all / Collapse all is a view control, not a run control: it moved out of the Stop/Kill cluster and is now a plain text button (#fold, Expand allCollapse all, aria-pressed) at the right edge of the This run heading's row — THIS RUN · 6 steps, the heading over the time card and the steps — at the same size and weight as the time card's raw events · Copy debug JSON, never a boxed or iconed button floating on a line of its own. One right edge: every timestamp, duration and control column on the page — the block headings' moments, the step heads' clocks, the span rows, the group summaries' totals, the cards' facts, the pending row's elapsed — ends on one right gutter, --sb-gutter (web/src/assets/main.css, pr-(--sb-gutter) in the templates; a bordered card subtracts its border), whatever its nesting depth; the call card's chevron leads, like every other fold's, so nothing sits after the facts. Run context: a new stream event run_meta{ type:"run_meta", agent, model, repo?, ref?, pr?, headSha? } — is published by the dispatcher right after input — at the ledger reservation, from the RepoContext as resolved then — and once more, with the adopted headSha, when the attach finds the PR head moved and adopts it (agent-review item 12); readers take the latest, so the record and the page name the head actually reviewed; the timeline folds it into { kind:"meta", … } keeping only well-typed fields (a positive integer pr, a 7–40 hex headSha; a meta without agent/model is ignored), and the page renders it as the facts bar under the header, above the request — REVIEW · anthropic/claude-fable-5 · owner/repo · ref · #7 · c211fd0 — where repo, ref, PR and head are GitHub links (/tree/<ref> for a ref inside git's ref grammar, /pull/N for a positive integer, /commit/<sha> for a 7–40 hex sha shown as its seven characters; web/src/lib/githubLinks.ts), built only for an allow-listed owner/name shape — a value of any other shape renders as text, never a link; the review's Reading diff control sits at the row's right edge as a link-weight text button with the git-compare glyph, the same weight as the chips beside it. runFriction.ts ignores it; runEventLines.ts accepts it (agent + model required); the status card never sees it (published straight to the registry). The Request source line leads with a drawn Slack mark (four lozenges as inline SVG — no external asset under the CSP) and the channel name is the link to the thread: ⁝ #general · alice. 404: GET /runs/:id for an unknown run, an expired one, a wrong token or a tokenless live run answers 404 as a page — the shared shell with a RunNotFoundSeed ({ page:"runNotFound", retentionDays }; web/src/pages/NotFoundPage.vue, no connection indicator), 404 · That run isn't here., the same non-revealing sentence for every case, the retention sentence (item 17's) so the likely reason is on the page, and ← All runs; every page 404 is byte-identical and echoes nothing from the request. The machine routes (/events, /friction, /stop) keep the text body run not found.

  20. Runs index — started column, activity tooltip, pager, expiry divider; run page — total duration. Toggle: item 17's Show all link is a checkbox Show completed (checked on ?all=1); changing it navigates — the view is a server mode, not a client filter — and the retention note is a real tooltip on the ? help (UTooltip, see below) with a screen-reader copy the checkbox is aria-describedby. Started column: every row leads with when it started the way GitHub says it — indexFormat.ts formatRelative: just now (< 45 s), 1 minute ago59 minutes ago, 1 hour ago23 hours ago, yesterday, 2 days ago6 days ago, then the date (Aug 28, with the year when it differs) — painted from the server clock and re-ticked by the page every minute; its tooltip is the exact received … (once the run carries receivedAt, tracing.md) / started … / finished … stamps (renderer's zone). Activity on the dot: RunSummary.activity (new, additive) is the run's latest one-line activity — the newest assistant line, tool_call summary, or answering, whitespace-collapsed and capped at 120 chars, set by RunRegistry.publish() and forwarded through RunView for live rows — and the status dot's tooltip reads now: <activity> (starting… before the first event) so a glance answers "what step is it on" without opening the run; a finished row's dot reads <status> in <duration>. Native titles are gone from the row. Tooltip component: informational tips ride Nuxt UI's UTooltip (the old hand-rolled installTooltips/data-tip component is gone) — shown on hover and keyboard focus, placed/flipped/clamped by Reka UI's floating layer, text only, multi-line tips preserved. Informational tooltips are not shown on touch: the mobile run row instead exposes the thread link and Stop/Kill through a finger-sized menu. Pager: ?all=1 pages are INDEX_PAGE_SIZE = 25 rows (the service cursor already existed; the page was rendering the 200-row cap): a nav.pager under the list — Older runs → when the page was full (item 17's cursor); on a cursor page ← Newest runs · runs finished before <Mon D, H:MM AM/PM> together on the left (the cursor stamp via formatDateTime, renderer's zone — item 21 replaced the earlier centered N shown, which said nothing a reader could use); none on the default view. A cursor page lists finished runs only — the service leaves the live rows off it, they all sort ahead of any cursor — and its feed only repaints rows it already has (the row is looked up for upserts and removals alike): a run starting or finishing now belongs on the newest page, never at the top of an older one. Expiry divider: with run history on (a known retentionDays), every finished row carries data-expires-at = finishedAt + retention; rows leaving within a day get class leaving, a gone <YYYY-MM-DD HH:MM> fact in the renderer's zone (exact time on hover), and sit under ONE cut — ⏳ Leaving within a day — each row says when it is removed — placed before the first such row (they are the oldest, so it is one cut in the newest-first list). The page re-places the divider after every feed change and once a minute (a row can age into its last day while the page is open) and removes it when nothing is leaving; the sorted row insert skips it. With history off there is no divider and no stamps (every finished row leaves within a minute; the toggle's tooltip says so). Run page: once finished the header says how long the run took — history pages from the record (finished · completed · 2m 27s, RunHistorySeed.durationMs), the live page at end from the first→last event stamps (finished · 2m 27s, stopped (soft) · 41s). A turn with no narration reads no commentary (not went straight to tools — the calls are the rows below). A tool whose summary is only its name (submit_verdict) shows the chip alone, no duplicated word.

  21. Runs index — how a run ended, where it came from, one grid. Outcome: a finished row that did not complete carries an outcome badge beside its label — failed and killed (a hard stop; the dot is red for both), interrupted (cut down before finish — a tombstone or drain-deadline record; red too), stopped early (a soft stop; amber) — and the dot's hover adds what it was last doing under the outcome line (failed in 41s ⏎ ⚠️ resident not onboarded: acme/web): RunSummary.activity now also takes the answer text's first line, so a failed inline run's ⚠️ reply is the failure, and the persisted record carries activity (RunRecord.activity, from the stored events) so history rows say it too. The same words everywhere: statusLabel (web/src/lib/indexRow.tsstopped_hardkilled, stopped_softstopped early) drives the run page header, and the Scheduled tab's outcome column shares the vocabulary through scheduledPanel.ts's label maps; a finished registry summary whose record status is not known yet shows its stop badge with the same word. Source mark: every run has the standard trigger metadata — the platform prefix of its ids (AGENTS.md invariant 4: slack:, http:, mcp:, cli:) and the identity behind it — rendered after the label, revealed on row hover / keyboard focus like GitHub's row quick actions, whose hover reads via <Surface> · <identity> on one line — the identity is the resolved display name (IncomingMessage.userNameRunMeta/RunSummary/RunView/RunRecord.userName), falling back to the id suffix, never a raw slack:U…. Without a thread the mark is the surface's glyph ( Slack, HTTP ingress, MCP, >_ CLI); when the message had a permalink (IncomingMessage.sourceUrlRunMeta.sourceUrlRunSummary/RunView/RunRecord.sourceUrl) it is instead the familiar open-in-new-page arrow (, class linked) — a pointer cursor and a chip-style hover state say "clickable"; no caption — opening the thread in a new tab. Only for an http(s):// URL; a record is data, and any other scheme renders the plain mark. The run page follows the same outbound rule: the Request block's thread link and the run-meta GitHub links (repo · ref · #PR · head) open a new tab. Turn heads: a step whose turn produced no prose is ONE row — the duration chip with the token facts beside it, no "no commentary" filler for the eye to land on (the filler survives only for a turn with no facts at all); under prose the facts sit ABOVE the head row as a smaller, dimmer line pulled tight against it — metadata reads as a superscript, never as the lede. Run meta: run_meta carries the resolved effort beside the model (one resolution per run — agent/model/effort are fixed at dispatch and cannot change mid-run; if per-turn switching ever lands, the turn blocks would carry it); the meta line reads agent · model · effort · repo (GitHub link) · branch as a quiet unlinked tag (nobody clicks "main"; the sha is gone for the same reason) · the #PR link led by a drawn GitHub mark. Tooltip icons: a source mark's tip leads with an allow-listed icon — Slack's drawn mark (SlackMark.vue), or the http/mcp/cli glyph — rendered by the components (SourceMark.vue), never from record text; the index's source marks set it, answering "which surface" at a glance in the overlay. Tab title + favicon: the index tab reads (n) Live runs while n runs are live and carries a drawn dot favicon — green while anything runs, gray when idle — server-rendered in the shell and re-derived by the client on every feed change (src/channels/favicon.ts data URIs; the CSP's img-src 'self' data: exists for exactly this favicon plus bundled assets — everything else stays blocked). The dot is one grammar with four tones (FAVICON_BY_TONE: green · amber · red · grey, the same tones StatusDot.vue paints in a row), worn only by pages with a state to claim — the runs index and run page (live/idle), and the residents index, whose dot is the fleet's worst resident (resident-repos.md item 42); every other page wears the neutral mark. GET /favicon.ico serves the raw idle-dot SVG (public, cacheable, exact path) — the fallback every page without an inline icon link asks for; the catch-all used to answer it with a text/plain ok. An SSE RE-connect reloads the page for a fresh server snapshot: the backend may have restarted, and rows it never knew would otherwise never receive their removed events — a stale tab once pinned the count at runs from a previous backend. Other surfaces render the standard set today; their extended sets (an HTTP caller, an MCP client name) plug into the same mark when the adapters carry them. Repo tag: a repo run shows the repo name as a small tag — api, not acme/api (one org; the path is noise) — linked to https://github.com/<owner>/<repo> (a new tab — outbound links never take the operator off the dashboard; the source mark's thread link likewise) with the full slug on hover; from RunView.repo when the run had one, else from a label scope shaped owner/repo (anything else stays a plain scope, never a link). One grid: the row is a stretched link — one <a class="row"> covers the <li> (named open run <label> for assistive tech) under a <div class="body"> whose own links (repo tag, source mark), buttons and tooltip cells take the pointer, everything else falls through to the row; a click on a tooltip cell (dot, started, elapsed) is routed to the row's href by the page — so a link may hold no link and no button, yet the whole row is one click. The actions cell is always present at a fixed width (empty on a finished row), so the facts (elapsed, event count) sit in the same column on a running row with Stop · Kill and on a finished row. The stop buttons' hints ride UTooltip (no native titles remain). Expiry divider: one dashed line under its label, no doubled border or top margin (the row above already draws the line); a leaving row's gone <when> fact sits in the outcome column (before the facts), in human form — gone Aug 30, 9:17 PM (formatDateTime: Mon D, H:MM AM/PM, the year when it differs from now's, renderer's zone) — so elapsed and event count keep their columns. Snippet budget: composeRunLabel's snippet cap is 100 chars (was 60) and the label cap 160 (registry cap 200): a laptop-width row holds that much after the started column, chips and facts, and a 60-char snippet left half of every row empty. The request snippet has no full-text hover: the row is fed from the run label, which carries only the snippet — the full request is the run page's first event.

  22. Run header — the outcome chip; one status vocabulary. The header's two axes are separated for good: the ∿ pulse says only what the CONNECTION is (green connected · amber connecting · red disconnected) and does not exist on a history page (nothing is connected there); the RUN's state is its own element. Display words everywhere (statusLabel, web/src/lib/indexRow.ts; the Scheduled tab's OUTCOME_LABEL in scheduledPanel.ts matches): completed renders as succeeded — "finished · completed" was a tautology and bare "finished" read as success while including failures — beside failed, killed, stopped early; the Scheduled tab's outcome column says the same. A history page heads with the index's own outcome chip plus the duration in tabular numerals: success is the quiet green ✓ 2m 22s (nothing to flag IS the message; green stays the "alive" color, so success does not shout), failed/killed red chips, stopped early amber, and a grey ended chip for a pre-history record whose status is unknown. A live page reads running · <stopwatch> — the WHOLE run, on the one duration definition (tracing.md runDurationMs): from the moment our process received the message (receivedAt, falling back to the registry's startedAt) to the server clock the seed carried (serverNow) projected forward by the browser time since the seed arrived — never an event stamp, never a server stamp minus the browser's clock — ticked each second and frozen at end at the value it had; the index row, runs list and the history seed print the same number — while connected; stopping (soft|hard) / stop failed: keep the slot while a stop is in flight. At the live end the page shows the chip it can KNOW — killed / stopped early when the viewer stopped it, else the grey ended + duration (an inline ⚠️ failure still arrives as an answer, so the live page never guesses "succeeded"; a reload shows the record's word). Between the finished frame and end the header reads delivering… · <total> (phase: finished — the total frozen at the frame's server stamp, the pulse still, the actions gone, the tab idle; a stop in flight keeps its word), and at end the duration gains the delivery caption from the frame's stamps (tracing.md): delivered in 2s (sealedAt − finishedAt, when replyOk is true), reply failed (false), nothing when no reply attempt was measured (a command run that fell through to the agent, a backstop or sweep seal, a stored stream's {}); a history page reads the caption from the record's sealedAt/replyOk. On the index a finished row whose stream is not sealed yet and whose record is not written (delivering, web/src/lib/indexRow.ts) is amber whatever its status and its dot tip ends · delivering the reply; a seal or a persisted record ends it. Amber is shared with stopped early — the word and the tooltip tell them apart — and a run whose reply never settles stays amber until the registry evicts it.

  23. Run page — a loaded skill is its own row. A skill_use event (run-visibility.md item 1; skills.md) is folded by the timeline into { kind:"skill", step, skill:{ name, description, agent, source?, bodyBytes, at } } on the current step — the step whose use_skill call it belongs to (a skill before any step opens one); only an http(s) source survives the fold, a nameless event is ignored, and the skill is never a call (the step's call list is unchanged). The page renders it inside the step's card area as a violet-railed row — [when] 📚 skill <name> <description …> source · 12.3 KB into context — where source becomes a link (target=_blank, rel=noopener noreferrer) only for the fold-vetted http(s) URL and the size comes from bodyBytes; the row joins the step's group like a card, so folding/expanding the step carries it along. The use_skill call card above it keeps saying what the tool did; the row says what was loaded.

  24. Duration heat — slow reads warm, over budget reads red, everywhere a time is printed. Every rendered duration on the run page and the runs index used the same muted grey, so a 15-minute pnpm typegen that ate a third of a 45-minute budget looked like one more line among two hundred 200 ms greps, and the only colour rule was a binary amber on a model turn past a minute. One pure helper, web/src/lib/durationTone.ts (durationTone(ms, kind, over)), now maps every duration to a heat: the duration is placed on a log scale between the kind's quiet floor and its ceiling and painted in OKLCH with a fixed lightness per theme (--sb-heat-l, 0.52 light / 0.8 dark, beside the status tokens in main.css) so every step of the ramp keeps the same contrast against the page and only hue (amber 88° → red 28°) and chroma (0.06 → 0.17) carry the signal; below the floor the text inherits its muted colour, because most calls are quick and should stay quiet. The anchors are the runtime's own numbers: tool runs 2 s → 20 min (the bash tool's BASH_TIMEOUT_MAX_MS), turn 15 s → 5 min (the friction analyzer's 60 s slow-turn threshold lands on the ramp, not below it), run 1 min → 45 min (the coding agent's wall clock). The helper hands the DOM one scalar, --heat-t, and the .heat utility in main.css does the colour math (oklch(var(--sb-heat-l) calc(…) calc(…))), so the ramp is a theme concern, not a script one; levels 0–3 ride on data-heat for tests and styling, and from level 2 the text is also medium weight. Over budget is categorical, never a hotter shade of slow: a command the sandbox killed at its deadline is data-heat="4", its duration fact is text-bad bold with a timed out label before it, whatever its duration — and a timed-out call with no computable span still wears the label after its last fact. The signal is exit 124 only: every executor renders its own deadline kill as an exit 124: line (execution.md item 11, bashTimeoutNote) and keeps a foreign SIGKILL out of that path, so 137 (the OOM killer, a kill -9) is a plain failure, not a timeout. Painted sites: the call card's duration fact (always its last fact) and the card's data-heat; the step head's thought … chip on the turn scale (replacing the binary text-warn; the model's quick flag stays as the sub-minute fact); the group summary's tallied tool time, which also goes over when any call in the group timed out; a finished index row's stopwatch on the run scale (a live row stays green — its clock is moving). The fold carries what the paint needs: CallVm gains exitCode and timedOut, TurnVm gains durationMs.

  25. The timeline — the run's shape, from its spans and stamps alone (tracing.md). Under the This run heading and above the steps — it is the summary of this run — #timeline, headed Where the time went (web/src/components/run/TimelineSection.vue; its view-model buildTimeline, web/src/lib/timelineVm.ts, is pure) reads the page model's span set and loss intervals (spanSet() / losses(), folded per frame from the same stream the log reads, traceVersion bumping per frame) and the header's own window — receivedAt (else startedAt) plus the header's total, i.e. to finishedAt on a record and to the projected server clock while live — so its lede closes to the header's total by construction. Finished: the total (4m 12s, the header's) over a bar and its legend — swatch · word · time per item, getting ready 34s · thinking 2m 16s · in tools 1m 10s · finishing up 8s · Switchboard overhead 4s, the word's definition on hover (TERM_DEFINITIONS; a swatch wears its segment's paint class — TERM_PAINT in web/src/lib/termPaint.ts is the one word→class mapping the segments, the swatches and the phase heads' markers share, each counted bucket its own token in both themes: getting ready blue, thinking the product green, in tools violet, finishing up muted, the residual hatched) — the printed items summing to the printed total; vm.lede keeps the one-sentence form (4m 12s — 34s getting ready · …) for the bar's aria-label. Live: the non-zero buckets over the elapsed window, then the bucket currently open as a drill-down — · currently thinking 1m 26s, the deepest open counted span's elapsed, a subset of its bucket and never an addend, omitted when the deepest open span is uncounted or background; the log's tail row names that same span (a model turn…, attaching the workspace…) instead of a rotating verb while one is open — then · currently delivering after the finished frame and, after end, delivered in 2s / reply failed / nothing per replyOk (item 22's caption). Below the informativeness gate (fewer than two buckets at 5 % or 2 s) the lede is the total and the dominant word (40s — getting ready). The root's queuedBeforeMs / queuedBehindMs — set when the root starts (startRequestRoot's originAt / queuedBehindMs), never after, since a root's only streamed event is its span_start — print as queued … before we saw it / … behind the previous run from a minute. When the gate passes: the bar, whose segments are the legend's numbers in the legend's order (the open bucket's in-flight tail hatched, the residual and the loss terms striped or hollow), and Longest steps — up to three steps ranked by their own time (the in-window duration minus the union of their children's; the root, the agent loop and background subtrees excluded; ties by earlier start), each named as its row is (a tool step by its card's command through TimelineInput.callTitle, else the display table) with whitelisted facts only (token expired, budget clipped, timeout 20m 00s, waited 4m 00s, exit 1, timed out, 2 attempts, failed), and each a link to its row: RankedItem.anchor is call-<callId> for a tool step and span-<spanId> otherwise, the ids CallCard, SpanRow and a step's turn stamp on their elements; a click asks the model to open whatever folds the row (reveal: the step's group or the phase head, as a reader's own toggle) and scrolls there with a one-time highlight; the self-time footnote is the heading's hover. The log speaks the bar's words: slack.receive, the dispatch.* steps and the attach's grafts fold under a Getting ready head, the steps classOf counts as finishing up under a Finishing up head (phaseOfSpan, PhaseGroupVm; each open while its phase is in progress, closed on its own when the loop starts / delivery begins / the run ends, a reader's toggle winning from then on), thought … heads every model turn and the cards are the tool calls, so a reader correlates the legend with the rows; the request and run.agent spans are the page's structure and draw no row (isStructuralSpan). A record cut to its budget (the seed's truncated) prints its lost stretch as not recorded (too large); a live page whose replay was elided prints not loaded (the record has the full shape); a record with no root shows the total and getting ready: not recorded (too large); a record written before span schema (the seed's untimed, tracing.md item 14) shows the total and no timing data — no bar, no ranked steps, no captions — the one neutral empty state, whatever the record carries. Raw span names and the partition live only behind Copy debug JSON; the raw-events link (/runs/:id/events) appears in history mode only. runOwnerOf (src/core/runOwner.ts) names the partition owner from run_meta's agent, so a command run's run.command is its tools.

Validation criteria

CriterionEvidence
skill_useskill change on the current step (opens one if none), http(s)-only source, nameless ignored, never a call; the page renders the skill row inside the step with the source link and the byte fact (item 23)[unit] src/channels/runTimeline.test.ts::createRunTimeline — skill_use (2); web/src/lib/runPageModel.test.ts::calls, groups, folding::a skill lands as its own row inside the step, never a call card, web/src/pages/runPage.test.ts::RunPage — history mode::update_status renders as a quiet line and a loaded skill as its own row…. [agent] see skills.md criteria: a review run that loads a skill shows the violet row under its use_skill card.
Every model call is one model.turn span, ended before what it produced, timed by the runner clock, carrying the model, the stop reason and provider usage when reported; a refusal is a turn, a throw is not; each tool call is a tool.<name> span whose tool_call/tool_result carry its id and whose end carries the outcome; the loop is run.agent under the given parent; without a parent the run emits no span and is unchanged (item 15; tracing.md item 17)[unit] src/runner.test.ts::model turn and tool spans (docs/reference/specs/tracing.md)::* (7 tests), src/providers/anthropic.test.ts::usageFromAnthropic…, src/providers/openaiCompat.test.ts::usageFromOpenAI…, src/core/runEventLines.test.ts::parseRunEventLines::accepts the timeline events…, src/core/dispatcher.test.ts::live run-view wiring (Area 2)::registers the run, publishes its events, and finishes it (stream shape +request, input, run_meta, +run.agent, +model.turn, -model.turn, +tool.bash, tool_call, tool_result, -tool.bash, +model.turn, -model.turn, -run.agent, answer)
The page shows a turn as a 💭 Thought for … row with token facts — and the first-token, thinking and writing facts when the span carries them — and the model above the step it produced; a turn is a step boundary; a malformed span end is ignored and an event kind the fold does not know draws nothing (item 15)[unit] src/channels/runTimeline.test.ts::createRunTimeline — model turns (item 15)::* (6 tests), web/src/lib/runPageModel.test.ts::steps and turns::*; [human] before/after screenshots on the PR from a local replay of a real run's events
15: the Anthropic stream's block boundaries reach the observer by kind and index and the first token fires once; the runner sums them onto the turn as blocks, thinkingMs, textMs (an open block ends at the return) and a silent provider adds no attr[unit] src/providers/anthropic.test.ts::stream timing hooks::*, src/runner.test.ts::model turn and tool spans (docs/reference/specs/tracing.md)::a provider that streams block boundaries…
Page sends clickjacking defenses (frame-ancestors 'none' + X-Frame-Options: DENY)[unit] src/channels/webShell.test.ts::WEB_HTML_HEADERS::locks the page down: self-only scripts, no external assets, no framing, no caching, src/channels/liveView.test.ts::createLiveViewHandler …::serves the live-run shell for a valid id+token (strict headers; seed carries the token-scoped stream + stop URLs)
create() mints distinct, unguessable id + token; defaults are crypto-random[unit] src/core/runRegistry.test.ts::RunRegistry.create::*
Live subscriber receives published events in order[unit] ::subscribe — token gate (constant-time capability)::delivers published events to a live subscriber
Wrong/missing token and unknown run are rejected (null), nothing delivered; has() mirrors the gate[unit] ::rejects a wrong token …, ::rejects a missing/empty token, ::rejects an unknown run id …, ::has() mirrors the same constant-time gate … (token gate red-verified: removing the compare fails these)
Late subscriber replays backlog in order, then live-forwards[unit] ::backlog replay for a late subscriber::replays already-published events, in order, then live-forwards new ones
Backlog is bounded to the most recent N[unit] ::bounds the backlog: only the most recent N events are retained for replay
Default backlog 8000: the 8001st event drops the oldest; eventCount keeps counting; the byte bound drops the oldest tool results[unit] src/core/runRegistry/backlog.test.ts::RunRegistry — backlog bounds::defaults to an 8000-event backlog …
The protected head: head material published before any other event survives the count and byte bounds (the trim drops the oldest after the head, keeps the newest); the head closes at the first non-head event and at HEAD_BUDGET_BYTES; a head event is capped to MAX_EVENT_BYTES at publish; a fresh subscribe replays the head first then the newest within the budget with the range between elided, a resume re-sends nothing from it; stepCount counts content events only[unit] src/core/runRegistry/backlog.test.ts::RunRegistry — backlog bounds::the protected head::*
Span rows and decorations: the fold's span cases (a step row per dispatch/run/post span, the model.turn turn row and step boundary, the tool twin rule, run_meta without a model); the page keeps one row per span named through the display table, open then closed[unit] src/channels/runTimeline.test.ts::createRunTimeline — span records::*, web/src/lib/runPageModel.test.ts::span rows::*, web/src/pages/runPage.test.ts::RunPage — live mode::a streamed span renders as one row…
Byte budget (default 4 MiB, per-event JSON size): 4 × 1 MiB drops the oldest; budget + 1 byte drops the oldest until under budget (exact boundary, explicit backlogBytes)[unit] ::bounds the backlog by bytes …, ::budget + 1 byte drops the oldest until under budget …
A throwing per-run subscriber breaks neither the other subscribers nor publish[unit] ::a throwing per-run subscriber does not break publish …
Late subscriber to a 3000-event run: one replay_elided frame (1–1000) then the newest 2000 frames, live frames uncapped, snapshot has all 3000; a run within the budget replays byte-identically with no frame; a finished long run → frame, 2000, end; the byte bound elides too; the registry replays the newest limit/byteLimit events, reports the skipped range, counts from the cursor, never elides what the backlog dropped, and REPLAY_EVERYTHING lifts both bounds[unit] src/channels/liveView.test.ts::serveEvents — live replay budget (item 5)::*, src/core/runRegistry/backlog.test.ts::RunRegistry.subscribe — replay budget::*
Dispatcher friction diagnosis equals analyzeRunFriction(registry.snapshot(...).events) (no second ring)[unit] src/core/dispatcher.test.ts::friction diagnosis reads the registry backlog …::… (red-verified: with the old ring the ledger saw 3 events vs the snapshot's 4)
Unsubscribe stops delivery[unit] ::unsubscribe::stops delivery after unsubscribe
Finish stops forwarding content; the seal notifies live subscribers via onSealed (the end frame); a post-seal subscriber replays then gets onSealed; publish to unknown run is a no-op[unit] ::finish::*
Finish and seal: finish sends finished and leaves subscribers attached (one index upsert); the seal, later, sends end from its own clock read (one more upsert, sealedAt after finishedAt under a ticking clock); finish keeps subscribers attached, span records publish after finish (forwarded, counted, no repaint), content is dropped, seal detaches with end, upserts once and returns the events since finish, is re-readable with the first replyOk standing, and everything is dropped after it; a late subscriber to a finished-unsealed run stays attached until the seal, to a sealed run gets finished then end at once; seal on a live run is a no-op and on an unknown run the empty result; the sweep evicts at sealedAt + TTL and seals-then-evicts an unsealed run after UNSEALED_HOLD_MS; sealAllFinished seals the finished-unsealed runs and returns the count[unit] ::finish and seal::*
Finished run evicted after TTL (subscribe → null); unfinished never evicted by age[unit] ::finished-run eviction after TTL::*
Route parsing: page vs events, id decode, rejects non-run/empty/malformed[unit] src/channels/liveView.test.ts::parseRunRoute::*
The live seed carries the token-scoped stream + stop URLs, id/token percent-encoded safely; the page opens that stream and renders event text as data[unit] src/channels/liveView.test.ts::createLiveViewHandler (node:http)::serves the live-run shell for a valid id+token (strict headers; seed carries the token-scoped stream + stop URLs), ::percent-encodes id/token into the seeded URLs so special chars can't break them; web/src/pages/runPage.test.ts::RunPage — live mode::opens the token-scoped stream, reads \running` once connected, and folds live frames through the same path`
SSE: 404 on rejected subscribe; sets text/event-stream + forwards data frames; backlog flushed after the 200 head; finished then end (with the seal stamp) on finish; a finished-unsealed run gets finished, stays open for span records, and end with replyOk at the seal; already-finished replays, then finished, then end; unsubscribe on client close[unit] ::serveEvents (SSE, transport-free)::*
The page freezes its header duration at the finished frame's server stamp through the one definition (from receivedAt when seeded), moves to delivering… · <total> (actions gone, pulse still, tab idle), keeps the total through the end that follows however long the wait, and adds delivered in Ns / reply failed / nothing from the end frame's stamps; a history page reads the caption from the record; parseFinishedFrame/parseEndFrame accept only well-formed stamps and a stored stream's end still carries {}[unit] web/src/pages/runPage.test.ts::RunPage — live mode::a \finished` frame freezes the duration…, web/src/pages/runPage.test.ts::RunPage — live mode::an `end` whose reply threw reads…, web/src/pages/runPage.test.ts::RunPage — live mode::a history page reads the caption from the record's stamps, web/src/lib/runPageModel.test.ts::the `finished` frame freezes the header at the server's stamp (docs/reference/specs/tracing.md)::, web/src/lib/runPageModel.test.ts::the `end` frame's stamps and the delivery caption (docs/reference/specs/tracing.md)::`
Index: the count cell prints the content-event count when the row carries it, else the published total, always as events, with the tooltip saying what it counts[unit] web/src/lib/indexRow.test.ts::…::the count cell prints the content-event count…
Index: a finished row with no seal and no record is delivering — amber whatever its status, tip · delivering the reply; a seal or a persisted record ends it[unit] web/src/lib/indexRow.test.ts::…::a finished row with no seal yet and no record is \delivering`…`
Handler: falls through non-run paths; serves page (CSP) for valid token; 404s page + stream on bad token; streams SSE; 405 on non-GET[unit] ::createLiveViewHandler (node:http)::*
listActive() returns non-evicted runs newest-first with id/token/label/startedAt/eventCount; omits absent labels; includes recently-finished (marked finished) until TTL then excludes; empty when none[unit] src/core/runRegistry.test.ts::RunRegistry.listActive::*
Runs index route: parseRunRoute maps bare /runs + /runs/ to { kind: "index" }[unit] src/channels/liveView.test.ts::parseRunRoute::matches the bare index route …
The index seed carries each live run's token (the rows link through it); empty seed when none; the shell references only same-origin hashed assets[unit] src/channels/liveView.test.ts::createLiveViewHandler (node:http)::serves the index shell at bare /runs: live rows with tokens in the seed, strict headers, the live count in the title, ::seeds an empty row list (and a bare title) when there are no active runs; src/channels/webShell.test.ts::renderShell::renders the app mount, the seed island, and the hashed asset references
A hostile label (a <script> payload) is inert — the seed island escapes every angle bracket; the client URL-encodes id/token into hrefs; escapeHtml escapes & < > " ' in the right order (the shell's title/asset paths)[unit] src/channels/liveView.test.ts::createLiveViewHandler (node:http)::a hostile run label is inert in the page: the seed island escapes every angle bracket, src/channels/webShell.test.ts::serializeSeed::*, web/src/lib/indexRow.test.ts::hrefs::percent-encodes ids and tokens, src/channels/liveView.test.ts::escapeHtml::*
Handler serves the index at bare /runs (+ trailing slash) with the same CSP + frame-ancestors 'none' + X-Frame-Options: DENY + no-store as the page (no token gate — Access-fronted); empty-state; 405 on non-GET; malicious label escaped[unit] ::createLiveViewHandler (node:http)::serves the index shell at bare /runs …, ::also serves the index at /runs/ (trailing slash), ::seeds an empty row list …, ::405s a non-GET method on the index, ::a hostile run label is inert in the page …
subscribeIndex replays the current active set as upserts (newest-first) on connect; create/publish (incremented count)/finish (finished:true) emit upserts; TTL eviction emits removed; unsubscribe is idempotent and stops delivery; a throwing subscriber never corrupts state or throws into the lifecycle call[unit] src/core/runRegistry/indexFeed.test.ts::RunRegistry.subscribeIndex — live runs-index feed::* (publish→upsert, sweep→removed red-verified)
Live index page: seeds the rows (keyed data-run-id), opens EventSource("/runs?stream=1"), repaints through the one row component (no raw markup), and shows a connection-state indicator[unit] web/src/pages/runsIndex.test.ts::RunsIndexPage — toolbar, states, pager::counts the live rows, seeds the list newest-first, and opens the right feed, ::RunsIndexPage — the live feed::connection indicator: connecting → connected on open, disconnected when the stream closes, connecting on a soft error, ::upserts insert new rows in newest-first position and repaint existing rows in place, web/src/components/runs/RunRow.test.ts::carries the sort/merge data attributes: run id, start stamp, persisted, expiry
serveIndexEvents: text/event-stream, buffers the synchronous replay and flushes it after the 200 head, live-forwards upsert/removed frames, unsubscribes on client close (always 200 — no token gate)[unit] src/channels/liveView.test.ts::serveIndexEvents (index SSE, transport-free)::* (buffer-then-flush red-verified)
Handler routes GET /runs → HTML and GET /runs?stream=1 → the index SSE feed (replay + live forward + unsubscribe on close); GET-only (405 on non-GET stream)[unit] src/channels/liveView.test.ts::createLiveViewHandler (node:http)::routes /runs (no flag) to HTML …, ::streams the index SSE feed at /runs?stream=1 …, ::405s a non-GET method on the index SSE stream (?stream=1 routing red-verified)
Dispatcher labels the run at create() via composeRunLabel, additively — everything else unchanged[unit] src/core/dispatcher.test.ts::live run-view wiring (Area 2)::* (existing wiring tests still green with the composed label)
composeRunLabel (pure): repo run → agent · owner/repo · "snippet"; chat run → agent · #channel · user · "snippet" with display names, else prefix-stripped ids; channel-agnostic (http/mcp ids); empty text → no snippet; whitespace collapsed; first-sentence-or-~60-char snippet, word-boundary + ellipsis; overall length capped[unit] src/core/dispatch/reply.test.ts::composeRunLabel::* (label composition red-verified)
Slack adapter resolves human channel/user display names into channelName/userName — best-effort, cached (one API call per new id), display_name→real_name→name preference, API error → undefined without throwing, failure not cached[unit] src/channels/slack/lookups.test.ts::resolveChannelName / resolveUserName (best-effort, cached)::*
Index rows are full-row clickable (the anchor stretches over the row); per-row status dot (green live / grey finished) with an accessible label; feed-added rows share the identical shape because ONE component renders both; a hostile label renders as text[unit] web/src/components/runs/RunRow.test.ts::is a stretched link: the anchor covers the row, live rows carry the token, finished rows never do, ::dot tone agrees: killed is red, stopped early amber, succeeded grey, live green, src/channels/liveView.test.ts::createLiveViewHandler (node:http)::a hostile run label is inert in the page: the seed island escapes every angle bracket
Per-run page has a ← All runs back link to the token-less /runs, plus a header connection status mark + label[unit] web/src/pages/runPage.test.ts::RunPage — live mode::opens the token-scoped stream, reads \running` once connected, and folds live frames through the same path(the back link is part of the page header,web/src/pages/RunPage.vue` — no dedicated assertion)
Both pages carry a header connection indicator driven by the stream's state[unit] web/src/pages/runsIndex.test.ts::RunsIndexPage — the live feed::connection indicator: connecting → connected on open, disconnected when the stream closes, connecting on a soft error, web/src/pages/runPage.test.ts::RunPage — live mode::reads disconnected when the stream closes for good
SSE keepalive heartbeat: an idle live stream emits a : hb comment each interval, and a client disconnect clears the timer (no further heartbeat)[unit] src/channels/liveView.test.ts::createLiveViewHandler (node:http)::SSE heartbeat: writes a keepalive comment on an idle live stream, then stops on client close (fake-timer, real timer behavior red-verified)
Dispatcher registers the run, publishes each event, finishes it in finally[unit] src/core/dispatcher.test.ts::live run-view wiring (Area 2)::registers the run, publishes its events, and finishes it
Live link on the status card when PUBLIC_BASE_URL set; omitted (no crash) when unset[unit] ::puts the per-run capability link on the status card when PUBLIC_BASE_URL is set, ::omits the link entirely when PUBLIC_BASE_URL is unset …
Live end-to-end: open the link in a browser during a real run and watch tool calls/results stream, ✓/✗ per result; a wrong t= gives 404; the stream closes when the run finishes[agent] Behind Access, open the per-run link during a live run: // rows stream through to any stop notes and the end frame (header → finished or stopped (mode)); a wrong t= → 404 on both the page and the stop route.
RunControl: unrequested → soft records only → hard records + aborts hardSignal; soft→hard escalates, hard never de-escalates, idempotent[unit] src/core/runRegistry/runControl.test.ts::RunControl::*
requestStop: create() returns the run's control; valid token drives it and reports the effective mode; wrong token/unknown run → not-found with the control untouched; finished run → finished; publishes a stop_requested note with mode; index shows stopping then stopped; never-stopped runs carry no stop field[unit] src/core/runRegistry.test.ts::RunRegistry.requestStop — run control …::* (red-verified before implementation)
Route: parseRunRoute maps /runs/:id/stop (+ trailing slash) to { kind: "stop" }[unit] src/channels/liveView.test.ts::run control: POST /runs/:id/stop …::parseRunRoute matches the stop route
Handler: soft → 200 JSON + control soft (hard signal live); hard → hard signal aborted; missing/unknown mode → 400 with the run untouched; wrong/missing token or unknown run → 404 with the control untouched; finished → 409; GET on stop → 405 allow: POST; POST on other run routes → 405 allow: GET[unit] src/channels/liveView.test.ts::run control: POST /runs/:id/stop …::soft…, ::hard…, ::400s…, ::404s…, ::409s…, ::is POST-only…, ::the other run routes stay GET-only…
Index UI: Stop/Kill buttons rendered OUTSIDE the row anchor for a live run, hidden for finished or already-stopping runs; stopping (mode)/stopped (mode) badge from RunSummary.stop; rows POST to the token-scoped stop route[unit] web/src/components/runs/RunRow.test.ts::the actions cell is always present (fixed width); the buttons appear only while the run is stoppable, ::Stop POSTs the token-scoped soft stop; the button disables while in flight, ::Kill confirms first (destructive), POSTs mode=hard on yes, does nothing on no; a failed POST re-enables the button, ::shows the stop badge while a stop is in flight, and as the outcome for a finished summary with no record status; the record's status wins
Per-run page: Stop/Kill buttons POST to this run's token-scoped stop route; stop_requestedstopping (mode), end → the stop outcome[unit] web/src/pages/runPage.test.ts::RunPage — live mode::Stop POSTs mode=soft on the run's stop URL and reads \stopping (soft)`; the end then says stopped early, ::Kill confirms first and marks killed at end; a refused confirm does nothing, ::a stop note from the stream marks the run stopping for every viewer, ::a failed stop re-enables the buttons and says so`
Live: from /runs behind Access, Stop a running review → row shows stopping (soft), the thread gets a ⏹ … findings so far reply, row shows stopped (soft); Kill a running coding run → reply ⛔ … aborted within seconds, row stopped (hard); without an Access session the stop route is refused at the edge; with Access but a wrong t= → 404, bad mode → 400, GET → 405 allow: POST[agent] Steps as in the criterion. Stop → row <n> events stopping (soft) (buttons gone) → reply → row stopped (soft). Kill (for automation: POST from the page's origin with the row's token, bypassing confirm()) → 200 {"mode":"hard","state":"stopping"} reply → row stopped (hard). Same-origin wrong t= → 404, mode=nuke → 400, GET → 405 allow: POST. curl with no Access cookie/JWT → 302 to the Access login (the edge answers before our fail-closed 403, which stays [unit]-proven). Pool-user release after the Kill is the "hard stop releases the resident pool user" row in run-loop.md.
The final answer appears on the run page (item 11) and Slack/PR are projections of it[unit] src/core/dispatcher.test.ts::live run-view wiring (Area 2)::publishes the final answer as a redacted \answer` event before finishing the run and before replying, web/src/lib/runPageModel.test.ts::request / context / reply / placeholder::the reply (the answer event) lands below the log; the step's group stays open (the tally bars are the narrative); [agent]Soft-stop a coding run from/runs: the run page's Reply block shows byte-for-byte the Stopped early by an operator (soft stop) — findings so far:text the thread received, headerstopped (soft), log ending with the last ✓ bash: …row →⏱ soft stop — no further steps, writing up findings so far`.
context events render into the collapsed Earlier in this thread block between the Request and the This run heading via the markdown guard; a seeded page feeds its escaped JSON seed through the one fold; hostile seeded text opens no tag; no seed → placeholder kept[unit] web/src/lib/runPageModel.test.ts::request / context / reply / placeholder::collects context turns outside the log, web/src/pages/runPage.test.ts::RunPage — history mode::collects context turns into the collapsed Earlier-in-this-thread block with a count, ::renders hostile model text as data, never markup, src/channels/webShell.test.ts::serializeSeed::*
The Request block shows https://github.com/o/r/pull/1 please review & fix @user in #general (markdownLite links the url), not raw <https://…|…> mrkdwn or &amp;; context turns are humanized the same way; the answer is never unescaped[unit] src/core/dispatcher.test.ts::input / context / answer events in the run stream …::input and context text is humanized before publish …, ::\<url…label>`: Slack's auto-link label … collapses to the full url …, ::the model's own answer is never entity-unescaped …`
Index empty-state sentinel hidden while rows exist[unit] web/src/pages/runsIndex.test.ts::RunsIndexPage — toolbar, states, pager::shows the empty sentinel per view, and the store-degraded banner when the seed carries one, ::RunsIndexPage — the live feed::default view: a finished upsert removes the row; the empty sentinel returns when the last row leaves
Runner emits assistant only for text alongside tool_use, before that turn's tool_call; never for a tool-less turn or the final text-only answer; redacted, uncapped[unit] src/runner.test.ts::assistant text turns in the event stream::* (red-verified: no emit → 2 fail; no redaction → 1 fails)
Dispatcher publishes input (directive-stripped text + attachment suffix, redacted, at) directly after create(), before any tool event; attachmentSuffix counts with singular/plural, empty when none[unit] src/core/dispatcher.test.ts::live run-view wiring (Area 2)::publishes the request as a redacted \input` event…, ::registers the run, publishes its events, and finishes it(order nowinput, tool_call, …), src/core/dispatch/reply.test.ts::attachmentSuffix:😗(red-verified: publishingmsg.text` unredacted fails)
Status card shows an assistant turn as a one-line 💬 excerpt capped at 80 chars, never the full text[unit] src/core/dispatcher.test.ts::live run-view wiring (Area 2)::shows an \assistant` turn on the status card as a one-line 💬 excerpt…`
Markdown renderer: each construct (paragraphs, headings, fences incl. unterminated, bullet/ordered/nested lists, quotes, bold/italic/code, http(s) links with rel); nesting; unknown/unbalanced syntax → text; empty input → nothing[unit] src/channels/markdownLite.test.ts::renderMarkdownInto — blocks::*, ::renderMarkdownInto — inlines::*
GFM table subset: header + |---| + rows → table/thead/tbody/tr/th/td, cells through the inline renderer, alignment colons accepted; a pipe row without a separator (or a bare ---) stays text; header-only table → no tbody; a table directly after a paragraph line starts a table; markup in cells stays text (no element, no anchor); the page styles .md tables (the typography classes on MarkdownText.vue)[unit] src/channels/markdownLite.test.ts::renderMarkdownInto — blocks::renders a GFM table…, ::table cells run through the inline renderer…, ::a pipe row with no …---… separator…, ::a table directly after a paragraph line…, ::a header-only table…, ::renderMarkdownInto — safety contract::markup in table cells stays text… (the table styling itself rides MarkdownText.vue's prose classes — no dedicated test)
########## headings render as h4–h6, never paragraph text; seven+ markers degrade to text; markup in h4–h6 text stays text; a bare #### marker degrades to text (progress guarantee)[unit] src/channels/markdownLite.test.ts::renderMarkdownInto — blocks::renders ####/#####/###### headings…, ::renderMarkdownInto — safety contract::markup in h4–h6 heading text stays text…, ::a bare …`#### ` marker…
The Waiting for activity… placeholder clears on ANY first painted change — input and answer included[unit] web/src/lib/runPageModel.test.ts::request / context / reply / placeholder::paints the request with its source, clears the placeholder on the FIRST change of any kind
Renderer robustness: a bare # /## marker line terminates and degrades to text (the parser always consumes a line — no browser hang); 50,000 nested > neither throw nor drop the text (depth capped at 8)[unit] src/channels/markdownLite.test.ts::renderMarkdownInto — safety contract::a heading marker with no content…, ::deeply nested quotes are bounded…
Every markdown surface on the page renders through ONE guarded component (MarkdownText.vue: try/catch → textContent fallback); the renderer is never called directly by the surfaces[unit] web/src/pages/runPage.test.ts::RunPage — history mode::renders hostile model text as data, never markup (the guard path is MarkdownText.vue, the one caller of renderMarkdownInto — the fallback branch itself has no dedicated test)
Markdown safety: <img onerror> is text (no element), javascript:/data:/vbscript:/protocol-relative/ftp: links are plain text (no anchor), href set verbatim via setAttribute (no attribute breakout), </script> in a code block is text, re-render replaces[unit] src/channels/markdownLite.test.ts::renderMarkdownInto — safety contract::* (red-verified: dropping the https?:// gate fails 2)
Renderer stays self-contained: String(fn) is a plain function with no import/require (it bundles cleanly into the web app with no second copy)[unit] src/channels/markdownLite.test.ts::renderMarkdownInto — inlinable into the run page::*
Run page routes Request/Reply/assistant through the shared renderer (MarkdownText.vue); Request block above the log, Reply below, captioned by what it is; every row/block gets its timestamp (empty when at is absent); tool rows monospace, markdown proportional; no raw markup[unit] web/src/pages/runPage.test.ts::RunPage — history mode::seeds the whole record through the ONE fold: request (with source), steps, cards, reply; no stream, no stop controls, web/src/lib/runPageModel.test.ts::request / context / reply / placeholder::*
Friction consumers ignore/accept the narrative events: analyzer findings identical with or without input/assistant, toolCalls unchanged; CLI parser accepts input/assistant/answer with string text, skips malformed[unit] src/core/runFriction.test.ts::analyzeRunFriction — empty / untimed input::ignores the timeline events…, src/core/runEventLines.test.ts::parseRunEventLines::accepts the timeline events…
Live: open a run page during a real run — the Request block shows the ask (attachments noted), the model's prose appears between tool rows with timestamps, markdown in the Answer renders (headings/lists/code/links), a <script>/javascript: in model text stays literal, no console errors; screenshots before/after[agent] Ask a general/review run something that makes the model narrate between tool calls and answer with headings, bullets, fenced code, a blockquote and an https link; then send two follow-ups in the thread. Expect: the Request block carries a [HH:MM:SS] stamp, every row is stamped, the narration appears as assistant rows between → $ … / ✓ bash: … pairs, the Answer renders each construct, header ends finished; each follow-up mention becomes its own run with sticky thread context; a finished run's page answers run not found once the 60 s TTL has passed (item 4, by design). XSS payloads are [unit]-covered and need not be replayed live.
Timeline model — grouping: leading un-narrated step; assistant opens a step and following calls join it; result pairs by callId out of order; a result with no id, or whose call was trimmed → its own finished call; input/answer/run_note pass through; unknown/malformed ignored[unit] src/channels/runTimeline.test.ts::createRunTimeline — grouping::*
Timeline model — cards: shell title without $ , non-shell title without the tool prefix; update_status quiet; status ok/failed/infra; facts (exit code, N lines from the summary's size note else the output, duration ms/s/m s; error/sandbox error; no exit fact for non-shell); no duration when a timestamp is missing or the clock ran backwards; pending() is the oldest running call[unit] ::createRunTimeline — call cards::* (red-verified: pending() scanned newest-first before the fix)
Timeline model — classification: tests/build/install/git/network/read/shell from the command after the cd hops, most specific wins; tool name for non-shell; failed/infra appended by the result; headline = first line without cd hops + , a bare cd kept[unit] ::createRunTimeline — classification::*
Timeline model stays self-contained: plain function, no import/require/DOM/innerHTML, re-evaluates via new Function with identical behavior[unit] ::createRunTimeline — inlinable into the run page::*
Page: every frame goes through the ONE fold (createRunPageModel over createRunTimeline); Request above / Reply below the log; narration is prose on one left edge; timestamps empty when at is absent; commands monospace, markdown proportional[unit] web/src/pages/runPage.test.ts::RunPage — history mode::seeds the whole record through the ONE fold…, ::RunPage — live mode::opens the token-scoped stream, reads \running` once connected, and folds live frames through the same path`
Page cards: header = spinner/✓/✗/⚠ + $/tool chip + headline (ellipsis when collapsed, full command open) + facts (first fact red when not ok); body = output / no output / running…; no raw markup[unit] web/src/lib/runPageModel.test.ts::calls, groups, folding::cards carry the classification: shell $, headline vs full title, facts, status transitions, web/src/pages/runPage.test.ts::RunPage — history mode::groups a step's cards from the 2nd on under a tally bar; failed cards open by default, clean ones collapsed
Page open-by-default: OPEN_BY_DEFAULT = ["failed","infra"], ?open= override incl. all, applied at creation (replayed backlog) and when the result lands; Expand all / Collapse all toggle applies to later cards; update_status one muted line[unit] web/src/lib/runPageModel.test.ts::calls, groups, folding::failed and infra calls open by default; ok calls stay collapsed; ?open= overrides; open=all opens everything, ::expand all opens every card (later results keep it), collapse all closes them; new cards while expanded start open, ::update_status renders as ONE quiet row, never a card — its result must not duplicate it
No tail row: a running card ticks its elapsed from its own start and settles into its duration fact; a silent model is the pending-turn row (∿ pulse, the run's model by name as the badge with the full ref on hover, a verb derived from the silence's length so each silence starts at Thinking, elapsed amber past a minute) that the real step replaces — a turn on a different model carries a ⇄ <model> chip in its head and the badge names the new model, a same-model turn carries none; the live page's empty state is the placeholder; the header keeps the whole-run stopwatch; a replay notice restarts nothing; all gone at end[unit] web/src/pages/runPage.test.ts::RunPage — live mode::in-progress work draws where it will end up: a running card ticks its elapsed from its own start, a silent model is a pending-turn row (pulse, model badge, rotating verb) timed from the last stamped event, and the header keeps the whole run's stopwatch
Page Request source: #channel · user · open thread ↗; only http(s) URLs become anchors, rel="noopener noreferrer"[unit] web/src/lib/runPageModel.test.ts::request / context / reply / placeholder::paints the request with its source…, web/src/pages/runPage.test.ts::RunPage — history mode::seeds the whole record through the ONE fold: request (with source)…
Page has bottom room (the last row never sits on the viewport edge); there is no inline script at all — the only <script> elements are the JSON island and the module reference[unit] src/channels/webShell.test.ts::renderShell::has NO inline executable script — the only script elements are the JSON island and the module src
Autoscroll only when the viewer was at the tail, sampled once per event before additions; MarkdownText.vue is the only caller of the renderer[unit] web/src/pages/runPage.test.ts::RunPage — history mode::renders hostile model text as data, never markup (renderer routing); the autoscroll sampling lives in web/src/pages/RunPage.vue — no dedicated unit test
Slack permalink: <team url>/archives/<C>/p<ts sans dot> (+ ?thread_ts=…&cid=… for a reply); adapter passes it as IncomingMessage.sourceUrl from the cached auth.test URL[unit] src/channels/slack/lookups.test.ts::slackPermalink…::*
Dispatcher puts source (url/channel/user, only the fields present) on the input event[unit] src/core/dispatcher.test.ts::live run-view wiring (Area 2)::publishes the request as a redacted \input` event…`
Live: open a run page during a real run — calls appear as collapsed cards with a spinner, settle to ✓/✗ with exit code, line count and duration; a nonzero exit reads ✗ exit N in red and is open by default with its output; clicking a header expands the full command and output; Expand all opens every card; a running card's elapsed ticks and settles into its duration, a silent model shows the pending-turn row (∿ · <model name> · <verb>…), both gone at finished; the Request header links to the Slack thread; ?open=tests opens the test runs[agent] Compare against a replay of the same event stream on the previous page build if the grouping looks wrong.
Scheduled panel model: every registry schedule listed with kind/cron/command/identity and a computed next fire; last firing attached with fired-at, outcome, run id; run link carries the token only while the run is live, else bare /runs/<id>; firings without a run have no link; retired/unknown schedules ignored; unavailable history → no last; unparseable cron → no next fire; hostile run id URL-encoded[unit] src/channels/scheduledPanel.test.ts::buildScheduledRows::*
Scheduled panel: a firing recorded with a trace id shows trace <8 chars> with the full id on hover, and no trace cell otherwise (tracing.md item 22)[unit] src/channels/scheduledPanel.test.ts::buildScheduledRows::carries the firing's trace id…, web/src/pages/scheduled.test.ts::ScheduledPage::shows a bare run id (no link) when the firing's run is not live…
Scheduled page render: one block per schedule, UTC times + relative hints, plain-English outcome with ok/warn/bad tone, run link, never fired / unknown + a reason note when history is unavailable, hostile strings as text[unit] web/src/pages/scheduled.test.ts::ScheduledPage::*, src/channels/scheduledPanel.test.ts::outcome vocabulary (shared with the web page)::*, ::time helpers::*
Scheduled tab handler: with scheduled configured the tab's seed is built from the store's latest() (a store failure or no store → the unavailable reason, never a fake "never fired"); without it the seed says no registry is configured (200, not 404); the runs index seed carries no schedule rows[unit] src/channels/liveView.test.ts::scheduled tab — GET /runs/scheduled …::*
Schedule registry ⇄ wrangler.jsonc per worker: schedulesFor(worker) cron set equals that Worker's triggers.crons (bot + resident); unique names, crons unique within a worker; keep-alive is internal + healthz, resident-watchdog is a visible watchdog; scheduleForCron is per worker; nextFire handles lists/ranges/steps, dow 7, the dom/dow OR rule, never-firing dates and year boundaries; malformed expressions rejected[unit] src/core/schedules.test.ts::schedule registry::*, ::cron evaluation (nextFire, UTC)::*
Resident watchdog firing record: counts re-armed / timed-out / errored residents, completed on a clean pass, failed naming the first errored resident or the thrown error, detail capped[unit] src/core/schedules.test.ts::watchdogFiring (the resident's firing record)::*
recordFiring (shared by both shims): fail-closed without STATE_WORKER_URL/MEMORY_TOKEN; POSTs {firing} to <url>/schedules/record with the bearer; non-2xx and thrown fetches are returned, never thrown[unit] src/core/schedules.test.ts::recordFiring (shared by both shims)::*
Panel hides internal schedules and lists the rest with worker + action; a non-run firing attaches without a run link[unit] src/channels/scheduledPanel.test.ts::buildScheduledRows::lists every non-internal schedule…, ::a firing for a non-run schedule…, web/src/pages/scheduled.test.ts::ScheduledPage::renders one block per schedule…, ::labels non-run actions…; src/channels/liveView.test.ts::scheduled tab…::seeds the rows from the registry + the store's latest firings, linking a live run with its token; internal plumbing stays off
ScheduleStore seam: in-memory (newest per schedule, copies, concurrent firings kept) and Worker client (/schedules/record + /schedules/latest with the bearer and a 10 s timeout; non-2xx / non-JSON / no firings throw; foreign rows dropped); buildScheduleStore selects the Worker store or warns naming what is missing[unit] src/core/scheduleStore.test.ts::*
SSE frames carry id: <seq>; a reconnect with Last-Event-ID: N replays only events after N and live-forwards from there[unit] src/channels/liveView.test.ts::serveEvents (SSE, transport-free)::sets the text/event-stream headers…, ::a reconnect with Last-Event-ID replays only the events after that position…
Cursor + budget compose: Last-Event-ID: 500 on a 3000-event run → replay_elided 501–1000 then id: 1001…3000; a cursor inside the budget window → no frame, exactly the events after it; the page renders a replay_elided frame as a replay row and keeps the range, ignores a malformed one, exempts transport frames from its dedupe, and its production EventSource adapter hands named listeners the frame's data string[unit] src/channels/liveView.test.ts::serveEvents — live replay budget (item 5)::a resume cursor and the budget compose…, web/src/pages/runPage.test.ts::RunPage — live mode::dedupes replayed frames by SSE id (a stripped Last-Event-ID must not double the log); replay notes are exempt, web/src/pages/runPage.test.ts::RunPage — live mode::a replay_elided frame renders as a replay row…, web/src/lib/runPageModel.test.ts::replay_elided frames (item 5)::*, web/src/lib/eventSource.test.ts::wrapNativeEventSource — named listeners receive the frame's data string, never the MessageEvent::*
One JSON.stringify per event however many subscribers — the registry's byte accounting at publish and every SSE frame share the serializedOnce memo (src/core/runEvents.ts)[unit] src/channels/liveView.test.ts::serveEvents (SSE, transport-free)::serializes each event once… (the spy counts registry + k viewers → 1)
parseLastEventId: positive integer → resume point; absent/empty/garbage/negative/exponent → 0[unit] src/channels/liveView.test.ts::serveEvents (SSE, transport-free)::parseLastEventId…
The page's pending() (the live tail's "running" call) is a lookup over the un-resulted calls, not a rescan of every step[unit] src/channels/runTimeline.test.ts::…::pending() is the oldest call still running… (behavior; the structure is pendingCalls in createRunTimeline)
ScheduleDO: record → latest newest-per-schedule round trip; firings without a run; same-instant firings both kept; bounded at 100 per schedule; 400 on malformed/oversize; 401 without bearer; 405 on GET[unit] deploy/cloudflare-memory/schedules.test.ts::* (inside workerd, real SQLite DO)
Live: /runs behind Access shows the Scheduled panel with self-improvement (friction propose as cron, worker bot) and resident-watchdog (not a run, worker resident) — and NO keep-alive row — a next fire in UTC, and after a firing the fire time, outcome, and a run link that opens the run's page while it is live[agent] Open <PUBLIC_BASE_URL>/runs; after a scheduled firing (see self-improvement.md 7c's [agent] row) reload and read the self-improvement row: fired-at ≈ the cron minute, completed, run <id> linked; while the run is live the link carries ?t= and streams.
History page: tokenless persisted run → 200 with a history-mode seed carrying the record's request/context/tool/reply events, status and duration; the page opens no EventSource in history mode and hides the stop controls; stopped/failed labels; a finished run still in the registry is served the same way; no ?t=/token anywhere in it; a persisted </script> message stays \u003c-escaped in the seed island (AE9); one audit line per page/events read with route, run id and the viewer's actor id, never content[unit] src/channels/liveView.test.ts::live view on RunsService: history pages + index toggle …::persisted run page (history mode)::*, web/src/pages/runPage.test.ts::RunPage — history mode::seeds the whole record through the ONE fold: request (with source), steps, cards, reply; no stream, no stop controls
AE11: withOmittedMarkers places an N records omitted note (records — the count includes span records) at every seq gap with that gap's own count, plus one at the tail for the remainder (start / middle / cut tail; none when complete; counts sum to published − stored); the page seeds it in place and the events replay carries it[unit] ::AE11: truncated records::*
Tokenless /runs/:id/events replays 1200 stored events in seq order across several service pages, prelude first, end last; /runs/:id/friction returns the stored diagnosis; tokenless stop → 409 persisted / 404 live (control untouched); a valid token still stops (200) and 409s a finished run[unit] ::persisted run events, friction and stop::*
Unknown, expired (31 days), wrong-token-on-live, and live-without-token all answer 404 run not found on page, events and friction; with history off an evicted run is the same 404[unit] ::404 shapes …::*
Actor binding: /runs?all=1 lists an unlisted browser session only the public runs, a native channel grant adds that channel, an admin the fleet, the actor's predicate handed to listRuns; the default /runs and the ?stream=1 feed carry only the live runs the viewer may read — a hidden run's row, token, upserts and removed never reach the page; a tokenless finished run the viewer may not read is the same 404 as an unknown id on the page (byte-identical), events, friction and the stop's 409, with the reason on the audit line ({ route, identity, denied: "not-member" }, no run id) and never in the reply; a viewer holding no runs:read sees an empty index and 404s even on a public run while a capability token still opens the live page, stream and stop; the Scheduled tab links a live firing with its token only for a viewer who may read it[unit] ::the viewer's actor binds the index and the tokenless history routes (authorization.md items 5–7)::*, src/channels/liveView.test.ts::scheduled tab — GET /runs/scheduled …::links a live firing with its token only for a viewer who may read that run…
Default index seeds only unfinished runs and never calls store.list/store.get (spies); toggle Show all/runs?all=1 with the truthful retention tooltip; ?all=1 seeds live rows (with token) + finished registry + persisted rows (tokenless) with status, duration and finished-at, all: true and the /runs?stream=1&all=1 feed; Run history is off… with retentionDays: null; a hostile persisted label is inert (AE9); the persisted flag rides the seed for a store-confirmed row; exactly one list call[unit] ::index: active by default, everything with ?all=1::* (red-verified: letting finished rows keep their token fails the no-token assertions)
ONE component renders every row — seeded and feed-repainted alike (the server renders no rows, so the old server/client mirror holds by construction); finished rows never carry a token, live rows do; feedAction drops a finished upsert in the default view, keeps it in ?all=1, and suppresses removed only for a persisted row there; the page routes every frame through it[unit] web/src/lib/indexRow.test.ts::hrefs::a live row links with its capability token; a finished row never does, even while the registry still holds one, ::feed reconciliation::*, web/src/pages/runsIndex.test.ts::RunsIndexPage — the live feed::*, src/channels/liveView.test.ts::IndexRow seed shape::accepts a live registry summary (with token) and a store view (without)
The handler carries no reachability rule of its own — who may read at all was decided by the dashboard auth strategy before it ran (the none strategy's loopback rule lives there, access-gate.md item 9); the handler decides only WHICH runs the admitted actor sees[unit] src/channels/dashboardAuth.test.ts::loopbackVerifier…::*; the actor-binding rows above
The site nav follows the capabilities the seed carries (item 18): Runs always; Residents only with residents, Costs only with costs; the section the viewer is on stays listed; no seed → Runs alone; hrefs stay clean[unit] web/src/components/AppNav.test.ts::navSections — which sections exist::*, web/src/components/AppNav.test.ts::AppNav::*
The shell's docs link and its phone-menu group are always there — no capability gates the project's site; the menu's sections are the nav's; the minimal installation's header is Runs, the docs link, the theme toggle and the menu[unit] web/src/components/AppNav.test.ts::AppShell::*
The runs page's tabs follow schedules: Runs · Scheduled with firing history configured, Runs alone without it — and a lone tab draws no bar; a viewer on /runs/scheduled keeps both tabs whatever the capability[unit] web/src/components/runs/RunsTabs.test.ts::runsTabs — which tabs exist::*, web/src/components/runs/RunsTabs.test.ts::RunsTabs::*
?all=1 asks listRuns({ status:"all", limit: INDEX_PAGE_SIZE }); a full page seeds olderHref with the service cursor and following it yields the next page (no live rows, no further link at the end); a short page has no link; a malformed cursor is ignored[unit] ::index: active by default, everything with ?all=1::?all=1 asks the service for one index page (INDEX_PAGE_SIZE), never the 50-row default, ::a full page seeds an \olderHref` carrying the service's cursor; following it yields the next page with `olderThan`, ::a short page has no older link; a malformed cursor is ignored (first page)`
A finished row's finishedAt/status ride the seed; a ?all=1 RunSummary upsert repainted through mergeRow keeps status/duration/dot, a live row's summary paints as-is, and the page's upsert uses that merge[unit] web/src/lib/indexRow.test.ts::feed reconciliation::a repaint merges the kept record fields under an incoming summary — it overrides only what it carries, web/src/pages/runsIndex.test.ts::RunsIndexPage — the live feed::?all=1: finished upserts stay; a repaint never wipes the record's finishedAt/status (merge under the summary), src/channels/liveView.test.ts::…::a persisted row's flag rides the seed; the store spy sees exactly one list call for ?all=1
The tokenless stored replay comes from ONE record read (store.get once, store.events never) for a 1,200-event run, in seq order, then end[unit] ::persisted run events, friction and stop::/runs/:id/events tokenless replays the stored stream in seq order from ONE record read…
Live: open a finished run's page > 60 s after completion behind Access — request, context, tool steps, reply and the grey finished · completed header render; /runs?all=1 lists it tokenless with duration and finished-at; the default /runs omits it[agent] Behind Access, wait until a run has been finished for more than 60 s (its registry TTL), then open /runs/<id> without ?t=: the page renders in history mode with the grey header; /runs?all=1 shows the row tokenless; /runs does not.
Index stopwatch: the one duration (runDurationMs): live rows painted from the seed's server clock and ticked client-side, finished rows fixed received (or start)→finish, no clock → empty cell; the dot and started tooltips add (received to finish) / a received … line together and only when the run carries receivedAt; formatDuration(ms, "clock") reads 38s/4m 12s/1h 03m, garbage → 0s (item 18)[unit] src/core/time/formatDuration.test.ts::formatDuration::clock: a ticking stopwatch reading with a fixed column width, web/src/lib/indexRow.test.ts::stopwatch::*, web/src/lib/indexRow.test.ts::tooltips::the tooltips switch to the received basis together, and only when the run carries receivedAt, src/core/runDuration.test.ts::runDurationMs::*, web/src/components/runs/RunRow.test.ts::shows the stopwatch (live ticks from now, finished fixed) and the event count in fixed columns
Index hierarchy: label → agent chip (allow-listed hue) · scope · snippet; unshaped labels render whole; the dot's hover carries status · started · finished; one component renders every row, so the old server/client mirror holds by construction (item 18)[unit] src/channels/indexFormat.test.ts::splitRunLabel, web/src/components/runs/RunRow.test.ts::renders the label as agent chip (hue allow-listed) · repo tag (name only, linked, slug on hover) · snippet, web/src/lib/indexRow.test.ts::agent hue allow-list::only the four built-in agents get a hue; anything else — hostile names included — the neutral chip, ::tooltips::the dot's tip…
Runs page shell: the tabs (Runs · Scheduled, the second with schedules on) with aria-current, N running count, the toggle with a hover/focus tooltip, the connection state beside the title on both pages (item 18)[unit] web/src/pages/runsIndex.test.ts::RunsIndexPage — toolbar, states, pager::counts the live rows, seeds the list newest-first, and opens the right feed, web/src/pages/scheduled.test.ts::ScheduledPage::marks the Scheduled tab current with Runs one click away, and Runs current in the site nav, web/src/pages/runsIndex.test.ts::RunsIndexPage — the live feed::connection indicator…; [human] on /runs: hovering "?" shows the retention sentence; the header reads Live runs ● connected
/runs/scheduled is the Scheduled tab (bare path only; trailing slash accepted); the index carries the switcher and no panel rows in its seed; the tab shares the shell and page headers, has no feed, is GET-only, and says "No schedule registry configured." without one (item 18)[unit] src/channels/liveView.test.ts::scheduled tab — GET /runs/scheduled …::*, web/src/pages/scheduled.test.ts::ScheduledPage::says so when no schedule registry is configured (rows null)
Run page: one step = one rail-bounded block whose head row is [when] 💭 <thought> <narration> … <tokens> (a turn with no step is flushed as its own head row); every row shares one column grid; a step's cards group under one tally row (timestamped by its first call, tally cells by name) — groups stay open by default and are never auto-folded; only a manual toggle closes one (and sticks), while running/failed forces it open (item 18, folding rule revised with the Vue port)[unit] web/src/lib/runPageModel.test.ts::steps and turns::*, ::calls, groups, folding::groups stay open as new steps begin — they are never auto-folded; only the viewer's toggle closes one, and it sticks, ::a step still running (or failed) forces its group open even after a manual close is superseded by new activity, web/src/pages/runPage.test.ts::RunPage — history mode::groups a step's cards from the 2nd on under a tally bar; failed cards open by default, clean ones collapsed; [human] before/after screenshots on the PR from a local replay
The header's one duration (createRunClock, item 22): received (or started) → the seed's server clock projected arrival-relative, frozen by finishedAt, never an event stamp; span records never move the runner clock or the stream's stamps[unit] web/src/lib/runPageModel.test.ts::header stopwatch (item 22)::*, web/src/pages/runPage.test.ts::RunPage — live mode::opens the token-scoped stream, reads …
One projected runner clock (runnerNow): a running call is timed from its own start (never amber), thinking from the last stamped event (amber past a minute), a quiet call is never the wait, an unstamped frame moves nothing; a running card = spinner + ticking elapsed fact + body no output yet; a history page's cards never tick (item 18)[unit] web/src/lib/runPageModel.test.ts::live wait — what the run is waiting on, and for how long::*, web/src/lib/runPageModel.test.ts::header stopwatch (item 22)::a frame without a runner stamp (a replay notice) never moves the clock — the stopwatch cannot reset on a reconnect, web/src/pages/runPage.test.ts::RunPage — live mode::a running card: the spinner, a ticking elapsed in the facts slot, and a body that says no output yet — never the word running, web/src/pages/runPage.test.ts::RunPage — live mode::a history page never ticks: a record's un-resulted call shows no elapsed, web/src/pages/runPage.test.ts::RunPage — live mode::the model badge is worn once, by the run's first head (a record from before per-turn stamps names its run_meta model there), and again only where the model switches — the ⇄ chip; the heads between stay quiet; nothing known → no badge
Slack bold: humanizeMessageText maps *bold***bold** on word edges only; globs, arithmetic and code untouched; HTTP/MCP text untouched (item 18)[unit] src/core/dispatcher.test.ts::…::humanizing maps mrkdwn bold to Markdown…, ::humanizing is Slack-only…
Scheduled panel: firing detail = the reply's first non-empty line; the self-improvement head carries the filed/already-open tally; two flowing lines per schedule with LAST outcome · ago · run · facts, title/emoji dropped, long detail cut with full text on hover (item 18)[unit] src/core/schedules.test.ts::interpretIngressResponse…::200 with a run receipt… (multi-line reply), src/core/selfImprovement.test.ts::formatSelfImprovementReport::renders runs analyzed… (head line), src/core/commands/friction.test.ts (golden replies carry the tally), src/channels/scheduledPanel.test.ts::firingDetailSummary::*, web/src/pages/scheduled.test.ts::ScheduledPage::shows the last firing: outcome word ('succeeded'), relative time with the exact local time on hover, a token'd run link, the detail's facts
Gutter layout: step timestamp in a fixed gutter padded off the rail (short clock, ISO on hover), chip + narration head, token facts on their own dotted line, tally as a bordered bar, no card timestamps (hover title), fold toggle above the log with a flipping icon, connection mark, header band (item 19)[unit] web/src/lib/runPageModel.test.ts::steps and turns::a turn is held for the step it produced and painted in that step's head (chip reads the bare duration), web/src/pages/runPage.test.ts::RunPage — history mode::the fold toggle opens every card (and future ones), then closes them; icon state flips, ::RunPage — live mode::opens the token-scoped stream, reads \running` once connected…; [human]` before/after screenshots on the PR
run_meta published once per run right after input with agent/model and the resolved repo/ref/pr/headSha; folded to meta keeping only well-typed fields; accepted by the capture parser; ignored by friction; rendered as linked owner/repo · ref · #PR · sha under the request (item 19)[unit] src/core/dispatcher.test.ts::live run-view wiring (Area 2)::registers the run, publishes its events, and finishes it (order + payload), src/channels/runTimeline.test.ts::createRunTimeline — run_meta (item 19), src/core/runEventLines.test.ts::accepts the timeline events… (run_meta case), web/src/pages/runPage.test.ts::RunPage — history mode::shows the run meta line: agent · model · effort · linked repo · the branch linked to its tree · the head sha linked to its commit · GitHub-marked PR; a hostile repo never links
Run page 404 is a page: identical for unknown / expired / wrong token / tokenless live, retention sentence + ← All runs, nothing echoed, CSP headers; /events /friction keep the text body (item 19)[unit] src/channels/liveView.test.ts::…::404 shapes …::*, web/src/pages/notFound.test.ts::NotFoundPage (run 404, item 19)::*
Show completed is a checkbox (checked on ?all=1) that navigates on change; the retention note rides a UTooltip with a screen-reader copy (item 20)[unit] web/src/pages/runsIndex.test.ts::RunsIndexPage — toolbar, states, pager::the Show completed checkbox reflects the view and navigates on change (a server mode, not a client filter), src/channels/liveView.test.ts::…::the default view seeds only unfinished runs and never calls the store, ::the retention days ride the seed truthfully with run history off
Started column: GitHub-style relative time from now, ticked every minute, exact stamps on its tooltip; formatRelative shapes (item 20)[unit] src/channels/indexFormat.test.ts::formatRelative, web/src/lib/indexRow.test.ts::tooltips::the started column's tip: exact local stamps, one per line
RunSummary.activity = latest narration / tool call / answering, one line, capped, absent before the first; forwarded via RunView; the dot's tooltip reads now: … / starting… / <status> in <duration>; no native titles on the row (item 20)[unit] src/core/runRegistry/activity.test.ts::RunRegistry — \activity` on the summary (live-view item 20), web/src/lib/indexRow.test.ts::tooltips::the dot's tip: a live run's activity (or starting…); a finished run's outcome + duration, plus the last activity when it did not complete, src/channels/liveView.test.ts::…::?all=1 seeds live rows…`
Tooltip component: informational tips ride UTooltip (Reka UI floating layer: hover + focus, placement/flip/clamp), text only; not shown on touch — the mobile row's menu carries the thread link and Stop/Kill (item 20)[unit] web/src/lib/indexRow.test.ts::tooltips::* (tip content); [human] hover a dot, the started column and the ? on /runs — the tip follows, flips above near the bottom edge, never clips
Pager: 25 rows per ?all=1 page; Older runs → when full, ← Newest runs + what the page holds on a cursor page (finished rows only there); none on the default view (items 20/21)[unit] src/channels/liveView.test.ts::…::a full page seeds an \olderHref`…, ::a cursor page holds finished runs only…, ::?all=1 asks the service for one index page (INDEX_PAGE_SIZE)…, web/src/pages/runsIndex.test.ts::RunsIndexPage — toolbar, states, pager:📟 Older runs when the page was full; ← Newest runs + what the page holds on a cursor page; nothing on the default view, ::RunsIndexPage — the live feed::a cursor page only repaints rows it already has — a run starting now belongs on the newest page`
Outcome badge on non-completed finished rows (failed/killed red, stopped early amber); the dot's hover adds the last activity — a failed inline run's ⚠️ reply — and activity is persisted on the record; the same words on the run page header, the Scheduled tab and a finished summary's stop badge (item 21)[unit] web/src/components/runs/RunRow.test.ts::shows the outcome badge for a finished run that did not succeed — failed/killed red, stopped early amber; none when it succeeded, web/src/lib/indexRow.test.ts::tooltips::the dot's tip…, web/src/components/runs/RunRow.test.ts::shows the stop badge while a stop is in flight, and as the outcome for a finished summary with no record status; the record's status wins, src/core/runRegistry/activity.test.ts::RunRegistry — \activity`…, src/core/dispatcher.test.ts(record carriesactivity/sourceUrl), src/channels/liveView.test.ts::…::carries the record's terminal status for stopped and failed runs (the page renders the outcome chip from it)`
Source mark: standard trigger metadata (platform prefix + resolved name, id suffix fallback) with a one-line via <Surface> · <identity> hover, revealed on row hover/focus; with sourceUrl it is the ↗ open-in-new-page control (pointer + hover state, new tab) (item 21)[unit] web/src/components/runs/RunRow.test.ts::the source mark is the ↗ link for a run with a thread, the surface glyph otherwise; a javascript: url never links, web/src/lib/indexRow.test.ts::surface + repo + sourceUrl::derives the surface from the channel id's platform prefix, ::tooltips::the source tip: via <surface> · <resolved identity>, falling back to the id suffix; [human] hover a Slack-started row on prod /runs?all=1 → the ⁙ appears, its hover names the surface and user, clicking it opens the thread
Repo tag: name only, linked to GitHub, full slug on hover; from RunView.repo or an owner/repo label scope; chat scopes and hostile shapes never link (item 21)[unit] web/src/components/runs/RunRow.test.ts::renders the label as agent chip (hue allow-listed) · repo tag (name only, linked, slug on hover) · snippet, ::a chat scope stays a scope (no repo link); a hostile repo-shaped label never links, web/src/lib/indexRow.test.ts::surface + repo + sourceUrl::repo comes from RunView.repo or a repo-shaped label scope; hostile shapes never qualify
Run header: outcome chips with the index's colors — ✓ + duration for success, red failed/killed, amber stopped early, grey ended for status-less records; no pulse on history pages; succeeded is the display word everywhere (index tips, Scheduled tab); live header reads running · <stopwatch>; live end shows only what the page can know (item 22)[unit] web/src/pages/runPage.test.ts::RunPage — history mode::heads with the outcome chip + duration (item 22): ✓ for success, red failed/killed, amber stopped early, grey ended for a status-less record, ::RunPage — live mode::at \end`: the outcome chip it can know (grey `ended`, never a guessed success), the duration, actions hidden, stream closed, web/src/lib/indexRow.test.ts::status vocabulary::, src/channels/liveView.test.ts::…::200s tokenless…, src/channels/scheduledPanel.test.ts::outcome vocabulary (shared with the web page)::, web/src/pages/scheduled.test.ts::ScheduledPage::shows the last firing: outcome word ('succeeded')…`
One grid: stretched row link under a body with its own links/buttons/tooltip cells; tooltip-cell clicks routed to the row; actions cell always present at a fixed min-width (the buttons can never be overrun); gone <when> in the outcome column in human form (formatDateTime); no native titles; divider is one dashed line; outbound links open a new tab; snippet cap 100 / label cap 160 (item 21)[unit] web/src/components/runs/RunRow.test.ts::is a stretched link…, ::a click on a tooltip cell (not a link or button) goes where the row goes, ::the actions cell is always present (fixed width); the buttons appear only while the run is stoppable, ::marks a leaving row and says when it is removed, src/channels/indexFormat.test.ts::formatDateTime…, src/core/dispatch/reply.test.ts::composeRunLabel::truncates a long snippet…, ::caps the overall label…; [human] on prod /runs?all=1 with one run live: the elapsed/event columns of the live row and the finished rows line up; clicking a dot opens the run; the repo tag opens GitHub
Expiry divider: with a known retention, rows leaving within a day sit under one ⏳ cut with gone <when> (exact time on hover) and an expiry stamp; re-placed on every change and each minute; none with history off (item 20)[unit] web/src/pages/runsIndex.test.ts::RunsIndexPage — expiry divider (item 20)::*, web/src/lib/indexRow.test.ts::expiry::expiresAt = finishedAt + retention; nothing without a retention or for live rows; [human] on /runs?all=1 in prod once the oldest persisted runs reach day 29 of the 30-day retention: the cut appears above them with their removal times
Run page header carries the total duration once finished (history from the record, live from the event stamps); no commentary head; tool-name-only calls show the chip alone (item 20)[unit] web/src/lib/runPageModel.test.ts::header stopwatch (item 22)::*, ::steps and turns::a turn is held for the step it produced and painted in that step's head (chip reads the bare duration), src/channels/liveView.test.ts::…::persisted run page (history mode)::*
Duration heat scale (item 24): quiet below the kind's floor (level 0, no colour); monotonic and saturating at the ceiling; the friction thresholds (30 s tool, 60 s turn) are warm; the paint is one scalar (--heat-t) for levels 1–3 and nothing when quiet or over budget; over budget is level 4 and categorical; 124 is the only timed-out exit (137 is not)[unit] web/src/lib/durationTone.test.ts::durationTone::*
The fold carries the paint's inputs: a call's exitCode and timedOut (124 only), a turn's durationMs (item 24)[unit] web/src/lib/runPageModel.test.ts::calls, groups, folding::a call carries its exit code and reads timed out on 124 only — a SIGKILL 137 is a failure, not a timeout (item 24), ::steps and turns::a turn is held for the step it produced and painted in that step's head (chip reads the bare duration)
Call card: the duration fact is painted by heat — a 15-minute command reads hot (data-heat="3", .heat with an inline --heat-t), a 200 ms one inherits (data-heat="0", no paint); a timed-out command (exit 124) is data-heat="4" with a timed out label and a red bold duration; the group tally goes over with it; a timed-out call with no duration fact still carries the label, and an exit 137 is a plain failure (item 24)[unit] web/src/pages/runPage.test.ts::RunPage — history mode::a slow call's duration reads warm and a timed-out one reads over budget: heat on the card, the label, and the group tally (item 24), ::RunPage — history mode::a timed-out call with no computable span still wears the label; a SIGKILL 137 is a plain failure (item 24)
Step head: the thought … chip is painted on the turn scale — a 5-minute turn carries heat and a --heat-t paint, a 5 s turn is plain meta with none (item 24)[unit] web/src/pages/runPage.test.ts::RunPage — history mode::the step head is ONE row — thought duration + token facts left, the 12-hour clock right; the prose sits flush under it, no chip, no gutter, ::RunPage — history mode::a sub-minute think reads as plain meta, not a warning
Runs index: a finished row's stopwatch is painted on the run scale — a 40-minute run carries heat, a sub-minute run inherits, a live row stays green and unpainted (item 24)[unit] web/src/components/runs/RunRow.test.ts::a finished row's stopwatch reads warm when the run was long; a short run and a live row are unpainted (item 24)

The runs index header links across to the residents dash (/residents, resident-repos.md item 42) when resident environments are configured; it sits behind the same dashboard gate and links back. | Timeline (item 25): the lede closes to the header's total on the finished, live, truncated, elided and no-root fixtures, its items summing to it; the bar is the same numbers with the open bucket's tail hatched; the drill-down is a subset and absent under an uncounted open span; currently delivering and the delivery caption follow the phase; the ranked list is own time with display-table labels and whitelisted facts, never a raw span name; below the gate the total and the dominant word; a command run's run.command is tools; raw names only in debug | [unit] web/src/lib/timelineVm.test.ts::buildTimeline::* | | Phase heads and tail (item 25): slack.receive, dispatch.* and the attach's grafts fold under a Getting ready head with their count and, once all ended, their span; it is open while the run sets up, closes when the agent loop starts unless the reader toggled it, and never takes a run.* row; the finishing-up steps fold under a Finishing up head that closes when delivery begins or the run ends; the request and run.agent spans draw no row; the tail row names the deepest open counted span, falling back to the rotating verbs before any span | [unit] web/src/lib/runPageModel.test.ts::span rows::setup spans — slack.receive, dispatch.* and the attach's grafts…, web/src/lib/runPageModel.test.ts::span rows::the post-loop steps the bar counts as finishing up…, web/src/pages/runPage.test.ts::*::the setup spans fold under a Getting ready head…, web/src/pages/runPage.test.ts::*::the post-loop steps fold under a Finishing up head…, web/src/lib/timelineVm.test.ts::buildTimeline::live: the buckets over the elapsed window… | | One structure, one vocabulary (items 12, 18, 19, 25): the page is four named blocks — Request, Earlier in this thread, This run (heading with the step count and the text Expand all at the right edge, over the time card and the steps), Reply — under a facts bar (#runmeta, the run_meta line of item 19, first thing under the header with the Reading diff control at its right edge); a finished run's page (history mode) leads with its outcome — Request, Reply, then the work — while a live page keeps the Reply last, where it lands as it arrives (ReplyBlock, one component, data-position first|last); the Request folds to three lines (ExpandableText: clamp, fade to the surface, Show more / Show less, aria-expanded, a link in the prose never toggles, a short text shows no fold); the Reading diff control is a link-weight text button; the Reply's caption comes from the run's facts; a step's tool-call summary is a muted sentence per state; the model badge is worn once and on every switch; the Longest steps link to their rows; the branch and head sha link only when shape-verified | [unit] web/src/pages/runPage.test.ts::RunPage — history mode::the page is four named blocks in order…, ::the request folds to its first lines…, ::the Reply's caption says what the reply is…, ::groups a step's cards from the 2nd on under a tally bar…, web/src/components/ExpandableText.test.ts::ExpandableText::*, web/src/lib/runPageModel.test.ts::the Reply's caption — what the reply is, from the run's facts::*, ::a step's tool-call summary — the tally as a reader says it::*, ::the model badge — named once, and on every switch::*, ::reveal — a Longest-steps link opens what folds its row::*, web/src/lib/githubLinks.test.ts::githubLinks::*, web/src/lib/timelineVm.test.ts::buildTimeline::a tool step is named by its card's command… | | Timeline (item 25): the page model folds span records into the span set, keeps every frame for the loss intervals (a seq gap is lost, or not-loaded once a replay_elided range covers it) and bumps traceVersion per frame | [unit] web/src/lib/runPageModel.test.ts::span set and losses (the timeline's inputs)::* | | Timeline (item 25): a history page renders the record's shape, the delivery caption, the raw-events link and the debug copy; a record with no root shows the total and the missing-setup word; an untimed record renders its transcript and states no timing data; a truncated record reads (too large); a live page's lede follows the phases on the header's total with no raw-events link | [unit] web/src/pages/runPage.test.ts::RunPage — the timeline (item 25)::* | | Timeline (item 25): the history seed carries the record's truncated; the command-run owner is one discriminator | [unit] src/channels/liveView.test.ts::live view on RunsService: history pages + index toggle …::*, src/core/runOwner.test.ts::runOwnerOf::* |