Skip to content

Live run view ​

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

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

Auth = per-run capability token for a LIVE run (locked design). A plain browser navigation can't send an Authorization header, so auth for a live run is an unguessable per-run token carried in the URL, validated (constant-time) by the registry — via RunsService.authorizeLive — for the page, the SSE stream and the stop control. A wrong/missing token on a live run — or an unknown/expired run — is a 404 (existence is never revealed; unknown, expired and wrong-token share one body). The token is the capability: unguessable, scoped to one run, never logged, and never in any finished row, page or persisted record. A run restarted from its request after a container replacement keeps its id AND its token (run-history.md item 54: the restart carries the run's identity, CreateOptions.token), so the page a person opened and every capability link already posted — the card's, a unit thread's — keep answering 200 across the replacement, and the index counts one run. 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 replacement web app. Every HTML route emits an escaped seed island and loads the single Vue entry at web/green/main.ts. The same route returns its seed as JSON for in-app navigation. Run, report and transcript components retain their server authorization and SSE contracts. The browser uses neutral surfaces, a green action accent and semantic status colors from web/green/theme.css. DM Sans renders the interface and JetBrains Mono renders code; both fonts are bundled locally. The shared ThemeToggle.vue offers light, dark and system appearance and persists the preference by the server-proved actual actor and authorized organization through VueUse. A selected read target does not change that key. Without both scope facts, appearance starts at System and is not persisted. The browser-wide legacy preference is not inherited. The layout adapts to desktop and mobile. The strict shell CSP permits bundled scripts, local fonts and assets, with no executable inline scripts. Rendering proofs mount the production views; server route and permission proofs remain in the channel tests.

