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/dist — packaging.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 read — predicateFor(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, theIndexEventmodel, per-run label +startedAt+eventCount+stop, bounded backlog, TTL eviction, constant-time token gate,defaultRunRegistrysingleton);src/core/runRegistry/(the registry's parts as sibling modules:runControl.ts—RunControl, the per-run soft/hard stop + hardAbortSignal;activity.ts—activityOf/activityOfEvents, the one-line activity rule the summary and the record share;state.ts—RunState, 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— theIndexEventmodel,IndexSubscriber, andIndexFeed, 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:parseRunRouteincl. theindex,scheduledandstoproutes,parseStopMode,createLiveViewHandler— takes the boundshelland 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,serveEventswith theREPLAY_LIMITring,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 inweb/:web/src/pages/{RunsIndexPage,RunPage,ScheduledPage,NotFoundPage}.vue(+RunRoutePage.vue, which dispatches/runs/:idbetweenRunPageandNotFoundPageby the seed'spage),web/src/components/{AppShell,AppNav,ThemeToggle,StatusDot,MarkdownText,SlackMark,GithubMark}.vue(belowsmthe site nav + theme toggle collapse intoAppShell's hamburger menu;navSectionsinAppNav.vueis the one list of sections and the one rule for which exist),web/src/components/runs/{RunRow,RunsTabs,SourceMark}.vue(runsTabsinRunsTabs.vue, the same rule for the tabs),web/src/lib/capabilities.ts(useCapabilities— the seed'scapabilities, 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 isomorphicindexRowRenderer),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 whenPUBLIC_BASE_URLis set);src/core/dispatcher.ts(publishes events inonEvent, finishes in the run-loopfinally);src/core/types.ts(optionalchannelName/userNamedisplay hints onIncomingMessage);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/*, routesGET /runs+/runs?stream=1+/runs/:id+/runs/:id/events, sharingdefaultRunRegistrywith 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 withnpm testinweb/):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-onlynone— 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
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'sremoved).Live stream + one bounded backlog.
publish(id, event)stamps the event with a monotonic per-runseq, 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 readsregistry.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, theslack.receiveanddispatch.*span pairs, and themcp_unavailable/spans_droppednotes — published while the backlog holds nothing else are never trimmed, up toHEAD_BUDGET_BYTES(512 KiB), and are capped toMAX_EVENT_BYTESat publish so one giantcontextcannot spend the budget; head material after any other event, or past the budget, is ordinary.stepCountcounts the content events (span records excluded) besideeventCount, on the summary, the snapshot and the record. Theseqis the event's 1-based position in the run's stream (the registry's monotoniceventCountat publish — ONE counter, carried both on the event object and as the second argument to every subscriber); it is the SSEid: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 higherseq. A fresh subscribe (afterSeq0) 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.Constant-time token gate, 404 on failure.
subscribe/hascompare the presented token against the run's token withcrypto.timingSafeEqualover equal-length buffers (length guarded first). A wrong/missing token or unknown run yieldsnull/false→ the handlers answer 404 for both the page and the stream. The token is never logged.Finish, seal, TTL eviction. A run ends in two steps (tracing.md).
finish(id, status)— the agent stopped — stampsfinishedAt, sends every attached subscriber the named SSEfinishedframe ({ 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 (replyOktrue or false) or the run's branch was abandoned without one (absent) — stampssealedAt, detaches every subscriber with the terminalendframe ({ sealedAt, replyOk? }→ the page closes itsEventSource), upserts the index once, and returns aSealResult(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 —replyOktrue or false — through oneRunEndingper 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 theirendframes to the sockets. So a run has two index events at its end — finish's, then the seal's carryingsealedAt/replyOk— and the wire readsfinishedwhen the agent stops andendafter the reply. A viewer who opens the link after finish but within the TTL sees the full backlog,finished, thenend; one who opens it between finish and seal stays attached until the seal. The eviction TTL (default 60 s) runs fromsealedAt; a finished run left unsealed forUNSEALED_HOLD_MS(15 min — a reply that never settled) is sealed by the sweep with noreplyOk, 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.SSE handler.
GET /runs/:id/events?t=…setscontent-type: text/event-stream(+no-cache,x-accel-buffering: no), writes the 200 head, then flushes the replayed backlog and live events asid: <seq>\ndata: <json>frames; the terminalendevent closes the stream. A client disconnect unsubscribes. The backlog replay (synchronous duringsubscribe) 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 reportsreplayedand, when the budget left retained events out,elided: { fromSeq, toSeq }, the contiguousseqrange 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 noid:so it never moves a resuming client's cursor. It is distinct fromreplay_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 readingN events not loaded (a–b) — the record has themand keeps it onstate.elidedfor the partition'snot loadedterm (docs/reference/specs/tracing.md); a malformed frame marks nothing. Events the backlog itself dropped are not elided — they are aseqgap. A subscriber that must see every retained event (the run ledger, a store) passesREPLAY_EVERYTHING. Live frames after the replay are never capped;snapshotstill returns the whole backlog; the index feed is unchanged. Resumable: the browser'sEventSourcereconnects after a drop (proxy kill, deploy, laptop sleep —retry: 3000) sending the lastidasLast-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 noidand are exempt). Each event is JSON-serialized once per process however many viewers a run has (serializedOnce, aWeakMapover the event object the registry shares with every subscriber); index frames use the same memo and carry noid(the index has no resume semantics).The live run page.
GET /runs/:id?t=…serves the shared shell (strict CSP,no-store,noindex— the Rendering paragraph) with aRunLiveSeed—{ mode:"live", eventsUrl, stopUrl }, the capability token riding only inside those two URLs; the page (web/src/pages/RunPage.vue) opens the token-scopedEventSourceand renderstool_call→ a running row andtool_result→ ✓/✗ + summary, as text only (no raw markup anywhere). Both routes are GET-only (405 otherwise). The page also sendsframe-ancestors 'none'(CSP) +X-Frame-Options: DENY— this public surface cannot be iframed (clickjacking), whichdefault-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'sonopen/onerror/endstate.Graceful degradation. The dispatcher reads
PUBLIC_BASE_URLfrom 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.Runs index (Access-gated home page), live over SSE.
GET /runs(and/runs/) serves the shared shell whoseRunsIndexSeedlists every non-evicted run the viewer's actor may read (itsruns:readpredicate — authorization.md item 6), newest-first (rendered byweb/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:hoverbackground 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/titleso 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 opensEventSource("/runs?stream=1")and stays live — rows appear/update/finish/disappear without a refresh, and feed-added rows render through the identicalRunRowcomponent as seeded ones (the full-row + status-dot shape is one implementation, never raw markup). The feed is the same predicate's view (visibleIndexFeed): anupsertfor a run outside it is dropped, and so is that run's laterremoved, 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;startedAtis the clock time atcreate()andeventCountis 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-storeheaders 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 atcreate()viacomposeRunLabel— human-first: agent-led, thenowner/repofor 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.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 inlistActive()order (newest-first) — mirroring the per-run backlog replay — then registers the callback and returns an idempotent unsubscribe. Thereafter every lifecycle transition emits anIndexEvent:create(), each contentpublish()(live event count / running state — a span record's publish emits none),finish()(an upsert withfinished:true) andseal()(an upsert carryingsealedAt/replyOk) emit{ type:"upsert", run }; a TTL eviction duringsweep()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 shareddefaultRunRegistryviadispatch(), 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-runserveEvents— it buffers the synchronous replay, writes the 200text/event-streamhead, flushes the buffered frames, then live-forwards, and unsubscribes on client close — but always 200s (no token gate) and has no terminalendframe (the whole-registry feed stays open; finish is an upsert, eviction a removal). Each frame isdata: ${JSON.stringify(indexEvent)}\n\n.Run control from
/runs—POST /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/missingmodeis a 400 (checked before any registry lookup), an already-finished run is a 409, success is 200 JSON{ id, mode, state: "stopping" }withno-store. POST-only (GET → 405allow: POST); every other run route stays GET-only. The handler callsRunRegistry.requestStop(id, token, mode), which drives the run'sRunControl(see run-loop.md item 8: soft = wrap up via the finale, hard = abort now), publishes a typedstop_requestedrun_note(withmode) to the run's stream so every open viewer sees the request, and upserts the index.RunSummary.stop={ mode, state }—stoppingfrom the request until the run finishes, thenstopped; 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 astopping (mode)/stopped (mode)badge appears inside the anchor. The per-run page has the same two buttons in its header; onstop_requested/stoppednotes the connection label readsstopping (mode), and theendframe then readsstopped (mode)instead offinished. Kill asks for aconfirm()first (it is destructive: no summary, sandbox torn down). Both pages POST viafetch(CSPconnect-src 'self'already allows it), keep the id/token URL-encoded, and render rows/labels as text through the components.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
answerevent ({ type:"answer", text }— redacted with the sameredactSecretsas every event, but not capped: the summaries are the digest, this is the record) beforefinish()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 farwrite-up — or any run's answer — is readable on/runswithout 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-escapedRunHistorySeedJSON 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.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 byhumanizeMessageText(Slack<url|label>→ the url orlabel (url),<@U…>/<#C…|name>→@user/#name,& < >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 aftercreate()— 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 throughonEvent(no card refresh, no friction input);assistant({ type:"assistant", text }— the model's prose that rode alongsidetool_usein one completion, redacted, uncapped) is emitted by the runner before that turn'stool_callrows, and never for the final text-only completion (that is theanswer, published by the dispatcher — nothing appears twice). The status card shows anassistantturn 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 likeinput, bounded per run-visibility.md item 6), is published by the dispatcher right afterinput. The page rendersinputas a headed Request block above the log,contextturns 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),assistantas the narration that opens a step in the log (item 13), andansweras 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'satat their right edge (omitted whenatis 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:linesprop, 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'spr_opened,createddeciding the verb),output(a command run), elseanswer; 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. TheWaiting for activity…placeholder disappears on the first painted change of any kind —inputandanswerclear 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 bysrc/channels/markdownLite.ts— ONE self-containedrenderMarkdownInto(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/tdviacreateElement, 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 viacreateElement/createTextNode/textContent/setAttribute: angle brackets are text (no HTML passthrough —<img onerror>is literal), onlyhttp:///https://hrefs become anchors (withrel="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.vueimports the ONErenderMarkdownIntofromsrc/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 totextContent. The oldString(fn)inlining and its__nameshim 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 andv-htmlappears 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 atry/catchguard that falls back totextContent— a renderer bug can cost the formatting of one event, never the event or the stream.runFriction.tsignoresinput/context/assistant/answer(narrative, not friction or steps — they count toward neitherrunMsnoreventCount);parseRunEventLines(the input offriction analyze) accepts them in captures. Span rows (tracing.md): aspan_start/span_endpair whose name is adispatch.*,run.*,post.*orship.roundspan 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 atool.*span decorates its call card and amodel.turnspan its step (item 13); anmcp.*span draws nothing of its own.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-containedcreateRunTimeline()(no imports, no module-scope closures — still pinned by its own test), imported by the web bundle and driven bycreateRunPageModel(web/src/lib/runPageModel.ts) — into steps and call cards, deterministically (no heuristics): anassistantevent opens a new step with that prose as its narration; atool_calljoins the current step (calls before any prose form one un-narrated leading step); atool_resultattaches to its call bycallId(the provider'stool_useid, 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; ✓ / ✗ / ⚠ forok/failed/infra) · a dim$for shell calls or a tool chip otherwise · the command · right-aligned facts (exit N— red when nonzero — orerror/sandbox error,N linesfrom the summary's size note (the FULL output's count, since the event'soutputis capped), and the duration fromat−at, e.g.1.2s,2m 35s) · a chevron. Collapsed, the header shows the one-line headline: the command's first line with its leadingcd … &&/;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 redactedoutputin a scrollable<pre>(the summary if no output rides on the event;no output;no output yetwhile it runs — the spinner is the card's one live mark). Status is truthful:okis 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 carriestags(shell: the first matching oftests,build,install,git,network,read, elseshell, from the command text after thecdhops; other tools: the tool name; plusfailed/infraafter the result) and the page opens a card when any tag is inOPEN_BY_DEFAULT—["failed", "infra"]— overridable per view with?open=tests,build(orall); an Expand all / Collapse all header toggle flips every card and applies to cards added later.update_statusrenders 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 onend/ disconnect. Request block source: theinputevent carriessource({ url?, channel?, user? }— the Slack permalinkslackPermalink(teamUrl, channel, ts, threadTs)built from the cachedauth.testURL, plus the display names the adapter already resolves;IncomingMessage.sourceUrl), rendered as#channel · user · open thread ↗— onlyhttp(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— nov-html, no raw-markup assignment); the CSP is the strict shell policy. Data changes behind this:tool_result.output(control-stripped → redacted → capped atTOOL_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 byparseExitPrefixfrom the executors' sharedexit N:first line — see run-visibility.md item 1),callIdon both tool events, anddescribeToolCallnaming non-bash targets (use_skill code-review-and-quality,web_fetch <url>). The status card and the friction analyzer keep readingsummary. One fold for every frame: the seeded history of run-history.md and the live EventSource frames both go through the samecreateRunPageModel→timeline.push(e)→ apply-change path, so a seeded page and a live page render identically by construction;contextframes become the Earlier in this thread block (acontextchange) and the transport'sreplay_notea muted notice (areplay_notechange) through that same fold —createRunTimelineknows both kinds, and an unknown frame is ignored, never thrown on. Span cases (tracing.md): amodel.turnspan_enddraws the turn row from its attrs (model,inputTokens/outputTokens/cacheReadTokens) and is the step boundary; the history seed of a span-schema record isnormalizeSpans-ed beforewithOmittedMarkers(whose count and cursor skip the synthesized,seq-less spans), a record belowSPAN_SCHEMAarrives as stored,untimed, and any event kind the fold does not know draws nothing; atool.*span_endwhosecallIdattr names a call the result could not time (noat) gives that card its duration (the twin rule) and never opens a row; every other streamed span is aspanchange (open at the start, closed by the end).run_metawithout amodel(a command run) is ametachange with none."Scheduled" tab — the jobs that run without a human, visible without
wrangleraccess.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;scheduledis a reserved path word, never a run id — ids are UUIDs). The same view is the registry commandschedule liston every surface (src/core/commands/schedule.ts; command-registry.md item 20: cron, worker, command/identity, next firing UTC, newest firing per schedule,firingsUnavailablewhen no store is configured;internalentries are hidden there too). The tab's seed carries one row per non-internalentry of the schedule registry (src/core/schedules.ts— the single catalog of every cron any of our Workers runs: each entry names itsworker(bot=deploy/cloudflare,resident=deploy/cloudflare-resident) and itsaction(run|healthz|watchdog); a unit test fails when a Worker'swrangler.jsonctriggers.cronsdiffers from that worker's entries, and each Worker'sscheduled()looks its firing up in the registry, so a cron cannot exist in one place only).internal: truemarks 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 arunschedule, "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,failedwhen 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 theScheduleStoreseam (src/core/scheduleStore.ts): the productionWorkerScheduleStorereads the state Worker's singleScheduleDO(POST /schedules/latest; the shim writesPOST /schedules/recordafter every firing — including firings that produced no run — bounded at 100 per schedule, invariant 6),InMemoryScheduleStorefor tests. Configschedules.workerselects 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 insrc/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 inweb/src/pages/ScheduledPage.vuefrom theScheduledSeed, hostile strings as text.Model turns are visible — "Thought for 5m 04s" above what the thinking produced. Between a
tool_resultand the model's next output the page could only saythinking…; 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 onemodel.turnspan (tracing.md item 17) under the run'srun.agent: itsspan_endlands the moment the provider returns and before theassistant/tool_callevents that completion produced (a call that throws — hard stop, finale deadline, provider error — ends the spanerrorand is not a turn; a refusal is a turn withstopReason: other),startedAtanddurationMsfrom the runner clock (at - startedAt === durationMsexactly), and attrsstopReason(end_turn/tool_use/max_tokens/other), the usage when the provider reported it (inputTokens,outputTokens,cacheReadTokens?,cacheWriteTokens?— Anthropicmessage.usageviausageFromAnthropic, OpenAI-compatibleusageincl.prompt_tokens_details.cached_tokensviausageFromOpenAI; malformed/absent usage never fails the completion),ttftMswhen the provider streams (the Anthropic adapter'sCompletionRequest.observer.onFirstToken), and when it streams block boundaries (observer.onBlockStart/onBlockEnd, the rawcontent_block_start/stopevents by kind and index)blocks,thinkingMs(thinking and redacted-thinking blocks) andtextMs— the turn's thinking and writing time, a block still open at the return ending there — which the turn row prints asfirst token 800ms · thinking 3.2s · writing 1.1s, andmodel— the<provider>/<model>that took the turn, the SAME refrun_metacarries (the runner composes it asprovider.name/opts.model; the registry keys providers by the ref's prefix,opts.modelis 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'ssameModeltreats 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 noturnevent kind: a record written before spans carries its turns as objects no reader knows, and its page statesno timing data(item 25). The span's shape follows the OpenTelemetry GenAIchatspan (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 compacted800/12.3k/1.3M; cached only when present; a span end without a numericdurationMsis ignored and the span's start draws nothing) and the page renders it as a muted[HH:MM:SS] 💭 Thought for … · factsrow (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.1sas its activity line — the runner'sonProgressnote at each turn's end, which the dispatcher paints like every trace (replaced by the next event).runFriction.tstakes model time from themodel.turnspans on a stream that carries them (summed;slow_model_turnat the span's index) and a stream withoutmodel.turnspans has no model time (run-friction.md). Live elapsed time on thethinking…tail is a separate change.History mode.
GET /runs/:idwith no token (or a token the registry refused) asksRunsService.getRun(id, { include: "messages" }): a live unfinished run of THIS process → 404; a run live under another generation — the ledger's row,ownerGenset (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 onruns:writelikeruns.stopon 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 theruns:readcommand admission/api/runs.getasks 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 throughwithOmittedMarkersplusstatus/eventCount/durationMs), in history mode: noEventSourceis opened (the seed is the stream; opening one would paint every row twice), the Stop/Kill controls are hidden, and the header is a greyfinished · <status>(completed,stopped (soft),stopped (hard),failed; barefinishedbefore the store confirms a status). A truncated record shows one… N events omittednote row at the firstseqgap (withOmittedMarkers; N = published − stored; a gap at the start puts it first, a cut tail puts it last).GET /runs/:id/eventstokenless serves the events of that same ONEgetRun(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 terminalend.GET /runs/:id/frictiontokenless returns the stored diagnosis to a viewer who may read the run (404 otherwise).POST /runs/:id/stoptokenless 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-gatedrequestStopviaLiveRunAccess, unchanged. Each allowed history page/events read emits one audit line{ route, runId, identity }(identityis the viewer's actor id,access:<sub>) and each refused tokenless read one{ route, identity, denied }(deniedisauthorize's reason token, e.g.not-member; no run id, so the log reveals no more existence than the 404) — never content. Dev bypass: underACCESS_DEV_BYPASS, history reads (?all=1, tokenless page/events/friction) are 403 unless the client is loopback andPUBLIC_BASE_URLis unset/localhost; the token path is unaffected.Index toggle.
GET /runslists the active runs fromregistry.listActive()plus, with the run ledger on, the runs live under other generations (RunsService.liveElsewhere, run-history item 41: tokenless rows named by theirownerGen, static — the index feed stays the registry's) — never a store read — with aShow alltoggle to/runs?all=1, which lists one full page ofRunsService.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) withActive onlyto go back. When the page was full the service'snextBeforecursor renders as anOlder 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 tolistRuns; a short page has no link. The toggle (Show completed/Active only, item 18) carries the truthful retention sentence as a real tooltip (aUTooltipon the?help, with a screen-reader copy):Finished runs are kept for N days, then deletedfromrunHistory.retentionDays, orRun history is off; finished runs are kept about a minute.when there is no store. Rows are oneIndexRowprojection (aRunViewplus an optionaltoken—RunIndexRowSeedinwebSeed.ts) rendered by ONE component (web/src/components/runs/RunRow.vueover the row model inweb/src/lib/indexRow.ts— the port of the old isomorphicindexRowRenderer) 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 — andmergedRowsattaches 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 (oncefinishedAtis 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=1echoed) is reconciled byfeedAction: the default view drops afinishedupsert (the row leaves as the run ends) and honors everyremoved;?all=1keeps finished rows and ignoresremovedonly for a row flaggeddata-persisted(the registry'smarkPersistedupsert), so a run the writer lost still disappears at eviction and no ghost row survives a reload. A finished row keeps the record'sfinishedAt/status; the?all=1feed'supsertfor it carries the registry'sRunSummary(no such fields), so the client repaints throughmergeRow(prev, run)(web/src/lib/indexRow.ts) — the summary overrides only what it carries and never wipes the status, duration or dot.UX papercuts — the runs page and the run page read at a glance. Runs page shell:
/runsand/runs/scheduledare two tabs of one page (the shared shell plusAppShell/AppNav/RunsTabsinweb/src/components/: header, site nav,nav.tabswitharia-current="page"on the current tab — Runs · Scheduled). What the chrome lists follows the installation's capabilities (the seed'scapabilities,src/core/capabilities.ts, read throughweb/src/lib/capabilities.ts— never a page's own data): the site nav lists Runs always, Residents only whenresidentsis on and Costs only whencostsis; the Scheduled tab exists only whenschedules(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 innavSections' list with its capability predicate; nothing else changes. The routes themselves are unchanged —/residentsand/costsstill answer 503 when their subsystem is off; with the tab gone, nothing links there. The header readsLive runs ● connected— the connection indicator sits beside the title (its label isconnected/connecting…/disconnected, neverlive, 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'snow) and ticked once a second by the page from the row's start stamp (client clock), a finished row's start→finish fixed — viasrc/channels/indexFormat.tsformatElapsed(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 bysplitRunLabelinto 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 bystartedAt(unchanged). Live rows breathe (dot halo, off underprefers-reduced-motion), finished rows sit back (muted). The row model (web/src/lib/indexRow.ts) imports its three formatters (formatElapsed/formatRelative/splitRunLabelfromindexFormat.ts) directly — the oldRowFormattersparameter existed only because a bundler rewrites an imported binding inside an inlinedString(fn)body (seen under vitest as__vite_ssr_import_4__), and that constraint died with the inlining: Vite bundles the imports. The toolbar countsN running; the item-17 toggle readsShow completed/Active onlywith a real tooltip (see item 17). Run page — one step is one block, read top to bottom: aturnis 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 mutedtext-xssentence, 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 saysrunning…is three loading states at once, and a clock counted from the last received frame reads57sfor 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 (CallCardreads the page'sRunnerClockKey; 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 stampedturnnames one, item 15;modelwhen 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 atThinking, nothing rotating while no one waits — and the elapsed since the last stamped event, amber past the same minute the finishedthoughthead turns amber (a first cut painted it as a barethinking <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 firstthoughthead, 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.modelfalls back torun_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, elserun_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 sameWaiting for activity…placeholder a history page does. Every stopwatch on the page reads one projected runner clock —runnerNowinrunPageModel.ts: the newest stamped event'satplus 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 (liveWaitnames 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 saysno output yet, never a secondrunning. 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 becauseparseDirectiveshas 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 firingdetailis 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>— wherefiringDetailSummarydrops a leading emoji and a leading*Title* —, caps at 120 chars with…, and keeps the full text on the span'stitle. Count cell: the row'sN eventsprintsstepCount— the content events, span records excluded (tracing.md) — when the row carries it, elseeventCount, always under the wordevents; its tooltip says what it counts (countText/countTip,web/src/lib/indexRow.ts; the tooltip saysspan records includedwhen a row has nostepCountand the fallback total counts them).Run page — gutter layout, run context, a real 404. Layout: the header is a full-width band (back link · title ·
∿ connectedat 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.75remoff 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\00b7escape, 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 all↔Collapse 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'sraw 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 eventrun_meta—{ type:"run_meta", agent, model, repo?, ref?, pr?, headSha? }— is published by the dispatcher right afterinput— at the ledger reservation, from theRepoContextas resolved then — and once more, with the adoptedheadSha, 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 integerpr, a 7–40 hexheadSha; 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/Nfor 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-listedowner/nameshape — 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 thegit-compareglyph, the same weight as the chips beside it.runFriction.tsignores it;runEventLines.tsaccepts 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/:idfor an unknown run, an expired one, a wrong token or a tokenless live run answers 404 as a page — the shared shell with aRunNotFoundSeed({ 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 bodyrun not found.Runs index — started column, activity tooltip, pager, expiry divider; run page — total duration. Toggle: item 17's
Show alllink is a checkboxShow 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 isaria-describedby. Started column: every row leads with when it started the way GitHub says it —indexFormat.tsformatRelative:just now(< 45 s),1 minute ago…59 minutes ago,1 hour ago…23 hours ago,yesterday,2 days ago…6 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 exactreceived …(once the run carriesreceivedAt, 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 newestassistantline,tool_callsummary, oranswering, whitespace-collapsed and capped at 120 chars, set byRunRegistry.publish()and forwarded throughRunViewfor live rows — and the status dot's tooltip readsnow: <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>. Nativetitles are gone from the row. Tooltip component: informational tips ride Nuxt UI'sUTooltip(the old hand-rolledinstallTooltips/data-tipcomponent 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=1pages areINDEX_PAGE_SIZE= 25 rows (the service cursor already existed; the page was rendering the 200-row cap): anav.pagerunder 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 viaformatDateTime, renderer's zone — item 21 replaced the earlier centeredN 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 knownretentionDays), every finished row carriesdata-expires-at = finishedAt + retention; rows leaving within a day get classleaving, agone <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 atendfrom 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.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 —
failedandkilled(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.activitynow also takes theanswertext's first line, so a failed inline run's ⚠️ reply is the failure, and the persisted record carriesactivity(RunRecord.activity, from the stored events) so history rows say it too. The same words everywhere:statusLabel(web/src/lib/indexRow.ts—stopped_hard→killed,stopped_soft→stopped early) drives the run page header, and the Scheduled tab's outcome column shares the vocabulary throughscheduledPanel.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 readsvia <Surface> · <identity>on one line — the identity is the resolved display name (IncomingMessage.userName→RunMeta/RunSummary/RunView/RunRecord.userName), falling back to the id suffix, never a rawslack:U…. Without a thread the mark is the surface's glyph (⁙Slack,⌁HTTP ingress,◈MCP,>_CLI); when the message had a permalink (IncomingMessage.sourceUrl→RunMeta.sourceUrl→RunSummary/RunView/RunRecord.sourceUrl) it is instead the familiar open-in-new-page arrow (↗, classlinked) — a pointer cursor and a chip-style hover state say "clickable"; no caption — opening the thread in a new tab. Only for anhttp(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_metacarries the resolvedeffortbeside 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 runswhile 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.tsdata URIs; the CSP'simg-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 tonesStatusDot.vuepaints 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.icoserves 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/plainok. 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 theirremovedevents — 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, notacme/api(one org; the path is noise) — linked tohttps://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; fromRunView.repowhen the run had one, else from a label scope shapedowner/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>(namedopen 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 rideUTooltip(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'sgone <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.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'sOUTCOME_LABELinscheduledPanel.tsmatches):completedrenders as succeeded — "finished · completed" was a tautology and bare "finished" read as success while including failures — besidefailed,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/killedred chips,stopped earlyamber, and a greyendedchip for a pre-history record whose status is unknown. A live page readsrunning · <stopwatch>— the WHOLE run, on the one duration definition (tracing.mdrunDurationMs): from the moment our process received the message (receivedAt, falling back to the registry'sstartedAt) 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 atendat the value it had; the index row,runs listand 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 liveendthe page shows the chip it can KNOW —killed/stopped earlywhen the viewer stopped it, else the greyended+ duration (an inline ⚠️ failure still arrives as ananswer, so the live page never guesses "succeeded"; a reload shows the record's word). Between thefinishedframe andendthe header readsdelivering… · <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 atendthe duration gains the delivery caption from the frame's stamps (tracing.md):delivered in 2s(sealedAt − finishedAt, whenreplyOkis 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'ssealedAt/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 withstopped early— the word and the tooltip tell them apart — and a run whose reply never settles stays amber until the registry evicts it.Run page — a loaded skill is its own row. A
skill_useevent (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 whoseuse_skillcall it belongs to (a skill before any step opens one); only anhttp(s)sourcesurvives 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— wheresourcebecomes a link (target=_blank,rel=noopener noreferrer) only for the fold-vettedhttp(s)URL and the size comes frombodyBytes; the row joins the step's group like a card, so folding/expanding the step carries it along. Theuse_skillcall card above it keeps saying what the tool did; the row says what was loaded.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 typegenthat 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 inmain.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:toolruns 2 s → 20 min (the bash tool'sBASH_TIMEOUT_MAX_MS),turn15 s → 5 min (the friction analyzer's 60 s slow-turn threshold lands on the ramp, not below it),run1 min → 45 min (the coding agent's wall clock). The helper hands the DOM one scalar,--heat-t, and the.heatutility inmain.cssdoes 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 ondata-heatfor 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 isdata-heat="4", its duration fact istext-badbold with atimed outlabel 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 anexit 124:line (execution.md item 11,bashTimeoutNote) and keeps a foreign SIGKILL out of that path, so 137 (the OOM killer, akill -9) is a plain failure, not a timeout. Painted sites: the call card's duration fact (always its last fact) and the card'sdata-heat; the step head'sthought …chip on theturnscale (replacing the binarytext-warn; the model'squickflag 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 therunscale (a live row stays green — its clock is moving). The fold carries what the paint needs:CallVmgainsexitCodeandtimedOut,TurnVmgainsdurationMs.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-modelbuildTimeline,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,traceVersionbumping per frame) and the header's own window —receivedAt(elsestartedAt) plus the header's total, i.e. tofinishedAton 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_PAINTinweb/src/lib/termPaint.tsis 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.ledekeeps the one-sentence form (4m 12s — 34s getting ready · …) for the bar'saria-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 deliveringafter thefinishedframe and, afterend,delivered in 2s/reply failed/ nothing perreplyOk(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'squeuedBeforeMs/queuedBehindMs— set when the root starts (startRequestRoot'soriginAt/queuedBehindMs), never after, since a root's only streamed event is itsspan_start— print asqueued … before we saw it/… behind the previous runfrom 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 throughTimelineInput.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.anchoriscall-<callId>for a tool step andspan-<spanId>otherwise, the idsCallCard,SpanRowand 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, thedispatch.*steps and the attach's grafts fold under a Getting ready head, the stepsclassOfcounts 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; therequestandrun.agentspans are the page's structure and draw no row (isStructuralSpan). A record cut to its budget (the seed'struncated) prints its lost stretch asnot recorded (too large); a live page whose replay was elided printsnot loaded (the record has the full shape); a record with no root shows the total andgetting ready: not recorded (too large); a record written before span schema (the seed'suntimed, tracing.md item 14) shows the total andno 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 behindCopy debug JSON; the raw-events link (/runs/:id/events) appears in history mode only.runOwnerOf(src/core/runOwner.ts) names the partition owner fromrun_meta's agent, so a command run'srun.commandis its tools.
Validation criteria
| Criterion | Evidence |
|---|---|
skill_use → skill 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_requested → stopping (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 &; 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::* |