Runs index = Access-gated, NOT token-gated (locked design). GET /runs (no id) is the run list a signed-in team member uses 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: web/src/components/StatusDot.vue, web/src/components/run/StepBlock.vue, web/src/components/ThemeToggle.vue, web/src/lib/uiTheme.ts (item 20: the one layer every popup paints at, handed to the theme by web/vite.config.ts), web/green/components/UserMenu.vue, web/green/components/UserMenu.vue, web/green/components/UserMenu.vue, web/src/lib/seed.ts (useSeed, useViewingAs), web/green/pages/Runs.vue, src/channels/viewAs.ts (view-as, item 30: the banner, the picker, the calls, the routes); src/core/runRegistry.ts (RunRegistry: create(label?)/publish/commitLiveState/finish/subscribe/has/listActive/subscribeIndex/requestStop, the IndexEvent model, per-run label + startedAt + eventCount + stop, bounded backlog, TTL eviction, constant-time token gate, defaultRunRegistry singleton); src/core/runLiveState.ts (the closed live-state table, assignment gate and replay fold); src/core/runEventLane.ts (the registration-time event sequencer shared by setup spans and the run loop); src/core/providerLiveStateCoordinator.ts (the one ordered provider park/recovery lane); src/core/runRegistry/ (the registry's parts as sibling modules: runControl.ts — RunControl, the per-run soft/hard stop + hard AbortSignal; 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 — the IndexEvent model, IndexSubscriber, and IndexFeed, the runs-index feed's subscribers, replay-on-subscribe and isolated fan-out; testing.ts — the shared test fixtures); src/channels/liveView.ts (the router + handler: parseRunRoute incl. the index, scheduled, unit and stop routes, parseStopMode, createLiveViewHandler — takes the bound shell and emits seeds; re-exports the modules below so it stays the one import path), src/channels/scheduledPanel.ts (the Scheduled tab's model, item 14), src/channels/webShell.ts (renderShell, makePageSender/PageSender — the shell or the seed by wantsSeed, item 31 — WEB_HTML_HEADERS/WEB_SEED_HEADERS), src/channels/pageFavicon.ts (pageFavicon, the first-paint favicon rule the shell and the app share), src/channels/webSeed.ts (the seed types, serializeSeed, SEED_ELEMENT_ID, retentionSentence), src/channels/webAssets.ts (loadWebAssets — the manifest read + immutable /assets/* server), src/channels/liveView/sse.ts (SseSink, serveEvents with the REPLAY_LIMIT ring, serveIndexEvents, serveHistoryEvents + withOmittedMarkers, the node sink and keepalive heartbeat — byte-for-byte unchanged by the Vue port), src/channels/liveView/html.ts (escapeHtml), src/channels/liveView/viewer.ts (readableRuns — the viewer's runs:read predicate — and visibleIndexFeed, the index feed narrowed to it; shared with the residents index), src/channels/favicon.ts (the dot favicon SVG/data URIs); src/core/runPace.ts (item 32: the one pace rule — PACE_WINDOW_MS, paceText, stalledFor, boundText, eventsInWindow, InFlightCall); the shared pure models the web bundle imports: src/channels/runTimeline.ts, src/channels/markdownLite.ts, src/channels/indexFormat.ts (formatElapsed, formatRelative, formatDateTime, splitRunLabel), src/channels/localIso.ts; the rendering in web/: web/green/pages/Runs.vue, web/green/pages/Run.vue, web/green/pages/Scheduled.vue, web/green/pages/NotFound.vue (+ web/green/pages/RunRoute.vue, which dispatches /runs/:id between run details, recorded PR evidence, and an unavailable page by the seed's page), web/green/components/Shell.vue (global navigation and appearance), web/src/components/{StatusDot,MarkdownText,SlackMark,GithubMark}.vue, web/src/components/runs/RunRow.vue (the index row, item 17), web/green/components/Shell.vue (global Scheduled work capability gate), web/src/components/runs/SourceMark.vue, web/green/lib/navigation.ts (primaryNavigation — the seed's current capabilities), web/src/components/run/{StepBlock,StepItems,CallCard}.vue, web/src/lib/indexRow.ts (the row model: statusLabel/statusDot/agentHue/runHref/stopHref/dotTip/whenTip/sourceTip/feedAction/mergeRow — the port of the old isomorphic indexRowRenderer; item 32: rowPace/rowStalled/rowBound/paceTip), web/src/lib/timelineVm.ts (buildTimeline, the timeline's pure view-model, item 25), web/src/components/run/TimelineSection.vue, the unit page's parts (item 28): web/src/components/runs/RunFoldRow.vue (a run's row that folds open), web/src/components/run/RunTimeline.vue (the fold — the run's timeline and the step a search hit landed on), web/green/components/ThreadSearch.vue (the search box), web/src/lib/sseReplay.ts (the stored replay as frames); 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/format.ts (the pages' formatters re-exported from the bot's — formatDuration, formatDateTime, formatUsd for the header's cost — plus the web-only formatClock and formatBytes), web/src/lib/{seed,eventSource,browser}.ts (fetchSeed, routeNavigationInApp/isAppPath, browser.leave), web/src/lib/seedRouting.ts (item 31: installSeedRouting, the guard that loads a seed per navigation; pageAddress), web/src/components/SeedScope.vue, web/green/App.vue, web/green/main.ts; src/core/dispatch/reply.ts (composeRunLabel — the pure, exported, channel-agnostic run-label composer; humanizeMessageText, attachmentSuffix, liveViewLink); src/core/dispatch/provision.ts (registerRun: registers the run with that human label before the attach, adds the link to the status card when PUBLIC_BASE_URL is set); src/core/dispatcher.ts (publishes events in onEvent, finishes in the run-loop finally); src/core/types.ts (optional channelName/userName display hints on IncomingMessage); src/channels/slack/lookups.ts (resolveChannelName/resolveUserName — best-effort, cached name lookups that populate those hints); src/index.ts (loads the web assets, serves /assets/*, routes GET /runs + /runs?stream=1 + /runs/:id + /runs/:id/events, sharing defaultRunRegistry with the dispatcher — the /runs* Access gate already covers /runs?stream=1); scripts/web-preview.ts (fixture preview server); the page itself — web/green/pages/Run.vue, web/src/lib/runPageModel.ts (the fold of the stream into the page's view-model), web/src/components/run/MessageFiles.vue + web/src/lib/filePreview.ts (a message's files and how each opens, item 26), web/src/components/run/CallCard.vue, web/src/components/run/ReplyBlock.vue, deploy/cloudflare/knownLength.ts (the shim keeps a container answer's length on the wire, item 26).
  • Tests: web/src/pages/scheduled.test.ts, web/src/lib/uiTheme.test.ts, src/channels/viewAs.test.ts (item 30), item 31 (navigation in place): src/channels/webShell.test.ts, web/src/lib/seed.test.ts, web/src/lib/seedRouting.test.ts, web/src/lib/browser.test.ts, web/src/App.test.ts; server side (routing, token/Access gates, seed contents incl. the token rules and the 404 seed, SSE transport, paging, audit): src/core/runPace.test.ts (item 32), src/core/runRegistry.test.ts, src/core/runLiveState.test.ts, src/core/providerLiveStateCoordinator.test.ts, src/core/dispatch/runLoop.test.ts, src/core/runLedger/inMemory.test.ts, src/core/runRegistry/runControl.test.ts, src/core/runRegistry/activity.test.ts, src/core/runRegistry/projections.test.ts, src/core/runRegistry/backlog.test.ts, src/core/runRegistry/indexFeed.test.ts, src/channels/liveView.test.ts, src/channels/webShell.test.ts, src/channels/webAssets.test.ts, src/channels/indexFormat.test.ts, src/core/dispatcher.test.ts (live run-view wiring (Area 2)), src/core/dispatch/reply.test.ts (composeRunLabel, attachmentSuffix), src/channels/slack/lookups.test.ts (resolveChannelName / resolveUserName); rendering behavior (web/src/**/*.test.ts, run with npm test in web/): web/src/lib/runPageModel.test.ts, web/src/lib/timelineVm.test.ts (fold/group/fold rules), web/src/lib/durationTone.test.ts (the heat scale), web/src/lib/indexRow.test.ts (row vocabulary + feed rules), web/src/components/runs/RunRow.test.ts, web/src/pages/runsIndex.test.ts (live feed, divider, title/favicon, reconnect reload), web/src/pages/runPage.test.ts (history + live + stop + dedupe; item 28: the units and spawned-runs blocks), web/src/pages/unitPage.test.ts (item 28: the unit page, its folds and its search box), web/src/lib/sseReplay.test.ts (item 28: the replay a fold reads), web/src/pages/{scheduled,notFound}.test.ts, web/src/components/AppNav.test.ts (the nav and the shell under each capability shape), web/src/stack.test.ts, deploy/cloudflare/knownLength.test.ts (the shim's length re-frame, item 26).
  • Docs: AGENTS.md invariants 1, 2, 6, run-visibility.md. The whole /runs* surface sits behind the dashboard auth strategy — Cloudflare Access (SSO) re-verified fail-closed in our own code, a bearer token, or loopback-only none — see access-gate.md; that identity gate runs before the per-run capability-token check here (defense in depth). "Access-gated" below means "behind that gate", whichever strategy an installation runs.

Behavior ​

  1. Per-run capability. RunRegistry.create() mints a random run id and a random view token (default: randomUUID + 32 random bytes as hex). The dispatcher creates one run per agent run — at its ledger reservation, before the workspace attach (run-history item 42) — and finishes it when the run ends; a dispatch that ends before its run loop discards it (discard: no finished frame, the index feed's removed). Admission commits the first RunLiveState (admitted, with its absolute bound) and its run_state event to the ledger before either becomes visible in the registry and before workspace attachment starts. A plane queue row is not admitted and carries no live state. The admission deadline stays fixed while workspace setup runs: a resident's shorter waiting_deploy or waiting_repository bound belongs only to that wait. After it expires, falling_back, preparing and the first working transition use the remaining admission budget (narrowed by an already-running lease, if present), never a renewed duration or the expired wait bound. Each transition rechecks expiry, including time spent committing the preceding boundary or preparing the prompt. The first harness open rechecks the same deadline after asynchronous pre-harness reads, including coordinator PR facts; it cannot start a model lease after setup has expired. An exhausted budget releases the selected workspace and ends with typed run_budget_exhausted and a time_budget_exhausted note before any model starts. A coordinator child keeps its advertised id, original profile and binding; ledger and registry projections agree and spawn replay cannot create another child or writer. This does not change when the harness starts its model lease.

  2. Live stream + one bounded backlog. The registry has two event entry points over one monotonic per-run sequence. publish(id, event) assigns the next seq, appends the event to the backlog and fans it out. commitLiveState(id, committed) applies only an assignment the ledger already acknowledged; when that assignment carries a changed-state boundary, it verifies that boundary's reserved seq is next, then appends and fans out that already-durable event before exposing the projection. The run loop serializes every harness event through one awaited lane because tool projection persistence may wait: a tool source commits before its publication, no later event can consume its sequence, and projection refusal or failure never drops the event from the live stream. Provider park and level callbacks likewise share one coordinator, so a recovery cannot overtake the waiting projection of an earlier durable park. Every fan-out isolates subscribers in try/catch (like index sinks), so a dead SSE sink can neither stop the others nor throw into the runner. The registry backlog is the only per-run event store (the dispatcher has no ring of its own — its friction diagnosis reads registry.snapshot), bounded by count (8000) and bytes (4 MiB, measured as each event's UTF-8 JSON): past either, the oldest events AFTER the protected head drop until under both — the newest always survives. The protected head (tracing.md): the events that say what a run is — input, context, run_meta, the root's start, the slack.receive and dispatch.* span pairs, and the mcp_unavailable/spans_dropped notes — published while the backlog holds nothing else are never trimmed, up to HEAD_BUDGET_BYTES (512 KiB), and are capped to MAX_EVENT_BYTES at publish so one giant context cannot spend the budget; head material after any other event, or past the budget, is ordinary. stepCount counts the content events (span records excluded) beside eventCount, on the summary, the snapshot and the record. The seq is the event's 1-based position in the run's stream (the registry's monotonic eventCount at publish — ONE counter, carried both on the event object and as the second argument to every subscriber); it is the SSE id: a client resumes from. A subscriber that arrives mid-run replays the backlog in order, then live-forwards new events; subscribe(…, afterSeq) replays only events with a higher seq. A fresh subscribe (afterSeq 0) replays the protected head first and then the newest events within the replay budget (item 5), eliding the range between; a resume re-sends nothing from the head. Provider-failure notes remain protected independently of that head: up to 64 KiB of the newest diagnostic notes, limited by the configured backlog count and byte bounds. The saved record uses the same selection before its head/tail fitting.

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

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

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

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

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

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

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

  10. Stop and Kill. POST /runs/:id/stop?mode=stop|kill uses the existing Access, capability, visibility and stop:write gates. Stop interrupts the current turn without a summary turn, forces runtime cleanup, and retains a terminal stopped run with its requester and the answer ⏹ Stopped by user.. The dashboard presents this as a neutral stopped result. Kill skips the graceful turn interrupt. It removes the live projection, saved run row and saved events only after confirmed runtime shutdown. A durable ID fence prevents a delayed record write or same-ID claim from restoring the run. Unknown shutdown returns 503 and retains the row for the same operation. Kill requires confirmation in the dashboard. Its success removes the index row and returns the run page to /runs. Invalid modes return 400; wrong capabilities return 404. Legacy mode=soft|hard HTTP calls and stored values remain supported. They never delete a row. The dashboard offers only Stop and Kill. Hosted pipelines retain their operator-only admission-stop check before either control can close their parent.

  11. The run record is the source of truth for publishable answers; a private main answer stays in its requester DM. For ordinary runs, when the run loop returns, the dispatcher publishes the final answer into the run's stream as an answer event ({ type:"answer", text } — redacted with the same redactSecrets as every event, but not capped: the summaries are the digest, this is the record) before finish() and before the channel reply or the PR post (a publish on a finished run is a silent no-op, so the order is load-bearing and pinned by a test). A verified private main run instead projects a placeholder into its shared stream and record; the actual answer stays in the requester's DM and model session. An ordinary run page renders its answer as a dedicated Reply block under the log (through the safe markdown renderer, item 12), so a stopped run’s ⏹ Stopped by user. result is readable on /runs. A run that threw publishes no answer (the card shows ❌). The run page can be seeded with a run's events for a page that has no stream to replay from (the history page, run-history.md): the record's events ride a \u003c-escaped RunHistorySeed JSON island (serializeSeed) and is fed through the SAME fold (createRunPageModel, web/src/lib/runPageModel.ts) the EventSource frames go through, so a seeded page and a live page render identically by construction and event text can never close the island. The live page seeds no events (RunLiveSeed), because its SSE replay paints the backlog. The index's empty-state sentinel shows only while no rows exist, per view.

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

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

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

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

  16. History mode. GET /runs/:id with no token (or a token the registry refused) asks RunsService.getRun(id, { include: "messages" }): a live unfinished run of THIS process → 404; a run live under another generation — the ledger's row, ownerGen set (run-history item 41) — is admitted on the attribute decision alone (its token is the other generation's) and renders live with a running clock and time allocation. The shared EventSource attach opts into ?follow=1; that stream follows the ledger by sequence, reports missing ranges, and ends on a terminal receipt. Default tokenless reads remain finite snapshot replays for work folds. A provisional-only follow sends unavailable and stops polling; the page shows disconnected and freezes its last clock without claiming completion. Disconnect or an unavailable read closes transport without claiming the run ended. Stop controls use the ledger route; its friction is the live diagnosis, its tokenless stop through the ledger — the tokenless stop being a write, gated on runs:write (authorization.md item 5); requester cancellation through runs.stop uses item 5a, while the capability-token stop stays the token's; a finished run the viewer's actor may not read → the same 404 (authorize(actor, "runs:read", run) on the record's own channel, user and stamped visibility, after the runs:read command admission /api/runs.get asks first — authorization.md item 5; the reason goes to the audit line only); a finished run it may read — still in the registry or persisted — renders through the SAME run page, seeded with the record's events (RunHistorySeed: the events through withOmittedMarkers plus status/eventCount/durationMs), in history mode: no EventSource is opened (the seed is the stream; opening one would paint every row twice), the Stop/Kill controls are hidden, and the header is a grey finished · <status> (succeeded, stopped by user, failed; bare finished before the store confirms a status). A truncated record shows one … N events omitted note row at the first seq gap (withOmittedMarkers; N = published − stored; a gap at the start puts it first, a cut tail puts it last). GET /runs/:id/events tokenless serves the events of that same ONE getRun(id, { include: "messages" }) read — the whole stream is known before the head is written and the record is never re-read page by page — then writes the 200 head, the prelude, every frame (marker included) and the terminal end. GET /runs/:id/friction tokenless returns the stored diagnosis to a viewer who may read the run (404 otherwise). POST /runs/:id/stop tokenless is 409 for a finished/persisted run the viewer may read (404 otherwise — the 409 would confirm existence) and 404 for a live one; with a valid token the stop is the registry's token-gated requestStop via LiveRunAccess, unchanged. A hosted ship parent requires the operator route. runs stop <id> confirms the runner stop and its original terminal write, then retains a stopped_hard result and releases the host key. runs kill <id> uses the same confirmed shutdown boundary and then removes the run row and events. Internal soft requests on hosted parents remain refused because they have no model loop to stop. The same seal stops the runner (issue 1924): it writes the stop mark on the instance row (CoordinatorInstanceStore.markStopped), which the runner reads before every unit start and before every child spawn and honours by ending — the remaining units end stopped with the reason on their rows, a unit whose child is live when the stop lands ending stopped when that child ends — and the parent closes only after a confirmed stop mark or exact committed readback and its required original terminal acknowledgment; otherwise it remains unfinished and returns unavailable (agent-ship.md item 16). A hosted row under another generation retains its stop intent for that owner; unknown closure returns unavailable. Capability-token controls cannot stop a hosted runner. Both public commands use the authorization and shutdown rules in item 10. Each allowed history page/events read emits one audit line { route, runId, identity } (identity is the viewer's actor id, access:<sub>) and each refused tokenless read one { route, identity, denied } (denied is authorize's reason token, e.g. not-member; no run id, so the log reveals no more existence than the 404) — never content. Dev bypass: under ACCESS_DEV_BYPASS, history reads (?all=1, tokenless page/events/friction) are 403 unless the client is loopback and PUBLIC_BASE_URL is unset/localhost; the token path is unaffected.

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

  18. Run and schedule views. The global shell offers capability-backed reports, Home, run details, Settings, documentation, and configured Scheduled work. The labelled connection indicator sits beside the title. Index (Runs tab, on top of item 17): every row carries a stopwatch in its right-hand facts — a live row's elapsed since start, painted from the server clock (the seed's now) and ticked once a second by the page from the row's start stamp (client clock), a finished row's start→finish fixed — via src/channels/indexFormat.ts formatElapsed (38s / 4m 12s / 1h 03m; garbage → 0s); a paint with no clock leaves the cell empty until the first tick, so the row model stays clock-free. The dispatcher's label is split by splitRunLabel into an agent chip (hue per built-in agent — coding/review/research/general, else neutral; the class is allow-listed, never the raw name), the scope (repo or #channel · user, the row's anchor), and the request snippet (muted, ellipsized, no quote marks); a label not in that shape renders whole as the scope. The status dot's hover reads <status> · started <ISO> and, for a finished row, · finished <ISO> (formatLocalIso — the renderer's zone: the server's on first paint, the viewer's on the feed's repaint). Rows are newest-first by startedAt (unchanged). Live rows breathe (dot halo, off under prefers-reduced-motion), finished rows sit back (muted). The row model (web/src/lib/indexRow.ts) imports its three formatters (formatElapsed/formatRelative/splitRunLabel from indexFormat.ts) directly — the old RowFormatters parameter existed only because a bundler rewrites an imported binding inside an inlined String(fn) body (seen under vitest as __vite_ssr_import_4__), and that constraint died with the inlining: Vite bundles the imports. The toolbar counts N running and offers labelled Include completed and Include system runs controls. Run page — one step is one block, read top to bottom: a turn is held for the step it produced; the step's first row is its head — [when] 💭 <how long the model thought> <what it then said> … <tokens> — the narration IS what the thinking produced, so the chip and the prose share a line (the chip turns amber past 2 min; a tool-only completion shows went straight to tools in the prose slot; a stream from before turns existed has no chip). A 2 px rail on the step's left edge marks where it starts and ends (green while it is the live step), and every row inside a step shares one column grid — rail → 1.5rem → timestamp → marker → text … facts: text rows (head, group tally) pad 1.5rem, cards sit .75rem in and pad .75rem inside their border, so a card's timestamp lands in the head's column and the right-hand facts end on one line. From the second call on, a step's cards fold into one <details class="group"> whose summary is a muted text-xs sentence, not a header competing with the cards — ❯ 7 tool calls, 1 failed, 1 sandbox error, 2 still running … 9.4s (callSummary, every state: all succeeded; the non-success counts — N failed, N sandbox error(s), N still running — with successes implied; all running; the calls-began time on hover; total = sum of call durations; the tally cells are kept by name on the step node, never addressed by child index); groups stay OPEN by default and are never auto-folded (revised with the Vue port — an all-collapsed page did not read; the tally bars are the narrative): only a viewer's manual toggle (click on the summary) closes one, and it sticks; a failure/sandbox error or a still-running call forces a group open even past a superseded manual close; the CARDS inside stay collapsed except failed/infra (item 13, unchanged); Expand all keeps everything open. Single-call steps stay a bare card. A turn with no step after it (the answer's own thinking, or the run ended mid-thought) is flushed as its own head row reading wrote the reply below / the run ended here. There is no tail row (a dashed-rule row rotating a verb — Noodling… — under a card that spins AND says running… is three loading states at once, and a clock counted from the last received frame reads 57s for a 20-minute command after a reconnect's replay notice). In-progress work draws where it will end up, looking like what it becomes: a running card ticks its elapsed in the facts slot its settled duration lands in (CallCard reads the page's RunnerClockKey; a history page provides none, so nothing there ticks); a pending model turn — every call settled, the model silent — is a pending-turn row at the foot of the log (#thinking): the dashed outline of the card that has not landed yet, reading ∿ · <model> · <verb>… ……… <elapsed> — the ∿ pulse (item 19's glyph, reused), a badge naming the model the run is on (modelName: the part after the provider slash, the full <provider>/<model> on hover; run_meta's model until a stamped turn names one, item 15; model when nothing is known), a verb from a fixed list (Thinking, Pondering, Mulling it over, …) DERIVED from how long this silence has lasted — a new word every 6 s, every silence starting at Thinking, nothing rotating while no one waits — and the elapsed since the last stamped event, amber past the same minute the finished thought head turns amber (a first cut painted it as a bare thinking <span> head on a rail; it read as nothing, so the fun words and the pulse came back as a row shaped like the others); the real step replaces it when the turn lands, so a silent model is never a blank page; the model badge is worn where a reader learns something (TurnVm.showModel): the run's first thought head, and every head where the model changed — a run on one model names it once, and the heads between carry only their cost facts; the badge is the pending-turn row's, first in the head's meta row, naming the model that took the turn — the turn's stamp, else the model the run was on (TurnVm.model falls back to run_meta's, so a record from before per-turn stamps still names its model on its first head; a turn with nothing known wears none); a model switch stands out: a stamped turn whose model differs from the model the run was on (the previous stamped turn's, else run_meta's — TurnVm.switched) wears a loud ⇄ <model name> chip INSTEAD of the badge (amber border and fill, full ref on hover) in its step head or turn row, the pending-turn badge then names the new model, and a turn on the same model, an unstamped turn, or the first stamped turn of a meta-less run is never a switch; before the first stamped event the live page shows the same Waiting for activity… placeholder a history page does. Every stopwatch on the page reads one projected runner clock — runnerNow in runPageModel.ts: a clock ANCHOR plus the wall time since the anchor was learned. The anchor is seeded from the live seed's server clock (model.seedClock(serverNow), before the replay) and advanced by a stamped frame only when its at is ahead of the projection — so a page load times an open call by its REAL start (a ten-minute npm test reads ten minutes on a fresh load, never the seconds since mount), a replayed stamp, a replay notice or a reconnect moves nothing, the anchor never goes backwards, and browser and runner clocks are never subtracted from each other (liveWait names the current wait: starting / call / thinking; the home page's live turn seeds the same way). The card's spinner is the one executing mark; the ∿ pulse stays the connection's alone (item 22); a running card's body says no output yet, never a second running. Slack bold: humanizeMessageText (item 16's ingress-side humanizing of Slack-authored record text) also maps mrkdwn *bold* to Markdown **bold** — only when the asterisks delimit a non-space-edged run on word edges, so globs (src/*.ts) and arithmetic (2 * 3 * 4) are untouched, and code spans/fences are left byte-for-byte; block-level mrkdwn (• bullets) cannot survive because parseDirectives has already collapsed the request to one line. Scheduled work: /runs/scheduled shows the recorded definition, next firing, last outcome, full recorded run ID, trace, and detail. Next and last times retain exact ISO hover facts. A missing run link does not remove the recorded ID. Unknown firing history is distinct from a known schedule with no firing. Count cell: the row's N events prints stepCount — the content events, span records excluded (tracing.md) — when the row carries it, else eventCount, always under the word events; its tooltip says what it counts (countText/countTip, web/src/lib/indexRow.ts; the tooltip says span records included when a row has no stepCount and the fallback total counts them).

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

  20. Runs index — started column, activity tooltip, pager, expiry divider; run page — total duration. Toggle: item 17's Show all link is a checkbox Show completed (checked on ?all=1); changing it navigates — the view is a server mode, not a client filter — and the retention note is a real tooltip on the ? help (UTooltip, see below) with a screen-reader copy the checkbox is aria-describedby. Started column: every row leads with when it started the way GitHub says it — indexFormat.ts formatRelative: just now (< 45 s), 1 minute 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 exact received … (once the run carries receivedAt, tracing.md) / started … / finished … stamps (renderer's zone). Activity on the dot: RunSummary.activity (new, additive) is the run's latest one-line activity — the newest assistant line, tool_call summary, or answering, whitespace-collapsed and capped at 120 chars, set by RunRegistry.publish() and forwarded through RunView for live rows — and the status dot's tooltip reads now: <activity> (starting… before the first event) so a glance answers "what step is it on" without opening the run; a finished row's dot reads <status> in <duration>. Native titles are gone from the row. Tooltip component: informational tips ride Nuxt UI's UTooltip (the old hand-rolled installTooltips/data-tip component is gone) — shown on hover and keyboard focus, placed/flipped/clamped by Reka UI's floating layer, text only, multi-line tips preserved. Informational tooltips are not shown on touch: the run row exposes the thread link and Stop/Kill through one ⋮ actions menu, the same control at every width (a pair of Stop and Kill buttons per row cost 7.6em of every line for two rarely used actions) — Stop/Kill are offered only while the run is stoppable, each item disabled while its POST is in flight, and the menu is absent when it would be empty; its cell keeps a fixed width so the facts columns line up on rows with no menu. Pager: ?all=1 pages are INDEX_PAGE_SIZE = 25 rows (the service cursor already existed; the page was rendering the 200-row cap): a nav.pager under the list — Older runs → when the page was full (item 17's cursor); on a cursor page ← Newest runs · runs finished before <Mon D, H:MM AM/PM> together on the left (the cursor stamp via formatDateTime, renderer's zone — item 21 replaced the earlier centered N shown, which said nothing a reader could use); none on the default view. A cursor page lists finished runs only — the service leaves the live rows off it, they all sort ahead of any cursor — and its feed only repaints rows it already has (the row is looked up for upserts and removals alike): a run starting or finishing now belongs on the newest page, never at the top of an older one. Expiry divider: with run history on (a known retentionDays), every finished row carries data-expires-at = finishedAt + retention; rows leaving within a day get class leaving, a gone <YYYY-MM-DD HH:MM> fact in the renderer's zone (exact time on hover), and sit under ONE cut — ⏳ Leaving within a day — each row says when it is removed — placed before the first such row (they are the oldest, so it is one cut in the newest-first list). The page re-places the divider after every feed change and once a minute (a row can age into its last day while the page is open) and removes it when nothing is leaving; the sorted row insert skips it. With history off there is no divider and no stamps (every finished row leaves within a minute; the toggle's tooltip says so). Run page: once finished the header says how long the run took — history pages from the record (finished · completed · 2m 27s, RunHistorySeed.durationMs), the live page at end from the first→last event stamps (finished · 2m 27s, stopped (soft) · 41s). A turn with no narration reads no commentary (not went straight to tools — the calls are the rows below). A tool whose summary is only its name (submit_verdict) shows the chip alone, no duplicated word. Layers: every floating layer Nuxt UI portals to the body — a row's ⋮ menu, the view-as combobox's list, a tooltip, a popover — paints at one layer, z-30 (POPUP_LAYER in web/src/lib/uiTheme.ts, set on each component's content slot through the Vite plugin's app config): above the shell's sticky header (z-20) and above anything a page raises, level with the slideover's overlay and content (z-30) — among equal z-indices DOM order decides, and a popup opened from inside the slideover is portaled after it, so it wins. A portaled popup has no z-index of its own and paints in DOM order among positioned elements, so before this a run row's body (z-[1] over its stretched row link) drew over the picker's list. A row also isolates its own stacking (isolate on li.run), so its body's z-index never reaches the page.

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

  22. Run header — the outcome chip; one status vocabulary. The header's two axes are separated for good: the ∿ pulse says only what the CONNECTION is (green connected · amber connecting · red disconnected) and does not exist on a history page (nothing is connected there); the RUN's state is its own element. The run outcome is separate from the connection state. completed reads succeeded; failed stays red; both stop statuses read stopped by user in a neutral chip. An interrupted run stays red. An unknown status reads ended. A provisional record reads unfinished — no finish recorded. Beside the duration the header shows what the run cost (costs.md item 4c): · $0.038 from the seed's cost (formatUsd), · unpriced when a model it ran on has no price, nothing for a record without usage; each model's figure is the element's hover text. A live page reads running · <stopwatch> — the WHOLE run, on the one duration definition (tracing.md runDurationMs): from the moment our process received the message (receivedAt, falling back to the registry's startedAt) to the server clock the seed carried (serverNow) projected forward by the browser time since the seed arrived — never an event stamp, never a server stamp minus the browser's clock — ticked each second and frozen at end at the value it had; the index row, runs list and the history seed print the same number — while connected; stopping / stop failed: keep the slot while a stop is in flight. At the live end a stopped run reads stopped by user; a successful Kill returns to the run list. Other ended runs show the grey ended + duration (an inline ⚠️ failure still arrives as an answer, so the live page never guesses "succeeded"; a reload shows the record's word). Between the finished frame and end the header reads delivering… · <total> (phase: finished — the total frozen at the frame's server stamp, the pulse still, the actions gone, the tab idle; a stop in flight keeps its word), and at end the duration gains the delivery caption from the frame's stamps (tracing.md): delivered in 2s (sealedAt − finishedAt, when replyOk is true), reply failed (false), nothing when no reply attempt was measured (a command run that fell through to the agent, a backstop or sweep seal, a stored stream's {}); a history page reads the caption from the record's sealedAt/replyOk. On the index a finished row whose stream is not sealed yet and whose record is not written (delivering, web/src/lib/indexRow.ts) is amber whatever its status and its dot tip ends · delivering the reply; a seal or a persisted record ends it. A run whose reply never settles stays amber until the registry evicts it.

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

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

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

  26. The run's files — /runs/:id/artifacts/<key> and the files in each message's card (execution.md item 20, run-visibility.md item 1, record 0033). A run's artifact events name the files it received from its thread and sent back by their store KEY; the page never sees a URL from the record. The route: GET /runs/:id/artifacts/<key> — the key is the greedy tail after /artifacts/, slashes kept, each segment percent-decoded; artifacts is a reserved path word like scheduled, never a run id (/runs/artifacts/… routes nowhere). The decision that opens the RUN opens its files, unchanged: the capability token (?t=) on a live run of this process, the Access actor's tokenless read (authorize(actor, "runs:read", runResource(view)), item 16) on a finished run — a refused viewer gets the same 404 no run found as an unknown id, with the reason on the audit line ({ route: "artifact", identity, denied }); a read of a key the run names — served or expired — writes one audit line naming the run, never the key, and a 404 for a key the run never named writes none (it is not a read of anything). Then the KEY is decided: only a key one of THIS run's artifact events names, or its validated saved checkpoint receipt names, is served — the store holds every run's files under one bucket, so a key from another run's events is as unknown here as a made-up one (404). For a checkpoint, the canonical publicationSettlement on the run takes precedence (reclaim and interrupted finish retain a present invalid receipt as null); only when absent may the stream projection supply it. The shared record/reader validator checks the original run, instance, step, repository, requester and thread plus the exact saved key. A reclaimed receipt therefore survives missing event delivery, while an invalid or conflicting canonical receipt cannot be replaced by an event. Checkpoint bundles download as application/octet-stream under checkpoint.bundle; reading them grants no publication or recovery-writer authority. A named key is streamed from a signed GET the store mints for this request (ArtifactStore.get; no URL is stored or handed to the browser), with the EVENT's contentType (never the object's, never sniffed: X-Content-Type-Options: nosniff), its length, Content-Security-Policy: sandbox so an HTML or SVG file cannot script as this origin, Cache-Control: private, no-store, and a Content-Disposition that is inline for the four raster image types (INLINE_IMAGE_TYPES: png, jpeg, gif, webp) and attachment for everything else — SVG and HTML included — under the recorded name's safeBasename. Byte ranges: the players seek by them, so every answer says Accept-Ranges: bytes and a request's single Range (bytes=start-end, bytes=start-, bytes=-suffix) is handed to the store's get and answered 206 Partial Content with Content-Range: bytes start-end/size and the part's Content-Length, the other headers as on the whole object; a range starting past the end is 416 with Content-Range: bytes */size and no body; a range the syntax does not admit (a list, another unit, an inverted pair) is ignored and the whole object streams as a 200, as RFC 9110 allows and every player takes. The pipe (pipeToResponse) honours backpressure and stops the moment the client is gone: a write that answers false waits for drain OR close, a closed or destroyed response ends the loop and cancels the store's stream (so the upstream signed GET closes too), and nothing is written to a destroyed response — a viewer who abandons a large inline image leaves no promise parked on a drain that will never come. A named key whose object is gone (the bucket's lifecycle rule) answers 410 artifact expired: files are kept for <retentionDays> days. Without an artifacts: section the route answers 404 and the seeds carry no artifacts. The seed: when a store is configured both run seeds carry artifacts: { urlBase: "/runs/<id>/artifacts/", retentionDays }, the live seed also token — the page builds each row's URL as urlBase + the key encoded per segment (artifactHref) and appends ?t=<token> on a live page, the same capability its stream holds. The files, in the message they belong to (web/src/components/run/MessageFiles.vue; RequestVm.files, ReplyVm.files, CallVm.files in the page model): a file shows where it was received or sent, the way the thread showed it, never as a step, a log row or a block of its own. The join is data, never inference: every input event names its message (messageId — Slack's ts; the run id for a channel without message ids; inbox-<seq> or at-<ms> for a follow-up with none, followUpMessageId), a received artifact event names the message it arrived with (the same messageId; a file re-pulled from an earlier thread message names THIS run's request, the message it was pulled for), and a sent one names the attach_file call that posted it (callId, the id the call's tool_call/tool_result carry; ToolContext.callId, set by both loops). The page binds by those ids alone — never by the order the events were recorded in, never by matching a file's name to a call's headline — and a file naming a message or call the record has not shown is dropped, not guessed onto the nearest card — a record written before the ids existed shows its files on no card for the days it lives, and the route still serves their keys; there is no fallback to order or to names. A received file (direction: "in") sits nested in the card of the message its messageId names — the Request, or the Follow-up it was dropped with — outside the Request's fold so it stays in view when the text is collapsed. A sent file (direction: "out") sits nested in the Reply once the Reply lands; until then, on a live page, it sits in the attach_file call card that posted it — the call its callId names — and that card opens so the picture shows where it was sent; once the Reply carries the files the call's list starts every row closed (openImages: false). Each nested list is a small Files · N heading and rows in event order with the direction word (↓ received / ↑ sent), the name, the size and type. The name is a disclosure, never a link (aria-expanded, keyboard-operable): it opens a panel under the row that renders the file where it was sent or received, with a short animated open and close (a one-row grid growing from 0fr to 1fr, off under prefers-reduced-motion). The panel is decided by the EVENT's recorded type (previewKindOf): a raster image (INLINE_IMAGE_TYPES) is an <img> from the route; a video/* file is a <video controls preload="auto"> and an audio/* file an <audio controls preload="auto"> — auto, not metadata: the player is mounted only once a person opens the row, so the cost is bounded, and a stream-copied MP4 keeps its index at the end of the file, where a metadata-only preload that fell back to a plain read stalled live — both from the route (same origin: media-src 'self' in PAGE_CSP, beside img-src 'self'); a text/*, JSON, NDJSON, XML or YAML file is its first 64 KB (TEXT_PREVIEW_CAP), read from the route and cancelled past the cap, with a line saying how much of the file is shown; anything else shows its facts and one explicit Download action — the only place a download exists on the page. Raster images start open; every other kind starts closed, so a large recording loads nothing until asked, and a panel opened once stays mounted so its close animates. A file the route no longer serves (an image or media element that errors, a text read answered 410) reads expired after N days in place of the panel and its name is text. The whole-object answer carries its length end to end: the route writes Content-Length, and the bot shim (deploy/cloudflare/knownLength.ts, wired in deploy/cloudflare/worker.ts) re-frames every bodied container answer that names a byte count through a FixedLengthStream of that size, so the runtime declares the length instead of relaying a stream of unknown length — live, a 31 MB 200 reached the browser without its length while the 206s kept theirs, and a player that falls back from ranges to a plain read it cannot size never finds an index at the end of the file; a body of another length fails the answer instead of lying about it, and an answer with no body, no byte count or a WebSocket passes untouched. The links come from the seed's artifacts through one provided value (ArtifactLinksKey); with no artifacts in the seed the rows are text. The turn that wrote the reply is captioned wrote the reply with the caption pointing at the Reply's card (#reply), true on both layouts — the Reply sits first on a finished page and last on a live one.

  27. A quoted conversation is its own frame (record 0037). The run timeline folds a reference event into one reference change beside context — never a step — carrying the block, the channel name, the count and the permalink, and drops a frame that cannot name its source (no url, no channel name, no count): the page never shows a quotation it cannot attribute. The page model collects the changes in state.references outside the log, in publish order. The page renders them as a Referenced threads fold (#references) immediately before the Earlier in this thread fold, in the same fold grammar — the small-caps summary with the rotating chevron and the count, collapsed by default — one row per thread: the channel name as the permalink (rel="noopener noreferrer"), the message count, the time at the right edge, and under it the quoted lines exactly as the model saw them minus the block's header and fence (quotedLines), rendered as text, never markdown. Order with every block present: Request, Reply, Referenced threads, Earlier in this thread, This run.

  28. The unit page — the unit is the reading unit (record 0034; agent-ship.md item 17, agent-conductor.md item 11, session-log.md item 11). GET /runs/unit/<key> (key the unit key <instance>:<unit>, decoded; unit a reserved path word like scheduled, so a bare /runs/unit routes nowhere) serves the shell with a UnitSeed: exactly what runs unit <key> answers under the viewer's runs:read predicate — the unit's facts and its instance's, and its runs in round order — plus the server clock and the retention days, a LIVE row carrying its capability token as an index row does (a finished row never). A unit that does not exist, a malformed key, a process without the coordinator's records and a viewer the predicate admits to nothing of the unit are the run 404 page (item 19), byte-identical; GET-only; no audit line, as the index writes none — the seed is run metadata, never content. The page (web/green/pages/Unit.vue, web/src/components/unit/FindingsBlock.vue (item 28: the Findings block), selected by web/green/routes.ts and rendered only for a unit seed) heads with the facts bar in the run page's grammar — unit U16 · the repository · the branch (a link to its tree) · #PR · the board issue · the unit's thread (a permalink when the channel has one, not opened yet before the runner opens it) and, on a row written before record 0055, its review thread · the round count — the standing in the header's status slot (the ending's kind once the unit has one; else the round in flight, round 1 · coding running, or the last round that ended; else Started only when the row records startedAt, otherwise State not recorded), a contract block naming the unit's plan and, for a later try, its pipeline ordinal, the plan's title for the unit, its outcome's report and the pipeline's own record (the instance's runId), then Runs by round: one row per run in the order runs unit answers them (coding 0, review 1, coding 1 …), each wearing round n · thread, the agent chip, the outcome, the started time, the one duration and the count, and a tokenless open ↗ to its page. A FINISHED row is a <details> that opens to the run's own timeline in place (web/src/components/runs/RunFoldRow.vue → web/src/components/run/RunTimeline.vue): the stored replay is read once from the tokenless GET /runs/:id/events (web/src/lib/sseReplay.ts parses the frames; the named transport frames are left out), normalized as the history seed is when the record carries span schema (untimed otherwise), folded through the run page's one model (createRunPageModel) and drawn by the run page's one component (TimelineSection.vue, item 25), so a unit's row and the run's page cannot disagree; a Longest-steps link opens the run's page at that row; a replay the route refuses reads as a failure with the way to the run. A LIVE row does not fold — its record is its token-gated stream — and draws where it will end up: in its round, its clock moving, linking to its live page with the token. ?open=<run id>[,…] opens rows on first paint. A unit's runs are its one thread's, cut by agent; an empty retained run list says No retained run evidence without claiming the pipeline never started or completed the unit. Search (web/green/components/ThreadSearch.vue; session-log item 11): one session at a time — the coding agent's or the review agent's, since the index is per session — through GET /api/runs.search?session=<key>&query=<words>&limit=20, the session key a run of the thread names (RunView.session.key) or the unit's working key <instance>:<unit>:<lane> (session-log.md item 13) when none has yet; each hit lists its turn, who spoke, the snippet with the route's untrusted fence removed (unwrapUntrusted, src/core/untrusted.ts — the page renders text as text) and the round and thread of the run whose recorded range holds it, a link that opens that run's fold on this page and lands it on the step the turn lives in (RunFoldRow.vue → RunTimeline.vue's land: once the record is read, that one step drawn under the timeline with the run page's own StepBlock.vue at the run page's own anchor — span-<turn span>, else call-<id> — scrolled to and flashed, with a link to the same step on the run's page; the request and the reply named, not drawn; a record whose tool events carry no logIndex lands nowhere and the fold opens as before — session-log.md item 11 has the rule, run-history.md item 53 the field); ?open=<run id>&turn=<n> lands on first paint; a hit past a compaction gap says past a compaction gap at turn n; a hit no run's range holds says before any recorded run; no hits and a refused search each say so in place; ?session=<thread>&q=<words> runs a search on first paint (a shareable search). What a run is the parent of, on the run page: a history seed carries children — the runs naming this run as their parent, runs children's listing under the viewer's predicate, a live child with its token — and a live seed the children the registry holds (the live path reads no store); the page lists them under Spawned runs as the same fold rows (RunChildrenBlock.vue), in start order, no round or thread. The pipeline's own record names its instance in its run_meta (instanceId, on the second run_meta the ship branch publishes at the hand-off — the page resolves the LAST run_meta carrying one, record 0060), and its history seed carries units — the instance's unit rows as UnitFacts (RunsService.listInstanceUnits, admitted by the instance's requester and channel exactly as a unit not started is) — which the page lists under Units (RunUnitsBlock.vue): each unit's id and title (its branch when the plan gave none), how it stands, its pull request, each opening its unit page. Neither block is drawn when the seed carries neither. The /runs index nests a pipeline's runs under their parent's row (item 33). The Findings block (agent-ship.md item 18; web/src/components/unit/FindingsBlock.vue): when the unit row names a pull request, the unit route reads the pull request's findings ledger under the same predicate — the read runs findings makes — and the seed carries it as view.findings (the rows as the service answers them; a page binds text as text), no key when no run the viewer may see names the pull request or the row names none. The page draws it between the contract block and the runs: a header naming the pull request and tallying the statuses in the vocabulary's order, one row per finding — its id, severity, file:line, title, status (a re-raise naming the kind it answered) and the disposition recorded against it — and a trail naming where it was raised, what answered it and where it was last seen, each stop a run: one on this page opens its fold in place as a search hit does, one outside it links to its own page; a ledger with no findings says the reviews listed none. The run page's Findings link: a history page whose record names a pull request (pullRequestNumberOf, run-history.md item 58) reads the ledger under the viewer's predicate and, when a unit row names the pull request, seeds findingsLedger: { unit, rows }; the facts bar then links Findings · <rows> to that unit page's block beside the pull request — no link when the record names none, the viewer may read no run naming it or no unit row names it.

  29. Runs index — whose run, and "show mine" (record 0042). Who asked: every row carries a requester cell after the started column — the resolved display name (RunView.userName), else the id suffix, never a raw platform id (whoText in web/src/lib/indexRow.ts, the source mark's own identity rule) — always visible, led by the surface's name as a text label (the channel id's prefix word — slack, http, mcp, cli, an unknown prefix as the word unknown — real text a reader needs no legend for, never a glyph; a chip of one fixed width, so the names after it align whatever the word; the surface also on data-surface), because one person arrives over several credentials (authorization.md item 15: a bound token's runs are the person's) and slack alice, http alice and cli alice must read apart without a hover; the source mark's hover kept saying it only to a pointer, and a page of rows never said whose runs they were. Its hover is the source mark's via <Surface> · <identity> sentence. From sm the cell is a fixed-width column (14em, the label included): a content-sized cell moved every row's agent chip to a different x, so the page read as a ragged list rather than a table; a long name truncates in the cell and reads in full on hover, and a row that names nobody keeps the empty cell so the columns still align. Browser owner selection: the trusted global user menu selects Mine, View as a person, or authorized All people across every page. Local checkboxes control completed and system run states only; only the completed preference is retained locally. Legacy ?mine=1 and runs list --mine APIs remain compatible for native and unscoped clients; they are not the green browser's owner selector.

  30. Global View as. The server stamps the actual authorized actor's selected person on every browser seed. The global user menu labels that view read-only and offers only the protected people roster. Mine exits through /runs/data-view; person selection posts /runs/view-as. A refused change shows the server sentence without reloading. The selector clears private browser caches before the request finishes. Native /runs/view-as/exit compatibility remains; all writes retain the actual credential and ordinary authorization.

  31. Navigation in place: one view, one seed, two encodings (record 0056). Every page route answers two encodings from the one view: a document request gets the shell with the seed as its island, and a request whose Accept names application/json and not text/html (wantsSeed, src/channels/webShell.ts) gets the same seed alone as JSON — the same status (a 404 page is a 404 seed), WEB_SEED_HEADERS (application/json, no-store, Vary: Accept; the HTML answer varies on Accept too), the same stamps: the page sender (makePageSender, the PageSender every view receives) stamps capabilities, viewingAs and the document title on the seed, so a view never writes any of them and the app can set the title on an in-app navigation. The body is built before the head is written, so a sender that throws is the view's one 502, never a head without a body. On the client every link to a page of the app is a RouterLink (the site nav, the tabs, the brand, the rows, the pagers, the back links, the run links inside a thread, a fold or a ledger), browser.navigate goes through the router for an app path once main.ts hands the router's push in (an address the page is already at reloads it — the way a link to the page you are on behaves — so a control that sends the index to itself, view-as entering, sees the page seeded anew; browser.leave is always the browser's), and a beforeResolve guard (installSeedRouting, web/src/lib/seedRouting.ts) loads the next address's seed (fetchSeed, web/src/lib/seed.ts: Accept: application/json, same-origin credentials) BEFORE the route resolves: the first navigation is the document's own and takes the island; a hash-only move keeps the page and its seed (pageAddress is path and query, never the hash); a navigation superseded while its seed loads never lands — superseded by any later navigation, a hash move included, since the router has dropped it: its late answer neither seeds a page nor leaves; an answer that is not a seed — a document, a file such as /runs/<id>/events, a JSON twin, a text error, another origin's login (a cross-origin redirect fails the fetch), a network failure — or a load that throws is a full navigation to the address (leave) and the in-app one is cancelled, so the browser shows what the address really is and nothing regresses below the full-load behavior. App.vue keys one SeedScope per address (pageAddress) around the RouterView, so a new address is a fresh mount of its page from the seed loaded for it — the same mount a full load gives, which is why no page had to change: each still reads its seed once at setup — and SeedScope provides the seed (SeedKey) and sets the title and the favicon from it (pageFavicon, src/channels/pageFavicon.ts, the rule the shell's <link rel="icon"> uses too; a page with a live count repaints both itself). While a seed loads the current page stays and a 2 px progress bar sits at the top of the viewport (role="progressbar", aria-label="Loading page"). The router scrolls a new page to its top, a hash to its element when the page has it, and the back button to where the reader left. Direct reads of window.location.search at a page's setup (the unit page's ?open, the costs page's ?days) stay correct: the router writes the address before the page mounts. browser.reload() (after a settings write, after exiting view-as) stays a full reload: the page must be seeded anew as the new state. The fixture preview (scripts/web-preview.ts) serves both encodings through the same sender, so npm run screenshots:gen and a hand in the preview navigate in place too.

  32. The stall signal — a hung bash and a slow suite read apart. Five side-by-side coding runs showed the same green dot and a growing duration while one was hung on a FIFO read; the only tell was an event count not moving between two screenshots. One pure rule, src/core/runPace.ts (node-free — the web bundle compiles it), shared by the status card and the index row so the two never say it differently. The facts: the registry tracks each live run's pace on publish() — the content events' clock stamps inside the five-minute window (PACE_WINDOW_MS; span records are timing, not activity; the ring is pruned per publish and hard-capped), the newest tool_call's stamp, and the call in flight with the bound it declared — and the LIVE summary carries them as eventsLast5m (always present on a live row, so a run hung before its first event still reads as one), lastToolCallAt and inFlight: { tool, since, boundMs? }; a finished row carries none (no pace to misread), and RunView/the index seed forward them for this process's live rows only — never a persisted row, never a ledger row live under another generation (item 41), so a row without the fact shows no signal rather than a false stall. The bound is the call's OWN declaration: pi's bash timeout (seconds, unbounded when absent — harness-pi.md) rides the tool_call event as boundMs; a call that declared none is never marked. The row (rowPace/rowStalled/rowBound/paceTip in web/src/lib/indexRow.ts; RunRow.vue): a pace cell after the count — 2.8/min (events per minute over the last window; a run younger than the window rates over its own age, floored at a minute), or no tool call for N min in amber once nothing has landed for a whole window (stalledFor; the clock ticks client-side, so a hung run's row moves even though its feed does not), or, winning over both, the bound-exceeded mark bash 2083s, bound 600s in red once the in-flight call outran its declared bound — each with a tooltip saying what it measures; a stalled row also wears a stalled badge (amber; red when the mark shows) and data-stalled, and the index sorts stalled live rows first (then newest-first as before, the sort still stable). The card: the live title's suffix says — bash 2083s, bound 600s instead of — running bash (2083s) once the call is past its bound (quietSuffix; inFlightCallAfter carries the bound beside the tool), and every live frame's title ends with · <pace> — the same paceText words — so the meta-monitor's number is on the product, not in Cloudflare logs. The pipeline row (inheritedPace/inheritedPaceTip in web/src/lib/indexRow.ts): a live hosted head, which makes no tool calls of its own, borrows the pace cell of its newest live child on the page — the tooltip names the child — and reads waiting on the runner when no child on the page is live or lends a pace (the tooltip says the runner is between children and the gap is not judged here); a finished head shows nothing. Related: the bash tool's bound (the deadline that should end a hung call, and did not fire in the cases above) and run-history.md (where the summary's fields are bound).

  33. The index nests a pipeline's runs under their parent; a spawned run's page names the way up (record 0060; agent-ship.md item 17, agent-conductor.md item 11). A ship pipeline's story used to interleave with every other run on /runs — the hosted parent, each unit's coding and review runs — and a child's page never said whose it was. The facts: a hosted parent's live views carry the instance it hosts as instanceId — the registry keeps it on publish() from the LAST event naming one (ship_handoff at the hand-off, then the hosted second run_meta — RunState.instanceId in src/core/runRegistry/state.ts, projected by summaryOf), a ledger row reads it from its mirrored events (instanceIdOfEvents in ledgerView), a persisted row from its record — so a pipeline's rows can name their parent wherever they are read: a unit's run by parentInstanceId, a conductor's child by parentRunId. The index (groupRuns in web/src/lib/indexRow.ts; RunsIndexPage.vue, RunRow.vue): a row whose parent is ON THE PAGE nests under it — indented, wearing nested and data-parent-id — children oldest-first (start order), the chain walked up so a grandchild lands under the top of its pipeline; a row whose parent is not listed stays top-level (nesting never hides a run; a malformed cycle leaves every row on it its own head); heads keep the index's own order — stalled groups first, a group stalled when any of its rows is (item 32), then newest-first — and the expiry divider (item 20) cuts by the head's expiry, a nested row leaving on its own schedule wearing its own gone … cell. The way up (RunLineageSeed in src/channels/webSeed.ts; RunsService.unitLineage): a history seed carries lineage — the record's parentRunId, or through its parentInstanceId the instance's own record (InstanceFacts.runId) and the unit whose thread the record's threadKey is (its key, id, title and which of its two threads — coding or review), admitted exactly as listInstanceUnits is, so a viewer outside the instance's channel simply gets no lineage; a live seed carries the parent the registry alone knows — parentRunId, or the hosted row whose instanceId the child's parentInstanceId names — and no unit (the live path reads no store). On both seeds a parent LIVE in this process's registry carries its capability token on lineage.parent and the page's link keeps it — a hosted parent is live for the pipeline's whole life, so a tokenless up-link could only answer the run 404 — the history seed only for a viewer whose predicate admits the parent's row, the live seed mirroring how a parent's page hands each live child its token; a finished parent never carries one. The run page draws a part of facts strip (#lineage) above the facts bar: pipeline › (parent run › when no unit is known) to the parent's page and unit U16 · <title> › to the unit page, the thread named beside them; no strip when the seed carries none.

The token-free PR-image route serves only deliberately published, separately marked PNG copies from the Worker. It never routes to this handler or accepts a run-artifact key. The run page and /runs/:id/artifacts/<key> retain their existing viewer authorization and attachment-event or checkpoint-receipt membership checks; ordinary attachments and inbound files do not become public.

Validation criteria ​

CriterionEvidence
An empty, missing or invalid capability does not open or stop a local live run, including a registry row restored with an empty token; a valid capability retains page/stream/control behavior. Tokenless history and stop still require their existing actor/entity decisions, and private worker tokens stay unavailable on ordinary routes.[unit] src/channels/liveView.test.ts::createLiveViewHandler (node:http)::404s the page for a wrong/missing token (never reveals the run exists), 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), src/channels/liveView.test.ts::run control: POST /runs/:id/stop::404s a wrong/missing token or unknown run (existence never revealed), control untouched, src/core/runsService.test.ts::RunsService.authorizeLive::returns null for a wrong token or unknown run and a working subscription for the right one, src/core/runsService.test.ts::RunsService.getRun::hides a private worker's live and persisted prompt from ordinary run reads, src/channels/liveView.test.ts::live view on RunsService: history pages + index toggle::the viewer's actor binds the index and the tokenless history routes (authorization.md items 5–7)::a tokenless finished run the viewer may not read is the same 404 as an unknown id…
The run page's identity survives a container replacement (run-history.md item 54): a run restarted from its request re-creates its registry row under the same id and token — the posted capability link still validates, one row on the index — and, end to end, the restarted run's original id serves its page's reads (the registry row live then finished-completed under the one id, no second id) with the replacement as one resumed note in the transcript[unit] src/core/runRegistry.test.ts::RunRegistry.create …::a restart that keeps its predecessor's identity…, src/core/dispatcher.test.ts::run ledger write-through …::a resume whose recorded resident refuses to reuse the worktree provisions no sandbox…, ::a relaunch whose re-attach the resident refuses…
Item 28, the route: /runs/unit/<key> parses with its decoded key, a bare /runs/unit, a malformed key and a second segment do not; the seed is the unit's read with its facts, its instance and both threads' runs in round order, a live row's token re-attached and none on a finished row, the title naming the unit; an unknown unit, a malformed key, a process without the coordinator's records, a viewer whose channels exclude the unit and an actor with no grants are the run 404 page byte-identical; POST is 405[unit] src/channels/liveView.test.ts::parseRunRoute::matches the unit route with its decoded key…, src/channels/liveView.test.ts::the unit page and what a run is the parent of (item 28)::seeds the unit page from the unit's read…, src/channels/liveView.test.ts::the unit page and what a run is the parent of (item 28)::an unknown unit, a malformed key…
Item 28, the parent's seeds: a history page seeds the runs its run spawned in start order with a live child's token and the pipeline's own record seeds its instance's units — the instance resolved from the LAST run_meta carrying one — a run with neither seeds neither key; a live page seeds the children the registry holds, each with its own token; the parent record's run_meta names its instance[unit] src/channels/liveView.test.ts::the unit page and what a run is the parent of (item 28)::a history page seeds the runs its run spawned…, src/channels/liveView.test.ts::the unit page and what a run is the parent of (item 28)::a live page seeds the children the registry holds…, src/channels/adminCoordinator.test.ts::*::*finish writes the parent's record*
Item 28, the hosted parent's steps: the run page model draws a ship_unit fact as a step — the unit and its state head the row, the ending's report (else the thread's lead) is the detail; a fact without a unit or state draws nothing[unit] web/src/lib/runPageModel.test.ts::ship_unit — the hosted parent's unit facts draw as steps (record 0060)::*
Item 28, the page: current rows lay out in round order wearing round and thread, agent, outcome and a tokenless link; the facts bar names the unit, repository, branch, pull request, issue, its thread and the round count, retaining a pre-0055 review thread when present; the count names one unit thread; the contract block names the plan, a later pipeline's ordinal, title, report and the pipeline's record; a unit without retained runs keeps its pipeline provenance; absent rows do not prove its lifecycle; the standing reads recorded outcomes[unit] web/src/pages/unitPage.test.ts::UnitPage — the unit is the reading unit (item 28)::lays out both threads' runs in round order…, web/src/pages/unitPage.test.ts::UnitPage — the unit is the reading unit (item 28)::names the unit's one thread, phrases the pipeline ordinal, and shows missing retained evidence without inventing a lifecycle
Item 28, the fold: a row opens to the run's timeline read once from its events route through the run page's fold and component, the Longest-steps link opens the run's page at the row, reopening reads nothing again; a refused replay reads as a failure with the way to the run; ?open= opens a row on first paint[unit] web/src/pages/unitPage.test.ts::UnitPage — the unit is the reading unit (item 28)::a row opens to the run's own timeline in place…, web/src/pages/unitPage.test.ts::UnitPage — the unit is the reading unit (item 28)::a replay the route refuses…, web/src/pages/unitPage.test.ts::UnitPage — the unit is the reading unit (item 28)::?open=<id> opens that run's fold on first paint, web/src/lib/sseReplay.test.ts::parseSseReplay — the stored replay as frames for the fold::*
Item 28, the edges: a unit with one thread lists its runs by agent, shows no review thread cell, and searches both sessions on that thread; missing run rows show no retained run evidence and unknown state; a run in flight is a row in its round with its clock moving and a tokened link, the header naming the round that runs[unit] web/src/pages/unitPage.test.ts::UnitPage — the unit is the reading unit (item 28)::a unit with one thread lists its runs by agent…, web/src/pages/unitPage.test.ts::UnitPage — the unit is the reading unit (item 28)::a run in flight is a row in its round…
Item 28, the search box: one session per ask with the thread picked, the words and the page's cap; hits with turn, role, the unfenced snippet and the round and thread of the run whose range holds them; a hit opens its run's fold; a hit past a gap names it; a hit no run holds says so; no hits and a refused search say so in place; empty words ask nothing; ?session=&q= searches on first paint[unit] web/src/pages/unitPage.test.ts::UnitPage — the unit is the reading unit (item 28)::the search box asks the route for one session at a time…, web/src/pages/unitPage.test.ts::UnitPage — the unit is the reading unit (item 28)::no hits and a refused search each say so in place…, src/core/untrusted.test.ts::unwrapUntrusted — the body of a fence, for a surface that renders text as text::*
Item 28, the landing: a hit opens its run's fold at the step its turn lives in — the run page's own block at the run page's anchor, scrolled to and flashed, linking to the same step on the run's page; the record is read once for every landing; a turn no event names lands on the step that wrote the row before it; the request and the reply are named, not drawn; a record without rows lands nowhere and the fold opens as before; ?open=&turn= lands on first paint[unit] web/src/pages/unitPage.test.ts::UnitPage — the unit is the reading unit (item 28)::a hit lands on the step its turn lives in…, web/src/lib/runPageModel.test.ts::landing — the step a session-log turn lives in (session-log item 11)::*
Item 28, the dispatch and the parent's blocks: /runs/unit/:key renders the same unavailable-work state without run controls for a runNotFound seed or no seed, and the unit page for a unit seed; a conductor's page lists its spawned runs as fold rows in the seed's order, a live child linking with its token, and opening one reads its replay; the pipeline's record lists its units each opening its unit page with its standing and pull request; a run with neither shows neither block[unit] web/src/pages/unitPage.test.ts::UnitRoutePage (the /runs/unit/:key dispatch)::*, web/src/pages/runPage.test.ts::RunPage — history mode::a conductor's page lists the runs it spawned…
Item 28, live: the unit of a real ship read on its page[agent] (human-gated.) After a ship on the deployed bot, open /runs/unit/<instance>:<unit> behind Access (the instance id is the card's plan-<plan>[-<attempt>], the unit the plan's id for it, or the generated plan's one unit for a task) — expect coding 0, then each review round and findings step as the card drew them, a finished row opening to its timeline, the plan's record linked from the contract block and listing the unit back; search the coding thread for a word the coding run used and expect a hit naming its round.
Item 28, the Findings block: one row per finding with id, severity, file:line, title, status (a re-raise naming the kind it answered) and the disposition's note under a header tallying the statuses; the trail names each run by its round and thread, a click opens that run's fold on this page, a run outside the page links to its own page; an unknown id reads without a place; absent from the seed the block is not drawn and an empty ledger says the reviews listed none[unit] web/src/pages/unitPage.test.ts::UnitPage — the Findings block: the pull request's ledger beside the runs (agent-ship item 18)::*
Item 28, the Findings seeds: the unit page's seed carries the ledger when the unit row names a pull request, each run with its round, and no key for a unit naming none; a run's history page names the unit page its ledger lives on with the row count, none for a run naming no pull request[unit] src/channels/liveView.test.ts::the unit page and what a run is the parent of (item 28)::the unit page's seed carries the pull request's findings ledger…
Item 28, the Findings link: a history page whose seed names the ledger's unit links Findings · <rows> beside the pull request; a seed without one draws no link[unit] web/src/pages/runPage.test.ts::RunPage — history mode: the Findings link (agent-ship item 18)::*
Item 27: a reference event is one reference change with its url, channel name, count and block, never a step; a frame missing any of the three is dropped; the page model collects references outside the log in publish order and leaves context and log untouched[unit] src/channels/runTimeline.test.ts::createRunTimeline — reference frames::*, web/src/lib/runPageModel.test.ts::request / context / reply / placeholder::collects referenced conversations outside the log, in publish order, with the channel name and permalink (record 0037), ::quotedLines drops the block's header and fence and keeps one line per message; a block without a fence keeps everything after its header, web/src/pages/runPage.test.ts::*::a quoted conversation is a Referenced threads fold before Earlier in this thread — the same fold grammar, one row per thread with its channel as the permalink, the count and the time, the quoted lines under it without the fence
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 the proxy ends after the answer's last byte, carrying the model, the stop reason and the provider's usage when reported; 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/channels/modelProxy.test.ts::the meter — one model.turn span per proxied call, the runner's attrs::*, src/core/modelProxy/usage.test.ts::*, src/core/harness/pi/bridge.test.ts::the bridge's spans — one tool.<name> per call under run.agent, ended with the call's outcome::*, 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
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: a run's model.turn spans carry no blocks, thinkingMs or textMs — the stream timings only the deleted native runner's observer wrote; a record from before the deletion carries them[unit] src/channels/modelProxy.test.ts::the meter — one model.turn span per proxied call, the runner's attrs::a streamed Anthropic reply is forwarded chunk for chunk in order with its headers, and the span ends only after the last chunk with model, stop reason, the four token counts and the time to first token
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)
The shared spawn capability can act only while its original stop control owns the active registry row. A resumed row with the same id and token does not restore the old runner's launch authority.[unit] src/core/dispatch/spawn.test.ts::child launch after asynchronous waits::*
create() mints distinct, unguessable id + token; defaults are crypto-random[unit] src/core/runRegistry.test.ts::RunRegistry.create::*
Admission is durable before attachment, and queued plane rows have no live state (items 1–2)[unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::the run is reserved on the ledger BEFORE the workspace attach…, deploy/cloudflare-memory/runLedger.test.ts::the plane's admission stage — /plane/admit, reservations, the seal's walk (orchestration-plane; record 0064)::two asks a second apart on one thread…
Setup outlives a resident wait without renewing its admission deadline: the same advertised child reaches the fallback workspace, durable and live projections agree, and replay starts no duplicate child or writer (item 1)[unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::coordinator producer identity::continues the advertised child after the %s bound expires within its admission budget
A reclaimed nonterminal run opens one admitted setup segment on the same row under its remaining budget, then reaches preparation through the ordinary state table. The fenced transaction keeps its original workspace, transcript, deadline and child identity; stale sequences, foreign owners and terminal states remain refused. The Worker transport preserves validated refusal causes and treats an uncertain acknowledgement as unavailable. The coordinator sees child_resumed only after durable assignment and registry projection both accept; either refusal holds a pilot workspace with its precise cause and one supported next action.[unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::a resume re-attaches its recorded resident worktree…, ::a resumed pilot holds its original workspace after a %s and never signals child_resumed, src/core/runLedgerWorker.test.ts::WorkerRunLedger::keeps live-state refusals precise through the Worker transport and write-through, ::does not mistake an unverified live-state acknowledgement for an unknown run, src/core/runLedger/decisions.test.ts::assignLedgerLiveState — one resumed setup segment::reopens admission under the saved bound without adding a backward edge to ordinary live state, src/core/runLedger/inMemory.test.ts::InMemoryRunLedger::commits one fenced resume boundary after a provider hold without replacing its history, deploy/cloudflare-memory/runLedger.test.ts::run ledger — steps, events, inbox, state (items 30–31)::the SQLite ledger fences and sequences a resumed setup boundary after provider wait
At or after the overall deadline, fallback, preparation and model admission end with a typed budget outcome on the same child and release the selected workspace, never assign an expired state. The refusal and reply quote the budget sentence, and the exhaustive renderer inventory accounts for this producer proof (item 1)[unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::coordinator producer identity::ends the same child on its typed budget at %s with %s ms overrun; src/core/dispatch/reply.test.ts::renderRefusal — the one rendering of a Refusal::renders every gate sentence and all eight reference reasons byte-identical — the producing sites' own builders diffed against the inventory's quotes
PR-facts reads that reach or exceed the fixed admission deadline end the same child with the typed budget outcome, release its workspace once and leave no model lease, tool call or duplicate writer; spawn replay returns the original child (item 1)[unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::coordinator producer identity::ends the same child when PR facts consume its admission budget by %s ms
A live-state transaction failure exposes neither its event nor projection; streamed events remain ordered and publish through projection failure (item 2)[unit] src/core/runLedger/inMemory.test.ts::InMemoryRunLedger::an injected failure before commit exposes neither the event nor the live-state projection, src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::serializes every streamed event behind durable tool projection and still publishes a tool event when projection fails
A provider recovery cannot overtake the park that established its waiting state (item 2)[unit] src/core/providerLiveStateCoordinator.test.ts::provider live-state coordinator::finishes a provider park before a later recovery, so the durable and live projections end working
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`…`
The third state (run-history item 27): a provisional tombstone row reads unfinished — no finish recorded with an amber dot, the run page's outcome chip says the same word amber, and an unflagged interrupted record keeps its red dot and chip[unit] web/src/lib/indexRow.test.ts::status vocabulary::provisional: a tombstone-first record still in its window renders as 'unfinished — no finish recorded', amber dot…, web/src/pages/runPage.test.ts::RunPage — history mode::heads with the outcome chip + duration (item 22)…
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, and a direct message (slack:D…, the web chat's lane) → DM with no hash unless the adapter resolved a name; 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/green/pages/Run.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: mode=stop interrupts the turn and forces cleanup; mode=kill suppresses the reply and deletes the row after confirmed shutdown. Legacy soft/hard modes keep their original non-destructive behavior. Missing and unknown modes return 400. Wrong capabilities return 404. Stop on a finished run returns 409. GET on stop returns 405 allow: POST; other run routes remain GET-only[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…
A valid token hard stop writes durable stop intent before a retry handoff and seals a paused same-generation row even while it remains in the registry[unit] src/channels/liveView.test.ts::run control: POST /runs/:id/stop::a token-gated hard stop durably seals a retry pause still in the registry, ::returns 503 when a token-gated hard stop cannot be recorded in the ledger
Item 20, the layers: every popup's content carries the one layer above the shell header; a run row isolates its own layering; an opened run row menu paints at that layer; the global user menu has its own protected shell layer[unit] web/src/lib/uiTheme.test.ts::*
Index actions offer Stop and Kill. Stop requests a terminal stopped result. Kill asks for confirmation and sends mode=kill. Stopped records use a neutral badge.[unit] web/src/components/runs/RunRow.test.ts::the actions cell is always present (fixed width) and never a pair of buttons; Stop/Kill are items of the one ⋮ menu, offered only while the run is stoppable; the menu is absent when it would be empty, ::Stop POSTs the token-scoped soft stop; the item disables while in flight, ::Kill confirms first (destructive), POSTs mode=kill on yes, does nothing on no; a failed POST re-enables the item, ::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, ::keeps Kill available on an unfinished…
Run-page controls send mode=stop or mode=kill. Stop shows stopping until a terminal result arrives. Successful Kill returns to the run list. A refused request re-enables the controls.[unit] web/src/pages/runPage.test.ts::RunPage — live mode::Stop POSTs mode=stop on the run's stop URL and reads \stopping`; the end then says stopped by user, ::Kill confirms first and returns to the run list; 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, ::keeps Kill on the details…, ::reads disconnected when the stream closes for good`
Hosted parent controls require the operator route and confirmed runner shutdown. User Stop ends the parent with a stopped status. Capability-token controls cannot stop a hosted runner.[unit] src/core/runRegistry.test.ts::RunRegistry — a hosted run refuses the stop that would do nothing (record 0060)::*, src/core/runsService.test.ts::RunsService.stopRun — a hosted parent: soft refused, hard seals (record 0060)::*, src/channels/liveView.test.ts::run control: POST /runs/:id/stop::409s both modes on a hosted run…, ::the tokenless hosted hard stop requires a confirmed pipeline stop: %s, src/core/commands/runs.test.ts::runs.stop::a hosted parent refuses the soft stop…, src/core/coordinator/driver.test.ts::the plan runner's driver — the hosted parent's hard stop stops the runner (record 0060, issue 1924)::*, src/channels/adminCoordinator.test.ts::the runner's routes read the hard stop's mark (record 0060; issue 1924)::*
A hosted parent's live seed carries no stopUrl and the run page draws no stop control; a normal run's seed is unchanged[unit] src/channels/liveView.test.ts::run control: POST /runs/:id/stop::the live page seed of a hosted run carries no stopUrl…, web/src/pages/runPage.test.ts::RunPage — live mode::a live seed without a stopUrl draws no stop control (a hosted parent)
Live Stop interrupts the current turn, cleans up the runtime and retains a neutral stopped row. Live Kill sends no final reply and removes the row after confirmed shutdown.[gap] On a disposable deployed run, click Stop and observe its retained result. On a second disposable run, confirm Kill and verify that the row and run page are gone. Verify process cleanup from the original runtime receipt.
Ordinary runs, including web main-agent chats, show the final answer on the run page (item 11); a verified private Slack DM main run shows only a placeholder there and keeps the answer in its requester DM and model session[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), src/core/runRegistry.test.ts::RunRegistry.create::keeps a main agent's Slack read private in live, replayed and finished views, src/core/runRegistry.test.ts::RunRegistry.create::keeps a web main agent's answer in live, replayed and finished views, src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::keeps a web main agent's tool result in the durable event stream; [agent]Stop a disposable coding run from/runs. Verify that the thread and run page show ⏹ Stopped by user.` and the row has a neutral terminal status.
A main run without verified private-DM provenance keeps its repository and branch facts in its live row, reservation, claim and finished record; a verified private main DM omits those facts[unit] src/core/dispatch/provision.test.ts::registerRun — the run's row on every surface before the attach::keeps repository facts on a non-private main run's live row, ::gives a main DM a generic run label and no private request or repo facts on the live page, src/core/dispatch/provision.test.ts::reserveRun — the ledger reservation before the attach::keeps repository facts on a non-private main run's reservation, src/core/dispatch/run.test.ts::claimRun — the ledger claim once the prompt exists::keeps repository facts when a non-private main run claims its row, src/core/dispatch/record.test.ts::registerFinishRecord — the finish record, written by the drain after the reply::keeps repository facts in a non-private main run's finished record, ::omits repository facts from a verified private main run's finished record
A non-private main run keeps its completed-card checklist and debug timing; a verified private DM keeps those details hidden[unit] src/core/dispatch/reply.test.ts::deliverAnswer — the answer reaches the thread::keeps the checklist and diagnostics on a non-private main run's completed card, ::rechecks a stamped private run without a Slack context binding before its reply, src/core/dispatcher.test.ts::closed-card checklist and review verdict run link::keeps a non-private main run's queued detail on its completed card
context events render into the collapsed Earlier in this thread block between the Request and the This run heading via the markdown guard; a seeded page feeds its escaped JSON seed through the one fold; hostile seeded text opens no tag; no seed → placeholder kept[unit] web/src/lib/runPageModel.test.ts::request / context / reply / placeholder::collects context turns outside the log, web/src/pages/runPage.test.ts::RunPage — history mode::collects context turns into the collapsed Earlier-in-this-thread block with a count, ::renders hostile model text as data, never markup, src/channels/webShell.test.ts::serializeSeed::*
The Request block shows https://github.com/o/r/pull/1 please review & fix @user in #general (markdownLite links the url), not raw <https://…|…> mrkdwn or &amp;; context turns are humanized the same way; the answer is never unescaped[unit] src/core/dispatcher.test.ts::input / context / answer events in the run stream …::input and context text is humanized before publish …, ::\<url…label>`: Slack's auto-link label … collapses to the full url …, ::the model's own answer is never entity-unescaped …`
Index empty-state sentinel hidden while rows exist[unit] web/src/pages/runsIndex.test.ts::RunsIndexPage — toolbar, states, pager::shows the empty sentinel per view, and the store-degraded banner when the seed carries one, ::RunsIndexPage — the live feed::default view: a finished upsert removes the row; the empty sentinel returns when the last row leaves
The bridge emits assistant only for text alongside a tool call, before that turn's tool_call; never for a tool-less turn or the final text-only answer; redacted, uncapped[unit] src/core/harness/pi/bridge.test.ts::turns, narration and the answer — the loop's rules::*
Dispatcher publishes input (full authored text for operator binds, legacy 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)
Routine run notices use info colors: successful compaction, resumption, bounded retries, completed drain waits, stop acknowledgments, changed review heads, confirmed requests and handled tool lifecycle updates. Live and history pages use the same treatment. Refused retry prompts, storage failures, failed runs and unknown notice kinds keep warning colors.[unit] web/src/pages/runPage.test.ts::RunPage — recovery notices::*
Model changes use info badges in step heads and standalone turn rows. Connecting and stopping indicators use info; failures and uncertain outcomes retain their existing status colors. Fixture and read-only notices use info.[unit] web/src/pages/runPage.test.ts::RunPage — live mode::*, web/src/pages/runsIndex.test.ts::RunsIndexPage — the live feed::connection indicator…; [agent] Build the web app, run npm run web:preview, and inspect /runs/hist-1 and /runs?all=1&viewing=1 in light and dark themes. Confirm blue routine notices and yellow problem notices.
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 no run 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/green/pages/Run.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 a normal stopped result, 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::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 the refresh instances the pass created (refreshed — a skipped or duplicate instance is not one) / 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 no run 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; 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 global shell uses capability-backed report links and constant Home, run details, Settings, and docs paths without tokens[unit] web/src/components/AppNav.test.ts::Global shell navigation::renders capability-backed work links and constant run, settings, and documentation paths without tokens, web/src/components/AppNav.test.ts::Global shell navigation::removes unavailable report and schedule paths while keeping Home, run details, Settings, and docs
Documentation and labelled theme controls remain reachable in the phone navigation[unit] web/src/components/AppNav.test.ts::Global shell navigation::uses the server-owned docs destination in a protected new tab on every installation, web/src/components/AppNav.test.ts::Global shell navigation::exposes the phone menu and appearance controls with accessible labels
Appearance persists by actual actor and organization across Mine, Person and All; another account or organization and missing scope do not inherit a browser-wide preference.[unit] web/src/components/AppNav.test.ts::Global shell navigation::keeps appearance with the authenticated actor and organization across read-target changes, ::does not inherit a browser-wide or unbound account appearance
Scheduled work is linked only when configured or already selected[unit] web/src/components/AppNav.test.ts::Global shell navigation::removes unavailable report and schedule paths while keeping Home, run details, Settings, and docs, web/src/components/AppNav.test.ts::Global shell navigation::keeps Scheduled work reachable when already on its route even if firing history is unavailable
?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 (owner dimmed, then the name — it must read as a repository, not as another surface label; 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…
The run view retains live count, completed-state control, and connection state; global navigation gates Scheduled work[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/runsIndex.test.ts::RunsIndexPage — the live feed::connection indicator…, web/src/components/AppNav.test.ts::Global shell navigation::keeps Scheduled work reachable when already on its route even if firing history is unavailable
/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 …
The header's cost beside the duration (item 22; costs.md item 4c): the seed's cost as dollars, unpriced when a model has no price, nothing without usage, the per-model figures on hover[unit] web/src/pages/runPage.test.ts::RunPage — history mode::heads with the run's cost beside the duration (item 22): dollars, unpriced for a model without a price, nothing without usage; the per-model figures on hover
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; the anchor is seeded from the live seed's server clock and never moves backwards — a fresh page load or a reconnect's replay shows an open call's true age, never a count restarted at zero; 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::header stopwatch (item 22)::a page load seeds the runner clock from the seed's server clock: a replayed open call is timed by its true age, never by its arrival, web/src/lib/runPageModel.test.ts::header stopwatch (item 22)::a reconnect's replay of a stamped event never drags the clock backwards — only a stamp ahead of the projection advances the anchor, web/src/lib/runPageModel.test.ts::header stopwatch (item 22)::before any stamped event a seeded clock projects, but the wait is still \starting`, web/src/pages/runPage.test.ts::RunPage — live mode::a reload's replayed open call is timed by its true age: the seed's server clock anchors the runner clock, so a refresh never restarts the count, 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
Item 19, the binding's republish: a resident run whose attach bound a ref or head other than the one resolved publishes the meta once more from its own binding, after the reservation's and before the run loop — so every redraw, the first frame included, names the run's own branch[unit] src/core/dispatcher.test.ts::repo/ref resolution + resident prompt selection::a resident run whose attach binds a branch other than the resolved one republishes the meta from the run's own binding, before the loop
run_meta published once per run right after input with agent/model, the harness and whose word put the run on it (harness.md item 10) and the resolved repo/ref/pr/headSha; folded to meta keeping only well-typed fields (a harnessScope the roster does not know dropped); accepted by the capture parser; ignored by friction; rendered as <harness> harness (<scope> scope) and 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
Command and door rows are hidden from the Runs list by default; Show bookkeeping runs reveals them and names the controlled list for assistive technology (item 17)[unit] web/src/pages/runsIndex.test.ts::RunsIndexPage — toolbar, states, pager::hides command and door rows by default; the named toggle reveals the bookkeeping runs
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 row's ⋮ menu, the same control at every width, 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 red, stopped by user neutral); 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 and nothing visible otherwise — the requester cell already names the surface; 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 with a thread on prod /runs?all=1 → the ↗ appears, its hover names the surface and user, clicking it opens the thread; a row without a thread shows no mark
Repo tag: owner/name with the owner dimmed (never the bare name, which read as a surface label), 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 (owner dimmed, then the name — it must read as a repository, not as another surface label; 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, neutral stopped by user, 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…, 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 width (the ⋮ menu 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) and never a pair of buttons; Stop/Kill are items of the one ⋮ menu, offered only while the run is stoppable; the menu is absent when it would be empty, ::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::* | | Files (item 26), the route: the key is the greedy per-segment-decoded tail and artifacts is never a run id; a finished run's named key streams with the event's type and length, nosniff, Content-Security-Policy: sandbox, private, no-store, inline for a raster image and an attachment under the safe basename for SVG and HTML, and Accept-Ranges: bytes on every answer; a single Range (closed, open-ended or suffix) is a 206 with Content-Range against the object's size and the part's length and bytes, a start past the end a bodiless 416 naming the size, a list of ranges ignored for the whole object as a 200; the type is the event's, not the object's; a key with neither that run's attachment event nor a valid checkpoint receipt — another run's included — and an unknown run are 404 no run found; a named key with no object is 410 … kept for 30 days; a refused viewer gets the 404 with { route: "artifact", denied } on the audit line, a read of a named key one line naming the run, and an unnamed key's 404 no line; the pipe honours backpressure (drain), ends the loop and cancels the store's stream when the client goes away mid-download, and writes nothing to a destroyed response; the history seed carries artifacts without a token and nothing without a store (the route 404s); a live run's token opens the key, no token or a wrong one is 404, and the live seed carries the token | [unit] src/channels/liveView.test.ts::parseRunRoute::matches the artifact route…, src/channels/liveView.test.ts::artifact route (item 26)::*, src/channels/liveView.test.ts::artifact route (item 26)::pipeToResponse::* | | Files (item 26), the length end to end: the shim re-frames a bodied container answer naming a byte count through a fixed-length pipe of that size — status, headers and bytes intact, a 206 alike; an answer with no body, no Content-Length, a length that is not a byte count, a zero length or a WebSocket upgrade passes as the same object; an upstream body of another length fails the answer with the pipe's words. The opened players are preload="auto" | [unit] deploy/cloudflare/knownLength.test.ts::withKnownLength — the container's answer keeps the length it named::*, web/src/components/run/MessageFiles.test.ts::MessageFiles::a video row starts closed and mounts nothing; opening it renders a <video controls preload=auto playsinline> from the route on a finished page (no query); audio likewise | | Files (item 26), checkpoint recovery: the validated saved receipt on a reclaimed record authorizes its exact bundle with no stream event; an invalid or conflicting canonical receipt cannot be overridden by an event, and foreign, missing-receipt and denied-viewer requests fail | [unit] src/channels/liveView.test.ts::artifact route (item 26)::a finished run, tokenless::serves a verified private checkpoint only through the run that recorded it:… | | Files (item 26), the fold and the placement: an artifact event becomes one artifact change (no step, keyless/nameless ignored); the fold keeps each file's join key (messageId on a received file, callId on a sent one) and ignores an event without it; the page model puts a received file on the card of the message its messageId names (the request's files, or that follow-up's — whatever was recorded between) and never in the log, drops one naming a message the record has not shown, puts a sent file on the call its callId names (two files of one name from two calls each on their own; none when the record shows no such call) and opens that card, and hands the reply every sent file when it lands; a run without artifact events has empty lists; artifactHref is the base plus the per-segment-encoded key with ?t= only when the seed has a token; the nested list's names are disclosure buttons and never links: an image row starts open with its <img> carrying the token on a live page and no query on a finished one, a video row starts closed and opens to a <video controls preload="metadata"> from the route (click and keyboard, aria-expanded follows), an audio row to an <audio controls>, a text row fetches the route once and shows at most 64 KB with a "showing the first" line when cut, a zip row shows its facts and the one Download action (never on the name), an SVG is never inline, openImages: false starts every row closed, an errored image or player reads expired after 30 days in place of the panel with the name as text, and text rows with no store; the reply-writing turn's note is wrote the reply pointing at #reply | [unit] src/channels/runTimeline.test.ts::createRunTimeline — artifact::*, web/src/lib/runPageModel.test.ts::artifacts::*, web/src/components/run/MessageFiles.test.ts::MessageFiles::*, web/src/lib/runPageModel.test.ts::steps and turns::* | | Files (item 26), live: a run that received a file with its request and another with a follow-up, and sent an image and a video back, shows each file nested in that message's card — the request's and the follow-up's under their text, the image inline and the video as a link under the reply — with no standalone files block on the page; a key the store no longer holds renders as expired in place of the picture; the route serves a named key with the event's type and length and refuses a key with neither an attachment event nor a validated checkpoint receipt | [agent] behind Access, open the page of a run that received a video on its request and a file on a follow-up and attached an image and a clip (the execution spec's live rows leave such a run): expect the received rows inside the Request and the Follow-up cards, the image inline and the clip as a link inside the Reply, nothing below the reply, and the image's response headers Content-Type, Content-Length, nosniff and Content-Security-Policy: sandbox; GET /runs/<id>/artifacts/<a key the run never named> → 404; for the expired row, open the page of a run older than retentionDays (or delete one object from the bucket in a test deployment) and expect expired after N days where the picture was; receipts on the receipts issue | | Requester cell (item 29): the resolved name, else the id suffix, never a raw platform id; always visible (no hover reveal); an empty cell of the same width when the row names nobody | [unit] web/src/components/runs/RunRow.test.ts::RunRow::says who asked, always visible (record 0042, the runs page): the resolved name, else the id suffix, never a raw platform id; its hover is the source mark's sentence | | Requester surface label (item 29): the cell leads with the surface's name as text — slack, http, mcp, cli, an unknown prefix as unknown — never a glyph, in a chip of one fixed width so the name after it starts at the same x on every row, the surface on data-surface, so one person's Slack, HTTP and CLI rows read apart | [unit] web/src/components/runs/RunRow.test.ts::RunRow::leads the requester with the surface's name as a text label — the channel id's prefix word, readable text, never a glyph — so the same person's Slack, HTTP and CLI runs read apart | | Requester cell is a fixed-width column from sm (item 29): 14em for every row, the surface label included, a long name truncated with the full name on hover, a nameless row the same width | [unit] web/src/components/runs/RunRow.test.ts::RunRow::the requester cell is a fixed-width column from sm (item 29): a long name truncates in the same 14em every row gets, and the full name is the hover; a nameless row keeps the same width | | ?mine=1 (item 29): a linked session lists its person's runs alone, a private one included and a public one of someone else's not; an unlinked session lists only the runs requested as its own access:<sub> (none in this fixture, which started no run from the Threads chat) and the fleet admin nothing of their own; the seed carries mine and asUser; the narrowing is the store's own filter (readable ∧ own); the feed and the pager stay in the view | [unit] src/channels/liveView.test.ts::…::?mine=1 (record 0042): a session linked to its Slack person lists that person's runs alone — a private one included, a public one of someone else's not; the unlinked session and the fleet admin list nothing of their own; the seed carries the view and the person; the feed and the pager stay in the view | | The global user menu is the sole owner selector; run-state checkboxes remain local and never change the sender | [unit] web/src/pages/runsIndex.test.ts::RunsIndexPage — the completed preference::remembers completed visibility, honors an explicit URL, and offers no local owner selector, web/green/pages/homeActions.test.ts::Home actions::offers global view-as on a report and clears personal cache before the identity request finishes | | The completed-state preference is retained locally, an explicit URL wins, and no page-local owner control remains | [unit] web/src/pages/runsIndex.test.ts::RunsIndexPage — the completed preference::remembers completed visibility, honors an explicit URL, and offers no local owner selector | | runs list --mine (item 29): the caller's own runs, private ones included, nothing of anyone else's; a linked session's are its person's, an unlinked session's the runs requested as its own access:<sub> (none in this fixture), a service token's none; the store is asked with readable ∧ own, and a text surface's --mine is the same ask | [unit] src/core/commands/runs.test.ts::runs.list --mine (record 0042, the runs page): the caller's own runs, a narrowing of what they may read::* | | Item 30: the picker's people — the grants table's named Slack and Access people (never a service credential, loopback subject or surface star) named through the directory plus every requester in history, merged with the page's requesters once each by name; a requester's name spares the lookup, a failing lookup leaves the id, a failing history read leaves the granted people; the source answers from its cache and never waits — empty before the first refresh, the last list while a stale read refreshes behind it, the last list after a failing refresh; an empty cache leaves the page's requesters; a narrower viewer gets none | [unit] src/channels/viewAs.test.ts::peopleSource::*, src/config.test.ts::*::grantedPeople lists named Slack and Access people in grant order, never services, loopback, surface stars or schedules, src/channels/viewAs.test.ts::peopleOf and knownPeople::*, src/channels/liveView.test.ts::createLiveViewHandler (node:http)::the picker's people are the installation's known people merged with the page's requesters, sorted by name; an empty source leaves the page's requesters; a narrower viewer still gets none | | Item 30: the shell stamps viewingAs from the viewer's actor and the index offers viewAs — the page's named Slack and Access requesters — to a viewer holding all, nothing of either to a narrower viewer; /runs/view-as never parses as a run id | [unit] src/channels/liveView.test.ts::createLiveViewHandler (node:http)::the index seed offers named Slack and Access requesters to a viewer holding all, no services or loopback, and no picker to a narrower viewer, src/channels/viewAs.test.ts::requestersOf::*, src/channels/viewAs.test.ts::the view-as routes::claims its view-as and global data-view preference paths | | Item 30: the global menu offers only the trusted protected people roster | [unit] web/green/pages/homeActions.test.ts::Home actions::offers global view-as on a report and clears personal cache before the identity request finishes | | Item 30: the global person view is labelled read-only; Mine exits through the server selector, and a refused change keeps the observer and shows the refusal | [unit] web/src/components/AppNav.test.ts::Global shell navigation::exits the selected person's read-only view through the global Mine choice, web/src/components/AppNav.test.ts::Global shell navigation::keeps the selected read-only view and shows a refused global change without reloading | | Item 31: a request whose Accept names application/json and not text/html asks for the seed; a browser's document request (text/html, */*) and a request with no Accept get the document | [unit] src/channels/webShell.test.ts::wantsSeed — which requests ask for the seed rather than the document::* | | Item 31: the page sender answers the shell under the HTML headers to a document request and the seed alone as JSON under the seed headers to a seed request — the same status, the same stamps (title, capabilities, viewingAs), no markup in the JSON; the body is built before the head, so a sender that throws never answers twice | [unit] src/channels/webShell.test.ts::makePageSender — the shell for a document request, the seed for the app's own::*, src/channels/settingsView.test.ts::the settings view::a page sender that throws before any head is one 502 with the reason, never a second head or an unhandled rejection | | Item 31: a page route answers its seed as JSON through the sender — the index with tokens and title, the run 404 as a 404 seed | [unit] src/channels/liveView.test.ts::createLiveViewHandler (node:http)::answers the index seed as JSON, tokens and title included, to a request that accepts application/json — and the run 404 as a 404 seed | | Item 31: fetchSeed asks with Accept: application/json and same-origin credentials and returns the seed a JSON answer carries at any status; a document, a JSON twin without a page, a text error, a bodyless or malformed answer and a failing fetch are null | [unit] web/src/lib/seed.test.ts::fetchSeed::* | | Item 31: the guard — the first navigation takes the island and loads nothing; a later one loads the address's seed before the route resolves with the loading flag up meanwhile and the old address's seed gone; a hash move loads nothing and keeps the seed; no seed, or a throwing load, is a full navigation to the address and the in-app one is cancelled; a superseded navigation never lands, a hash move superseding it too; a non-JSON answer is cancelled unread | [unit] web/src/lib/seedRouting.test.ts::installSeedRouting::*, web/src/lib/seed.test.ts::fetchSeed::an answer that is not JSON is cancelled unread, so a stream does not hold the connection, web/src/lib/seedRouting.test.ts::pageAddress::is the path and the query, never the hash | | Item 31: browser.navigate is the browser's without a handler and for any address that is not an app path; with one, an app path goes to the router, the address the page is already at reloads, a hash on it is routed; browser.leave is always the browser's | [unit] web/src/lib/browser.test.ts::browser.navigate — in place for the app's own paths, a full navigation otherwise::* | | Item 31: the app mounts the first page from the island and a new address from the seed loaded for it — a fresh mount with the title and favicon set from the seed; a hash move keeps the mounted page; the progress cue is drawn only while a seed loads and the current page stays until it lands | [unit] web/src/App.test.ts::App::* | | Item 31: global navigation uses RouterLinks with clean paths | [unit] web/src/components/AppNav.test.ts::Global shell navigation::renders capability-backed work links and constant run, settings, and documentation paths without tokens | | Item 31: in a browser, clicking between Home, Runs, Runtime, Costs, Delivery and Settings, into a run and back, and through the runs pager repaints the page without a document load (the network panel shows one application/json request per navigation and no document request); a link to /runs/<id>/events and to the docs still opens as a document | [agent] open the fixture preview (npx tsx scripts/web-preview.ts), navigate the sections with the network panel filtered to documents, and confirm no document request is made until a file or docs link is clicked | | Item 32, the one pace rule: paceText reads events per minute over the last window (a young run over its own age, floored at a minute) or no tool call for N min once stalled and nothing for a row without the fact; stalledFor fires at a whole quiet window and never without the fact; boundText names a call past its declared bound (bash 2083s, bound 600s) and nothing inside it or without one | [unit] src/core/runPace.test.ts::paceText::*, src/core/runPace.test.ts::stalledFor::*, src/core/runPace.test.ts::boundText::* | | Item 32, the card text: a call past its declared bound is marked with the bound it outran — — bash 2083s, bound 600s, never ordinary progress — while a call inside it still reads running <tool> (Ns); the in-flight state carries the bound beside the tool, opened by a tool_call and closed by its result | [unit] src/core/statusCardLabel.test.ts::quietSuffix::a call past its declared bound is marked with the bound it outran — \bash 2083s, bound 600s`, src/core/statusCardLabel.test.ts::quietSuffix::a call inside its bound still reads as running — the bound is a mark, not a caption, src/core/statusCardLabel.test.ts::inFlightCallAfter::| | Item 32, the bound on the event: pi's bashtimeout(seconds) rides thetool_callasboundMsin ms; a call without one, a malformed one and a non-bash tool carry none |[unit]src/core/harness/pi/bridge.test.ts::the bridge speaks the loop's vocabulary — the same turn, the same events::a bash call's declared timeout rides the tool_call as boundMs (seconds → ms); a call without one carries none | | Item 32, the registry's facts: the live summary counts only the content events of the last window (eventsLast5m, present from create), stamps the newest tool call and carries the in-flight call with its bound; a span record neither counts nor opens a call; a finished run carries none, and so does a hosted ship parent — a coordinator makes no tool calls, so its quiet never reads as a stall | [unit]src/core/runRegistry.test.ts::RunRegistry — the stall signal's pace facts (live-view item 32)::| | Item 32, the view and the seed: a live view and a live index row's seed carry the pace facts; a persisted row never does |[unit]src/core/runsService.test.ts::RunsService.getRun::a live view carries the stall signal's pace facts — eventsLast5m, lastToolCallAt, inFlight with its bound; a persisted row none, src/channels/liveView.test.ts::createLiveViewHandler (node:http)::a live index row's seed carries the pace facts — eventsLast5m, lastToolCallAt, and the in-flight call with its bound| | Item 32, the row model:rowPace/rowStalled/rowBoundspeak the pace rule for live rows that carry the fact only — a finished row and an older writer's row show no signal — andpaceTipsays what the cell shows |[unit]web/src/lib/indexRow.test.ts::the stall signal (item 32)::| | Item 32, the borrowed pace: a live hosted head's cell reads its newest live child's pace —startedAtdeciding newest — orwaiting on the runnerwith no lender, a finished head nothing; only the head rendering borrows (a nested hosted row shows no signal) and the bound mark outranks the borrowed pace in cell and tooltip alike |[unit]web/src/lib/indexRow.test.ts::the hosted row's borrowed pace (item 32)::, web/src/pages/runsIndex.test.ts::RunsIndexPage — toolbar, states, pager::a hosted head's pace cell borrows the newest live child's pace…, web/src/components/runs/RunRow.test.ts::RunRow::a nested live hosted row — no children handed down — shows no pace signal…, web/src/components/runs/RunRow.test.ts::RunRow::the bound-exceeded mark wins over the borrowed pace…| | Item 32, the row: a healthy live row shows its pace and no badge; a stalled one readsno tool call for N min, wears the stalledbadge anddata-stalled; a call past its bound is the red mark in the pace cell; a finished row and a row without the fact show neither | [unit]web/src/components/runs/RunRow.test.ts::RunRow::a healthy live row shows its pace — events per minute over the last five minutes — and no stalled badge, ::a stalled live row reads `no tool call for N min`, wears the stalled badge and data-stalled, ::a call past its declared bound is the mark — `bash 2083s, bound 600s` — in the pace cell, red, ::a finished row and a live row without the fact (an older writer's) show no pace and no badge| | Item 32, the sort: stalled live rows first, newest-first within each group; a row without the fact never sorts as stalled |[unit]web/src/pages/runsIndex.test.ts::RunsIndexPage — toolbar, states, pager::sorts stalled live rows first, newest-first within each group| | Item 33, the fact: a live registry summary carries the hosted instance id once aship_handoffor arun_metanames one — the last event naming one wins — and both live views forward it, a ledger row's read from its mirrored events |[unit]src/core/runRegistry.test.ts::RunRegistry.listActive::carries the hosted instance id once a ship_handoff or a run_meta names one — the last event naming one wins (live-view item 33), src/core/runsService.test.ts::RunsService with the run ledger — one registry across generations (run-history item 41)::carries the hosted instance id on both live views — a registry row from its ship_handoff, a ledger row from its mirrored events| | Item 33, the nesting: a ship unit's runs group under the parent whoseinstanceIdtheirparentInstanceIdnames and a conductor's child under itsparentRunIdrow, children oldest-first, heads keeping the index's order; an unlisted parent leaves the row top-level, a cycle leaves each row its own head, a stalled child surfaces its whole group; the index indents nested rows naming the head |[unit]web/src/lib/indexRow.test.ts::the pipeline nesting (item 33)::, web/src/pages/runsIndex.test.ts::RunsIndexPage — toolbar, states, pager::nests a pipeline's runs under their parent…| | Item 33, the way up:unitLineagenames the instance's parent record and the unit whose thread the key names — coding and review apart, null outside the predicate; a spawned run's seeds carrylineage(history through the store, live from the registry alone) and the page labels the parentpipeline(orparent run) and links it and the unit, drawing nothing when the seed carries none | [unit]src/core/runsService.test.ts::RunsService.unitLineage — a pipeline child's way up (live-view item 33)::, src/channels/liveView.test.ts::the unit page and what a run is the parent of (item 28)::a spawned run's page names the way up (item 33)…, web/src/pages/runPage.test.ts::::a spawned run's page links the way up…| | Remote live runs (item 16) use live page seeds, a server clock and tokenless stop URLs. Their EventSource explicitly opts into follow; default tokenless snapshot replays end for work folds. Follow refreshes without replay duplicates, ends only on a terminal receipt, and stops as unavailable for a provisional-only record without polling or a terminal claim |[unit]src/channels/liveView.test.ts::live view on RunsService: history pages + index toggle …::a run live on the ledger under another generation stays live tokenless with a clock, an updating stream and ledger stop controls, src/channels/liveView.test.ts::serveLedgerEvents::; web/src/pages/runPage.test.ts::RunPage — live mode::a tokenless remote run allocates live tool time, freezes at its finish receipt and posts a ledger stop| | An index row without a display label still shows its resolved agent and current activity from structured metadata |[unit]web/src/components/runs/RunRow.test.ts::RunRow::shows a labelless remote run's agent and current work from structured metadata| | An unavailable follow closes the page stream and freezes its last elapsed time without claiming a terminal outcome; the same authorized Kill control remains available |[unit]web/src/pages/runPage.test.ts::RunPage — live mode::an unavailable follow stops a reconnecting clock without claiming the run ended; src/channels/liveView.test.ts::live view on RunsService: history pages + index toggle …::index: active by default, everything with ?all=1::a provisional replay ends for work folds and its reconnecting follow stops as unavailable| | Structured index metadata fills an absent agent label; an existing public label, including main's generic private label, remains unchanged |[unit]web/src/components/runs/RunRow.test.ts::RunRow::preserves the public agent label when structured metadata names an internal preset` |

Attached browser work scope ​

A trusted host scope narrows browser lists, feeds and point reads by the selected current owner. Unscoped blue, CLI and native callers retain their existing policy and capability behavior. Mine and person controls cannot broaden their scope through URL parameters or another owner's capability. Person observation is read-only. All people keeps the actual superuser's existing authorized run controls.

The optional private-unit callback proves only fresh owned metadata, under any attached Mine, person or Everyone scope. Its exact canonical key must match the requested unit, and the source proof and projected facts are checked again before HTML or JSON emission. Without a current proof, or for an unscoped blue request, the existing projection remains redacted. A recorded private thread link grants no history or IO authority: the independent private-thread route must still prove its current source and audience.

CriterionProof
Attached Mine scopes narrow current-owner lists, feeds and point reads without widening through capability URLs[unit] src/channels/liveView.test.ts::live view on RunsService: history pages + index toggle::the viewer's actor binds the index and the tokenless history routes (authorization.md items 5–7)::an attached Mine scope narrows the index feed and point reads without capability links widening its owner
Attached Mine, Person and Everyone preserve shared schedule definitions but expose last-firing result, trace and run identity only with the recorded run's current read proof. Private-worker, pruned, unavailable and runless source facts stay withheld with unknown coverage; unscoped native behavior is unchanged.[unit] src/channels/liveView.test.ts::scheduled tab — GET /runs/scheduled (item 18)::keeps shared schedule definitions but withholds unreadable firing details under Mine and Person while Everyone retains allowed firings, src/channels/liveView.test.ts::scheduled tab — GET /runs/scheduled (item 18)::keeps unreadable, absent and runless firing evidence unknown without borrowing a shared definition's identity
Scoped person observation refuses writes while All preserves the actual admin's authorized run controls[unit] src/channels/liveView.test.ts::live view on RunsService: history pages + index toggle::the viewer's actor binds the index and the tokenless history routes (authorization.md items 5–7)::an attached person scope stays read-only while All retains the actual admin's normal run controls
Fresh source-proved Mine, selected-person and All metadata reaches the private unit page and JSON twin; wrong canonical keys refuse, and legacy views stay redacted; person scope excludes other owners and refuses writes without granting log access[unit] src/channels/liveView.test.ts::the unit page and what a run is the parent of (item 28)::serves only fresh source-proved private unit metadata without lending its thread links log authority
Recipient proof loss before emission withholds private unit HTML and JSON[unit] src/channels/liveView.test.ts::the unit page and what a run is the parent of (item 28)::withholds private unit HTML and JSON when current recipient proof is lost before emission
Production activation supplies the fresh metadata callback from the already authenticated browser context and retains the independent strict private-log proof[gap] The optional read seam is tested locally; final host wiring and a current authorized user proof remain required before cutover.

Global work-view preferences ​

The server accepts Mine and restricted Everyone at POST /runs/data-view. This is a display preference; a cookie does not grant access or permit writes. Everyone requires the current actual human user to hold all actions, all channels and read access to the current organization. Finite repository grants do not hide or refuse Everyone; each row retains its existing read predicate. Impersonation still requires every grant, selects a known protected human requester and keeps its existing read-only ceiling. Setting Mine or Everyone clears impersonation; entering or exiting impersonation clears the other preference. Both cookies remain HttpOnly, SameSite=Strict and deployment-secure.

CriterionProof
Mine and Everyone preferences use one global cookie, clear impersonation, and refuse unsupported modes or insufficient action or channel scope.[unit] src/channels/viewAs.test.ts::the view-as routes::sets one global Mine or Everyone preference and clears impersonation without treating the cookie as authority
Cached view metadata cannot grant Everyone when the current raw action or channel scope is insufficient.[unit] src/channels/viewAs.test.ts::the view-as routes::refuses Everyone when raw grants are narrow even if cached view metadata claims eligibility
A human operator with all actions, all channels and current organization read access can select Everyone with finite repository grants. Repository limits remain unchanged, and impersonation remains refused.[unit] src/channels/viewAs.test.ts::the view-as routes::lets a repository-limited operator select Everyone without permission to view as another person, src/channels/workViewScope.test.ts::global work view scope::requires a current human operator with full action and channel scope in the admitted organization, ::offers Everyone with full channel scope while preserving a finite repository limit
Known Access people can be selected; unknown subjects, service credentials and the loopback operator cannot be impersonated.[unit] src/channels/viewAs.test.ts::the view-as routes::accepts a known Access person and rejects unknown or service subjects
CriterionEvidence
Scheduled work is current in the shared global navigation and Run details stays one click away[unit] web/src/pages/scheduled.test.ts::ScheduledPage::marks Scheduled work in the global navigation with Run details one click away

Stop and destructive removal ​

CriterionEvidence
Stop and Kill have separate command definitions. Kill needs the run stop grant and removes a saved row.[unit] src/core/commands/runs.test.ts::runs.stop::kill deletes a saved run through the authorized command
Kill retains the row when runtime shutdown is unconfirmed. A removed row cannot return through a delayed history write.[unit] src/core/runsService.test.ts::RunsService.stopRun::kill retains the row when runtime shutdown is unconfirmed, src/core/runsService.test.ts::RunsService.stopRun::kill removes a finished run and prevents a delayed record from restoring it
A confirmed Stop posts its generic terminal result after the cancellation seal; Kill posts no final answer.[unit] src/core/dispatch/reply.test.ts::deliverAnswer…::a confirmed user stop posts its terminal notice after cancellation sealed the record, src/core/dispatch/reply.test.ts::deliverAnswer…::kill sends no final answer and still releases the workspace
The real dashboard confirms Kill, removes the row, and shows Stop as a neutral terminal result on the deployed build.[gap] Requires a disposable live run on a build that includes these controls.
Kill removal also removes saved rows when completed runs are shown. Its feed event is filtered by the original run identity and reveals no private worker. A failed deletion poll logs once per outage and waits for a successful read before removing rows.[unit] web/src/pages/runsIndex.test.ts::RunsIndexPage…::Kill removes a saved row even with completed runs included, src/channels/liveView.test.ts::Kill removal reaches authorized history viewers without exposing other runs
Scheduled work uses the same stopped-by-user label for both retained stop statuses.[unit] web/src/pages/scheduled.test.ts::ScheduledPage::shows a normal stopped result for…
Legacy CLI/API mode options and HTTP mode queries keep their original non-destructive behavior, while default Stop uses the new terminal cleanup behavior.[unit] src/core/commands/runs.test.ts::runs.stop::keeps legacy mode…, src/channels/liveView.test.ts::run control: POST /runs/:id/stop::accepts legacy HTTP mode…

The index feed starts with the current authorized row IDs. The browser removes seeded rows absent from that snapshot before applying live changes. Both the active page and feed validate shared deletion state; the feed retains foreign seeded identities for deletion polling.

CriterionProof
Scheduled work retains independent page proof for surviving plain-English outcome labels.[unit] web/src/pages/scheduled.test.ts::ScheduledPage::shows the surviving…