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 byweb/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, theIndexEventmodel, per-run label +startedAt+eventCount+stop, bounded backlog, TTL eviction, constant-time token gate,defaultRunRegistrysingleton);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 + hardAbortSignal;activity.ts—activityOf/activityOfEvents, the one-line activity rule the summary and the record share;state.ts—RunState, the row a live run occupies,RunMeta, and the per-run subscriber contract (RunSubscriber,FinishedFrame,SealedFrame,Unsubscribe);projections.ts— the read shapes (RunSummary,RunSnapshot,SealResult,RunStopStatus) and the one projection each (summaryOf,snapshotOf,sealResultOf,sealedFrameOf);backlog.ts— the bounded per-run backlog with its protected head (appendToBacklog,DEFAULT_BACKLOG_LIMIT/DEFAULT_BACKLOG_BYTES,HEAD_BUDGET_BYTES) and the budgeted replay window a late subscriber gets (replayWindow,DEFAULT_REPLAY_LIMIT/DEFAULT_REPLAY_BYTES);indexFeed.ts— theIndexEventmodel,IndexSubscriber, andIndexFeed, the runs-index feed's subscribers, replay-on-subscribe and isolated fan-out;testing.ts— the shared test fixtures);src/channels/liveView.ts(the router + handler:parseRunRouteincl. theindex,scheduled,unitandstoproutes,parseStopMode,createLiveViewHandler— takes the boundshelland emits seeds; re-exports the modules below so it stays the one import path),src/channels/scheduledPanel.ts(the Scheduled tab's model, item 14),src/channels/webShell.ts(renderShell,makePageSender/PageSender— the shell or the seed bywantsSeed, 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,serveEventswith theREPLAY_LIMITring,serveIndexEvents,serveHistoryEvents+withOmittedMarkers, the node sink and keepalive heartbeat — byte-for-byte unchanged by the Vue port),src/channels/liveView/html.ts(escapeHtml),src/channels/liveView/viewer.ts(readableRuns— the viewer'sruns:readpredicate — andvisibleIndexFeed, 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 inweb/: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/:idbetween run details, recorded PR evidence, and an unavailable page by the seed'spage),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 isomorphicindexRowRenderer; 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,formatUsdfor the header's cost — plus the web-onlyformatClockandformatBytes),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 whenPUBLIC_BASE_URLis set);src/core/dispatcher.ts(publishes events inonEvent, finishes in the run-loopfinally);src/core/types.ts(optionalchannelName/userNamedisplay hints onIncomingMessage);src/channels/slack/lookups.ts(resolveChannelName/resolveUserName— best-effort, cached name lookups that populate those hints);src/index.ts(loads the web assets, serves/assets/*, routesGET /runs+/runs?stream=1+/runs/:id+/runs/:id/events, sharingdefaultRunRegistrywith the dispatcher — the/runs*Access gate already covers/runs?stream=1);scripts/web-preview.ts(fixture preview server); 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 withnpm testinweb/):web/src/lib/runPageModel.test.ts,web/src/lib/timelineVm.test.ts(fold/group/fold rules),web/src/lib/durationTone.test.ts(the heat scale),web/src/lib/indexRow.test.ts(row vocabulary + feed rules),web/src/components/runs/RunRow.test.ts,web/src/pages/runsIndex.test.ts(live feed, divider, title/favicon, reconnect reload),web/src/pages/runPage.test.ts(history + live + stop + dedupe; 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-onlynone— see access-gate.md; that identity gate runs before the per-run capability-token check here (defense in depth). "Access-gated" below means "behind that gate", whichever strategy an installation runs.
Behavior
Per-run capability.
RunRegistry.create()mints a random run id and a random view token (default:randomUUID+ 32 random bytes as hex). The dispatcher creates one run per agent run — at its ledger reservation, before the workspace attach (run-history item 42) — and finishes it when the run ends; a dispatch that ends before its run loop discards it (discard: no finished frame, the index feed'sremoved). Admission commits the firstRunLiveState(admitted, with its absolute bound) and itsrun_stateevent 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 shorterwaiting_deployorwaiting_repositorybound belongs only to that wait. After it expires,falling_back,preparingand the firstworkingtransition 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 typedrun_budget_exhaustedand atime_budget_exhaustednote 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.Live stream + one bounded backlog. The registry has two event entry points over one monotonic per-run sequence.
publish(id, event)assigns the nextseq, 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 reservedseqis 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 readsregistry.snapshot), bounded by count (8000) and bytes (4 MiB, measured as each event's UTF-8 JSON): past either, the oldest events AFTER the protected head drop until under both — the newest always survives. The protected head (tracing.md): the events that say what a run is —input,context,run_meta, the root's start, theslack.receiveanddispatch.*span pairs, and themcp_unavailable/spans_droppednotes — published while the backlog holds nothing else are never trimmed, up toHEAD_BUDGET_BYTES(512 KiB), and are capped toMAX_EVENT_BYTESat publish so one giantcontextcannot spend the budget; head material after any other event, or past the budget, is ordinary.stepCountcounts the content events (span records excluded) besideeventCount, on the summary, the snapshot and the record. Theseqis the event's 1-based position in the run's stream (the registry's monotoniceventCountat publish — ONE counter, carried both on the event object and as the second argument to every subscriber); it is the SSEid:a client resumes from. A subscriber that arrives mid-run replays the backlog in order, then live-forwards new events;subscribe(…, afterSeq)replays only events with a higherseq. A fresh subscribe (afterSeq0) replays the protected head first and then the newest events within the replay budget (item 5), eliding the range between; a resume re-sends nothing from the head. 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.Constant-time token gate, 404 on failure.
subscribe/hascompare the presented token against the run's token withcrypto.timingSafeEqualover equal-length buffers (length guarded first). A wrong/missing token or unknown run yieldsnull/false→ the handlers answer 404 for both the page and the stream. The token is never logged.Finish, seal, TTL eviction. A run ends in two steps (tracing.md).
finish(id, status)— the agent stopped — stampsfinishedAt, sends every attached subscriber the named SSEfinishedframe ({ finishedAt }; the page freezes its header duration at that stamp) WITHOUT detaching it, upserts the index, and stops content events; span records still publish until the seal (counted and forwarded, never an index repaint).seal(id, { replyOk })— the stream closed: the first reply attempt completed (replyOktrue or false) or the run's branch was abandoned without one (absent) — stampssealedAt, detaches every subscriber with the terminalendframe ({ sealedAt, replyOk? }→ the page closes itsEventSource), upserts the index once, and returns aSealResult(the events published since finish,eventCount, the two stamps); it is idempotent and re-readable (a second seal returns the same result and changes nothing), a no-op on a live run (no stamps) and the empty result for an unknown run.sealAllFinished()seals every finished-unsealed run (the drain). The subscriber set is copied and cleared before any callback fires, so a re-entrant seal is a no-op, and a throwing subscriber is isolated. The dispatcher seals a run once its first reply attempt has completed —replyOktrue or false — through oneRunEndingper dispatch (src/core/runEnding.ts): the card close, then the reply, then a drain that seals every finished run of the dispatch and writes every registered record after the seal. A command run that fell through to the agent is sealed with no reply attempted; a fenced run (another generation's) by the outer finally's backstop; the shutdown drain seals whatever finished runs remain before exit, and one event-loop turn hands theirendframes to the sockets. So a run has two index events at its end — finish's, then the seal's carryingsealedAt/replyOk— and the wire readsfinishedwhen the agent stops andendafter the reply. A viewer who opens the link after finish but within the TTL sees the full backlog,finished, thenend; one who opens it between finish and seal stays attached until the seal. The eviction TTL (default 60 s) runs fromsealedAt; a finished run left unsealed forUNSEALED_HOLD_MS(15 min — a reply that never settled) is sealed by the sweep with noreplyOk, then evicted. After the TTL the run is evicted (subscribe → 404). Unfinished runs are never evicted by age. Eviction is lazy (swept on registry activity) — no background timer keeps the process alive.SSE handler.
GET /runs/:id/events?t=…setscontent-type: text/event-stream(+no-cache,x-accel-buffering: no), writes the 200 head, then flushes the replayed backlog and live events asid: <seq>\ndata: <json>frames; the terminalendevent closes the stream. A client disconnect unsubscribes. The backlog replay (synchronous duringsubscribe) is buffered until after the 200 head so no frame is written before the status line. The replay budget lives in the registry (RunRegistry.subscribe(id, token, { onEvent, onFinish, afterSeq, limit, byteLimit })): of the retained events after the cursor, the NEWEST are replayed — at most 2000 (DEFAULT_REPLAY_LIMIT) and at most 1 MiB of UTF-8 JSON (DEFAULT_REPLAY_BYTES), the newest event always — and the result reportsreplayedand, when the budget left retained events out,elided: { fromSeq, toSeq }, the contiguousseqrange skipped. The handler writes that range as one leading named frame,event: replay_elided/data: {"fromSeq","toSeq"}: a transport frame, never a run event, never in the registry or the record, and with noid:so it never moves a resuming client's cursor. It is distinct fromreplay_note, which marks records MISSING from a stored stream (withOmittedMarkers, item 12): elided events still exist in the registry and on the record, so the page shows the range as a replay row readingN events not loaded (a–b) — the record has themand keeps it onstate.elidedfor the partition'snot loadedterm (docs/reference/specs/tracing.md); a malformed frame marks nothing. Events the backlog itself dropped are not elided — they are aseqgap. A subscriber that must see every retained event (the run ledger, a store) passesREPLAY_EVERYTHING. Live frames after the replay are never capped;snapshotstill returns the whole backlog; the index feed is unchanged. Resumable: the browser'sEventSourcereconnects after a drop (proxy kill, deploy, laptop sleep —retry: 3000) sending the lastidasLast-Event-ID; the handler parses it (parseLastEventId— a positive integer, else 0) and the registry replays only the events after it, so a reconnect never re-appends the whole backlog to the page as duplicates. The budget and the cursor compose: the newest 2000 of the events after the cursor are replayed, and the elided range, if any, starts after the cursor. The page also drops any run-event frame whose id is at or before the last one it applied, for a proxy that strips the header (transport frames carry noidand are exempt). Each event is JSON-serialized once per process however many viewers a run has (serializedOnce, aWeakMapover the event object the registry shares with every subscriber); index frames use the same memo and carry noid(the index has no resume semantics).The live run page.
GET /runs/:id?t=…serves the shared shell (strict CSP,no-store,noindex— the Rendering paragraph) with aRunLiveSeed—{ mode:"live", eventsUrl, stopUrl }, the capability token riding only inside those two URLs; the page (web/green/pages/Run.vue) opens the token-scopedEventSourceand renderstool_call→ a running row andtool_result→ ✓/✗ + summary, as text only (no raw markup anywhere). Both routes are GET-only (405 otherwise). The page also sendsframe-ancestors 'none'(CSP) +X-Frame-Options: DENY— this public surface cannot be iframed (clickjacking), whichdefault-src 'none'does not cover. The header carries a "← All runs" back link to the Access-gated index (/runs, no token — the index is Access-gated, not token-gated) and a connection status mark + label (green = live, amber = connecting, red = disconnected, grey = finished) driven by the stream'sonopen/onerror/endstate.Graceful degradation. The dispatcher reads
PUBLIC_BASE_URLfrom the env. Set → the live link (<base>/runs/<id>?t=<token>, trailing slash trimmed) is added to the status card while the run is live. Unset/blank → no link is added; the run, the card, and every other surface work unchanged. The registry always runs (so a run is always streamable if someone has the link) — only the surfaced link depends on the base URL.Runs index (Access-gated home page), live over SSE.
GET /runs(and/runs/) serves the shared shell whoseRunsIndexSeedlists every non-evicted run the viewer's actor may read (itsruns:readpredicate — authorization.md item 6), newest-first (rendered byweb/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:hoverbackground so the entire row reads as clickable, not just the label. A row leads with a status dot (green = live, grey = finished;role="img"+aria-label/titleso it is not color-only), then the run's label (or a short id fallback), then the event count. No active runs → an empty-state message. The header carries the same connection status mark + label as the per-run page. The initial snapshot rides the seed (first paint is the client's render of it — no second round trip); the page then opensEventSource("/runs?stream=1")and stays live — rows appear/update/finish/disappear without a refresh, and feed-added rows render through the identicalRunRowcomponent as seeded ones (the full-row + status-dot shape is one implementation, never raw markup). The feed is the same predicate's view (visibleIndexFeed): anupsertfor a run outside it is dropped, and so is that run's laterremoved, so the page never learns a hidden run's id.RunRegistry.listActive()returns the snapshot ({ id, token, label?, finished, startedAt, eventCount }per run) that backs the first paint;startedAtis the clock time atcreate()andeventCountis the monotonic total published (not the bounded-backlog length). The index (page AND feed) is GET-only (405 otherwise), carries the same CSP + clickjacking +no-storeheaders as the per-run page, and — unlike the per-run routes — has no token gate: it is fronted by Cloudflare Access, bound to that identity's actor, and must only be exposed behind Access (see the locked-design note above). The dispatcher labels each run atcreate()viacomposeRunLabel— human-first: agent-led, thenowner/repofor repo runs or#<channel> · <user>for chat runs (display names when the adapter resolved them, else the prefix-stripped ids), then a short quoted snippet of the request; capped to ~120 chars. The label is additive — inert everywhere except the index.Index event model + live feed.
RunRegistry.subscribeIndex(onEvent)backs the live index. On subscribe it replays the current active set as{ type:"upsert", run }events inlistActive()order (newest-first) — mirroring the per-run backlog replay — then registers the callback and returns an idempotent unsubscribe. Thereafter every lifecycle transition emits anIndexEvent:create(), each contentpublish()(live event count / running state — a span record's publish emits none),finish()(an upsert withfinished:true) andseal()(an upsert carryingsealedAt/replyOk) emit{ type:"upsert", run }; a TTL eviction duringsweep()emits{ type:"removed", id }— the only removal signal (a finished-but-unevicted run stays listed as finished, and eviction stays lazy/timer-free). Because all channels (Slack, HTTP, MCP) register their runs in the one shareddefaultRunRegistryviadispatch(), the feed reflects runs from every channel with no dispatcher hook. Notification is defensive: an index subscriber that throws is isolated and never corrupts registry state or the create/publish/finish/sweep caller.serveIndexEvents(subscribeIndex, sink)mirrors the per-runserveEvents— it buffers the synchronous replay, writes the 200text/event-streamhead, flushes the buffered frames, then live-forwards, and unsubscribes on client close — but always 200s (no token gate) and has no terminalendframe (the whole-registry feed stays open; finish is an upsert, eviction a removal). Each frame isdata: ${JSON.stringify(indexEvent)}\n\n.Stop and Kill.
POST /runs/:id/stop?mode=stop|killuses the existing Access, capability, visibility andstop:writegates. 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. Legacymode=soft|hardHTTP 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.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
answerevent ({ type:"answer", text }— redacted with the sameredactSecretsas every event, but not capped: the summaries are the digest, this is the record) beforefinish()and before the channel reply or the PR post (a publish on a finished run is a silent no-op, so the order is load-bearing and pinned by a test). 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-escapedRunHistorySeedJSON island (serializeSeed) and is fed through the SAME fold (createRunPageModel,web/src/lib/runPageModel.ts) the EventSource frames go through, so a seeded page and a live page render identically by construction and event text can never close the island. The live page seeds no events (RunLiveSeed), because its SSE replay paints the backlog. The index's empty-state sentinel shows only while no rows exist, per view.The run page is a timeline of the whole run, not a tool log. Two narrative events join the stream (run-visibility.md item 1):
input({ type:"input", text }— the request as received: the author's full text on operator-bound runs (legacy routes strip directives), humanized byhumanizeMessageText(Slack<url|label>→ the url orlabel (url),<@U…>/<#C…|name>→@user/#name,& < >unescaped once — channel-authored mrkdwn, so the Request block never shows raw<https://…|…>), plus a one-line attachment note like[+2 images, 1 document], redacted, uncapped) is published by the dispatcher directly to the registry right aftercreate()— at the ledger reservation, before the workspace attach (run-history item 42) — so it is the first content event of the record (the setup spans streamed ahead of it are span records, and head material) and never goes throughonEvent(no card refresh, no friction input);assistant({ type:"assistant", text }— the model's prose that rode alongsidetool_usein one completion, redacted, uncapped) is emitted by the runner before that turn'stool_callrows, and never for the final text-only completion (that is theanswer, published by the dispatcher — nothing appears twice). The status card shows anassistantturn as a one-line💬 <first 80 chars>…activity trace (one line, replaced by the next event — the card is a digest, the page is the record). A third narrative event,context({ type:"context", text }— one thread turn the model was given,user:/assistant:-prefixed, humanized and redacted likeinput, bounded per run-visibility.md item 6), is published by the dispatcher right afterinput. The page rendersinputas a headed Request block above the log,contextturns inside a collapsed Earlier in this thread<details>block between the Request block and the This run heading (each turn[ts]+ markdown through the same guard as the request — secondary by design, one click away),assistantas the narration that opens a step in the log (item 13), andansweras the headed Reply block below (the product's word — a reply is not always an answer: a review's is the verdict, a coding run's the PR); every row and both blocks carry the event'satat their right edge (omitted whenatis absent). Call cards stay monospace (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:linesprop, slot content, the fade's colour from--expandable-surface). The Reply's heading carries a muted caption saying what the reply IS, from the run's facts and never its text (replyCaption):verdict for owner/repo#N(a review with a resolved PR, linked),verdict, Slack only(the request opted out of the GitHub post —reviewPostOptedOut, the dispatcher's own parser),verdict(a review with no PR),pull request opened owner/repo#N/pull request updated …(a coding run'spr_opened,createddeciding the verb),output(a command run), elseanswer; the 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. TheWaiting for activity…placeholder disappears on the first painted change of any kind —inputandanswerclear it too, so a no-tool run (request → answer, no cards) never keeps it. Markdown, safely (safety contract): Request, Reply and assistant rows are rendered bysrc/channels/markdownLite.ts— ONE self-containedrenderMarkdownInto(root, text)supporting paragraphs,#–######headings (h1–h6; seven+ markers degrade to text),**bold**,_italic_/*italic*,`code`, fenced code blocks,-/*/1.lists (one nested level),> quotes,[text](http(s)://…)links, and a GFM table subset (header row +|---|separator (alignment colons accepted) + body rows →table/thead/tbody/tr/th/tdviacreateElement, cells through the inline renderer; a pipe row without the separator stays text; body rows are squared to the header width). It builds DOM only viacreateElement/createTextNode/textContent/setAttribute: angle brackets are text (no HTML passthrough —<img onerror>is literal), onlyhttp:///https://hrefs become anchors (withrel="noopener noreferrer";javascript:,data:, protocol-relative and every other scheme stay plain text), a</script>inside a code block is text, and unknown/unbalanced syntax degrades to plain text. It ships into the page through the web bundle:web/src/components/MarkdownText.vueimports the ONErenderMarkdownIntofromsrc/channels/markdownLite.ts(no second copy; the module stays a self-contained function with no imports or module-scope closures — a property its own test still pins) and renders every markdown surface through the same try/catch guard, falling back totextContent. The oldString(fn)inlining and its__nameshim are gone with the inline<script>itself. The CSP is the strict shell policy (script-src 'self'— no inline JS executes,frame-ancestors 'none'); nothing on the page assigns raw markup andv-htmlappears nowhere. Robustness contract: the block parser always consumes at least one line per iteration (a bare#heading marker or any other block-looking line that matches no block degrades to text instead of looping forever), quote nesting is capped at 8 levels (deeper>runs become text — no stack overflow), and every markdown surface on the page renders through atry/catchguard that falls back totextContent— a renderer bug can cost the formatting of one event, never the event or the stream.runFriction.tsignoresinput/context/assistant/answer(narrative, not friction or steps — they count toward neitherrunMsnoreventCount);parseRunEventLines(the input offriction analyze) accepts them in captures. Span rows (tracing.md): aspan_start/span_endpair whose name is adispatch.*,run.*,post.*orship.roundspan renders through the same fold as ONE row — its display name (displayNameOf, never the raw name),◌ …while open,◷ <duration>once ended (✗on error), the start's stamp — while atool.*span decorates its call card and amodel.turnspan its step (item 13); anmcp.*span draws nothing of its own.Call cards, steps and a live tail — the page is grouped the way a person reads a run. The flat stream is folded client-side by
src/channels/runTimeline.ts— ONE self-containedcreateRunTimeline()(no imports, no module-scope closures — still pinned by its own test), imported by the web bundle and driven bycreateRunPageModel(web/src/lib/runPageModel.ts) — into steps and call cards, deterministically (no heuristics): anassistantevent opens a new step with that prose as its narration; atool_calljoins the current step (calls before any prose form one un-narrated leading step); atool_resultattaches to its call bycallId(the provider'stool_useid, on both events since this item), and a result whose call was trimmed from the backlog — or that carries no id — becomes a finished call of its own — nothing is dropped. A call card is a native<details>: the<summary>header is[HH:MM:SS]· status glyph (CSS spinner while running; ✓ / ✗ / ⚠ forok/failed/infra) · a dim$for shell calls or a tool chip otherwise · the command · right-aligned facts (exit N— red when nonzero — orerror/sandbox error,N linesfrom the summary's size note (the FULL output's count, since the event'soutputis capped), and the duration fromat−at, e.g.1.2s,2m 35s) · a chevron. Collapsed, the header shows the one-line headline: the command's first line with its leadingcd … &&/;hops removed (they say where, not what) and a trailing…when more follows; open, it shows the full command (pre-wrap, hanging indent — continuation lines align under the command's first character, not the timestamp) and the body: the result's redactedoutputin a scrollable<pre>(the summary if no output rides on the event;no output;no output yetwhile it runs — the spinner is the card's one live mark). Status is truthful:okis now "the tool succeeded" — a bash command that exited nonzero renders ✗ with its exit code (it rendered ✓ before this item). Open by default = classification: every call carriestags(shell: the first matching oftests,build,install,git,network,read, elseshell, from the command text after thecdhops; other tools: the tool name; plusfailed/infraafter the result) and the page opens a card when any tag is inOPEN_BY_DEFAULT—["failed", "infra"]— overridable per view with?open=tests,build(orall); an Expand all / Collapse all header toggle flips every card and applies to cards added later.update_statusrenders as one muted line, not a card. Layout: one left edge — timestamps line up down the page for prose and cards alike; steps are separated by space, not rails or borders; the body has room at the bottom so the tail never sits on the viewport edge. What is happening now (no separate tail row): in-progress work draws where it will end up — a running card ticks its elapsed in its facts slot, and a silent model is a pending-turn row at the foot of the log —∿ · <model name> · <rotating verb>… ……… <elapsed>in a dashed card outline (item 18 has the clock); both are gone onend/ disconnect. Request block source: theinputevent carriessource({ url?, channel?, user? }— the Slack permalinkslackPermalink(teamUrl, channel, ts, threadTs)built from the cachedauth.testURL, plus the display names the adapter already resolves;IncomingMessage.sourceUrl), rendered as#channel · user · open thread ↗— onlyhttp(s)URLs become anchors (rel="noopener noreferrer"). Autoscroll follows the stream only when the viewer was already at the tail (sampled once per event, before anything is added). Everything still renders as text through the components (web/src/components/run/{StepBlock,StepItems,CallCard}.vue— nov-html, no raw-markup assignment); the CSP is the strict shell policy. Data changes behind this:tool_result.output(control-stripped → redacted → capped atTOOL_OUTPUT_CAP= 8000 chars with a…[N more chars]note; the registry's byte-bounded backlog, item 2, trims the oldest events before an output-heavy run can grow without bound),tool_result.exitCode(bash only, parsed byparseExitPrefixfrom the executors' sharedexit N:first line — see run-visibility.md item 1),callIdon both tool events, anddescribeToolCallnaming non-bash targets (use_skill code-review-and-quality,web_fetch <url>). The status card and the friction analyzer keep readingsummary. One fold for every frame: the seeded history of run-history.md and the live EventSource frames both go through the samecreateRunPageModel→timeline.push(e)→ apply-change path, so a seeded page and a live page render identically by construction;contextframes become the Earlier in this thread block (acontextchange) and the transport'sreplay_notea muted notice (areplay_notechange) through that same fold —createRunTimelineknows both kinds, and an unknown frame is ignored, never thrown on. Span cases (tracing.md): amodel.turnspan_enddraws the turn row from its attrs (model,inputTokens/outputTokens/cacheReadTokens) and is the step boundary; the history seed of a span-schema record isnormalizeSpans-ed beforewithOmittedMarkers(whose count and cursor skip the synthesized,seq-less spans), a record belowSPAN_SCHEMAarrives as stored,untimed, and any event kind the fold does not know draws nothing; atool.*span_endwhosecallIdattr names a call the result could not time (noat) gives that card its duration (the twin rule) and never opens a row; every other streamed span is aspanchange (open at the start, closed by the end).run_metawithout amodel(a command run) is ametachange with none."Scheduled" tab — the jobs that run without a human, visible without
wrangleraccess.GET /runs/scheduled(its own tab on the runs page since item 18 — the run list stays a run list; same Access gate, GET-only, no token, no SSE: a snapshot per load;scheduledis a reserved path word, never a run id — ids are UUIDs). The same view is the registry commandschedule liston every surface (src/core/commands/schedule.ts; command-registry.md item 20: cron, worker, command/identity, next firing UTC, newest firing per schedule,firingsUnavailablewhen no store is configured;internalentries are hidden there too). The tab's seed carries one row per non-internalentry of the schedule registry (src/core/schedules.ts— the single catalog of every cron any of our Workers runs: each entry names itsworker(bot=deploy/cloudflare,resident=deploy/cloudflare-resident) and itsaction(run|healthz|watchdog); a unit test fails when a Worker'swrangler.jsonctriggers.cronsdiffers from that worker's entries, and each Worker'sscheduled()looks its firing up in the registry, so a cron cannot exist in one place only).internal: truemarks plumbing nobody operates — the per-minute container keep-alive (healthz) — which is neither listed nor recorded. Each row: name (description on hover), cron expression (UTC), worker, what it runs —<command>as<identity>for arunschedule, "resident watchdog — not a run" for the resident's sweep (its firing record is the pass summary:<n>/<cap> residents · <n> re-armed · <n> timed out · <n> errors,failedwhen any resident errored) — next fire computed from the expression (nextFire: five-field Vixie cron incl. lists/ranges/steps and the dom/dow OR rule, UTC), and the last firing: fired-at, outcome (completed | failed | stopped (soft|hard) | no run created | ingress error | misconfigured — nothing ran), a link to the run (/runs/<id>?t=…while the run is live in the registry AND the viewer's actor may read it — the panel reads the same predicate-filtered live rows as the index, so the capability link is as safe here as on the run rows — else a bare/runs/<id>, which the bound run page resolves), and the reply's first line. Firings come from theScheduleStoreseam (src/core/scheduleStore.ts): the productionWorkerScheduleStorereads the state Worker's singleScheduleDO(POST /schedules/latest; the shim writesPOST /schedules/recordafter every firing — including firings that produced no run — bounded at 100 per schedule, invariant 6),InMemoryScheduleStorefor tests. Configschedules.workerselects the store; without it, or when the store fails, the panel still lists the schedules and says why the history is unavailable — it never shows "never fired" for a store it could not read. Pure model insrc/channels/scheduledPanel.ts(buildScheduledRows+ the outcome/action label maps; its HTML renderer was deleted with the Vue port — the run id is URL-encoded into the row's href); the page renders inweb/green/pages/Scheduled.vuefrom theScheduledSeed, hostile strings as text.Model turns are visible — "Thought for 5m 04s" above what the thinking produced. Between a
tool_resultand the model's next output the page could only saythinking…; a five-minute model turn and a dead stream looked identical, and nothing on the record said how long the model took or what it cost. Every provider call the runner makes (loop steps, the final answer, the finale) is onemodel.turnspan (tracing.md item 17) under the run'srun.agent: itsspan_endlands the moment the provider returns and before theassistant/tool_callevents that completion produced (a call that throws — hard stop, finale deadline, provider error — ends the spanerrorand is not a turn; a refusal is a turn withstopReason: other),startedAtanddurationMsfrom the runner clock (at - startedAt === durationMsexactly), and attrsstopReason(end_turn/tool_use/max_tokens/other), the usage when the provider reported it (inputTokens,outputTokens,cacheReadTokens?,cacheWriteTokens?— Anthropicmessage.usageviausageFromAnthropic, OpenAI-compatibleusageincl.prompt_tokens_details.cached_tokensviausageFromOpenAI; malformed/absent usage never fails the completion),ttftMswhen the provider streams (the Anthropic adapter'sCompletionRequest.observer.onFirstToken), and when it streams block boundaries (observer.onBlockStart/onBlockEnd, the rawcontent_block_start/stopevents by kind and index)blocks,thinkingMs(thinking and redacted-thinking blocks) andtextMs— the turn's thinking and writing time, a block still open at the return ending there — which the turn row prints asfirst token 800ms · thinking 3.2s · writing 1.1s, andmodel— the<provider>/<model>that took the turn, the SAME refrun_metacarries (the runner composes it asprovider.name/opts.model; the registry keys providers by the ref's prefix,opts.modelis the bare id the API takes). A run is pinned to one model, but a ship run's children answer on their own models on the one stream, so the page badges a silent model by name and makes a switch stand out per step; the page'ssameModeltreats a bare id and a ref with that name as one model (two refs with different providers are never the same), which is also how records stamped with a bare id still read correctly. There is noturnevent kind: a record written before spans carries its turns as objects no reader knows, and its page statesno timing data(item 25). The span's shape follows the OpenTelemetry GenAIchatspan (duration, stop reason, input/output tokens) so it can be exported without translation. Rendering follows the convention every comparable product (Claude Code, ChatGPT/Codex, Cursor) already uses — no boundary markers; the finished turn collapses to a one-line header over its output: the timeline folds the span end into{ kind:"turn", label:"Thought for 5m 04s", facts:["12.3k in","800 out","11.2k cached"], durationMs, model?, at }(tokens compacted800/12.3k/1.3M; cached only when present; a span end without a numericdurationMsis ignored and the span's start draws nothing) and the page renders it as a muted[HH:MM:SS] 💭 Thought for … · factsrow (text-only rendering, CSP unchanged). A turn is a step boundary: it clears the current step, so a tool-only completion opens a new un-narrated step and the row always sits above its output, never inside the previous step. The status card shows💭 thought for 5.1sas its activity line — the runner'sonProgressnote at each turn's end, which the dispatcher paints like every trace (replaced by the next event).runFriction.tstakes model time from themodel.turnspans on a stream that carries them (summed;slow_model_turnat the span's index) and a stream withoutmodel.turnspans has no model time (run-friction.md). Live elapsed time on thethinking…tail is a separate change.History mode.
GET /runs/:idwith no token (or a token the registry refused) asksRunsService.getRun(id, { include: "messages" }): a live unfinished run of THIS process → 404; a run live under another generation — the ledger's row,ownerGenset (run-history item 41) — is admitted on the attribute decision alone (its token is the other generation's) and renders 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 sendsunavailableand 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 onruns:write(authorization.md item 5); requester cancellation throughruns.stopuses 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 theruns:readcommand admission/api/runs.getasks first — authorization.md item 5; the reason goes to the audit line only); a finished run it may read — still in the registry or persisted — renders through the SAME run page, seeded with the record's events (RunHistorySeed: the events throughwithOmittedMarkersplusstatus/eventCount/durationMs), in history mode: noEventSourceis opened (the seed is the stream; opening one would paint every row twice), the Stop/Kill controls are hidden, and the header is a greyfinished · <status>(succeeded,stopped by user,failed; barefinishedbefore the store confirms a status). A truncated record shows one… N events omittednote row at the firstseqgap (withOmittedMarkers; N = published − stored; a gap at the start puts it first, a cut tail puts it last).GET /runs/:id/eventstokenless serves the events of that same ONEgetRun(id, { include: "messages" })read — the whole stream is known before the head is written and the record is never re-read page by page — then writes the 200 head, the prelude, every frame (marker included) and the terminalend.GET /runs/:id/frictiontokenless returns the stored diagnosis to a viewer who may read the run (404 otherwise).POST /runs/:id/stoptokenless is 409 for a finished/persisted run the viewer may read (404 otherwise — the 409 would confirm existence) and 404 for a live one; with a valid token the stop is the registry's token-gatedrequestStopviaLiveRunAccess, unchanged. A hosted ship parent requires the operator route.runs stop <id>confirms the runner stop and its original terminal write, then retains astopped_hardresult 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 endstoppedwith the reason on their rows, a unit whose child is live when the stop lands endingstoppedwhen 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 }(identityis the viewer's actor id,access:<sub>) and each refused tokenless read one{ route, identity, denied }(deniedisauthorize's reason token, e.g.not-member; no run id, so the log reveals no more existence than the 404) — never content. Dev bypass: underACCESS_DEV_BYPASS, history reads (?all=1, tokenless page/events/friction) are 403 unless the client is loopback andPUBLIC_BASE_URLis unset/localhost; the token path is unaffected.Index toggle.
GET /runslists the active runs fromregistry.listActive()plus, with the run ledger on, the runs live under other generations (RunsService.liveElsewhere, run-history item 41: tokenless rows named by theirownerGen; the index feed tracks their shared deletion markers). Cached rows require a successful shared deletion read. TheShow alltoggle opens/runs?all=1, which lists one full page ofRunsService.listRuns({ status: "all", visibleTo: predicateFor(actor, "runs:read", "run"), limit: INDEX_PAGE_SIZE })(live ∪ finished ∪ persisted the viewer may read, newest first — the viewer's predicate is the store's own filter, authorization.md item 6; a page's worth, item 20) withActive onlyto go back. When the page was full the service'snextBeforecursor renders as anOlder runs →link to/runs?all=1&before=<finishedAt>&beforeId=<id>, which the index route parses (parseIndexCursor; a malformed pair is ignored → first page) and passes back tolistRuns; a short page has no link. On both views, rows whose agent iscommandordoorare hidden by a client-side default; theShow bookkeeping runscheckbox 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 (aUTooltipon the?help, with a screen-reader copy):Finished runs are kept for N days, then deletedfromrunHistory.retentionDays, orHistory is off; finished runs are kept about a minute.when there is no store. Rows are oneIndexRowprojection (aRunViewplus an optionaltoken—RunIndexRowSeedinwebSeed.ts) rendered by ONE component (web/src/components/runs/RunRow.vueover the row model inweb/src/lib/indexRow.ts— the port of the old isomorphicindexRowRenderer) for seeded rows and feed repaints alike; the old server/client mirror holds by construction because only the client renders. Live rows link with their token; finished rows link tokenless — andmergedRowsattaches the registry token ONLY to unfinished rows, so a finished-but-still-in-registry run's token never reaches the seed. Finished rows carry a status dot (grey completed or stopped, red failed) with an accessible label — and a row whose record is a provisional tombstone (run-history item 27:provisional: trueon the view) readsunfinished — no finish recordedwith an amber dot (PROVISIONAL_LABEL, shared fromrunRecord.tsso the CLI says the same words), never the redinterrupted, 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 (oncefinishedAtis known) finished, and the duration sits in the row's facts as the fixed stopwatch (10s,1h 02m) — there is no separate status line. The feed (?stream=1,&all=1echoed) is reconciled byfeedAction: the default view drops afinishedupsert (the row leaves as the run ends) and honors everyremoved;?all=1keeps finished rows and ignoresremovedonly for a row flaggeddata-persisted(the registry'smarkPersistedupsert), so a run the writer lost still disappears at eviction and no ghost row survives a reload. A finished row keeps the record'sfinishedAt/status; the?all=1feed'supsertfor it carries the registry'sRunSummary(no such fields), so the client repaints throughmergeRow(prev, run)(web/src/lib/indexRow.ts) — the summary overrides only what it carries and never wipes the status, duration or dot.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 — viasrc/channels/indexFormat.tsformatElapsed(38s/4m 12s/1h 03m; garbage →0s); a paint with no clock leaves the cell empty until the first tick, so the row model stays clock-free. The dispatcher's label is split bysplitRunLabelinto an agent chip (hue per built-in agent —coding/review/research/general, else neutral; the class is allow-listed, never the raw name), the scope (repo or#channel · user, the row's anchor), and the request snippet (muted, ellipsized, no quote marks); a label not in that shape renders whole as the scope. The status dot's hover reads<status> · started <ISO>and, for a finished row,· finished <ISO>(formatLocalIso— the renderer's zone: the server's on first paint, the viewer's on the feed's repaint). Rows are newest-first bystartedAt(unchanged). Live rows breathe (dot halo, off underprefers-reduced-motion), finished rows sit back (muted). The row model (web/src/lib/indexRow.ts) imports its three formatters (formatElapsed/formatRelative/splitRunLabelfromindexFormat.ts) directly — the oldRowFormattersparameter existed only because a bundler rewrites an imported binding inside an inlinedString(fn)body (seen under vitest as__vite_ssr_import_4__), and that constraint died with the inlining: Vite bundles the imports. The toolbar countsN runningand offers labelled Include completed and Include system runs controls. Run page — one step is one block, read top to bottom: aturnis held for the step it produced; the step's first row is its head —[when] 💭 <how long the model thought> <what it then said> … <tokens>— the narration IS what the thinking produced, so the chip and the prose share a line (the chip turns amber past 2 min; a tool-only completion shows went straight to tools in the prose slot; a stream from before turns existed has no chip). A 2 px rail on the step's left edge marks where it starts and ends (green while it is the live step), and every row inside a step shares one column grid — rail → 1.5rem → timestamp → marker → text … facts: text rows (head, group tally) pad 1.5rem, cards sit .75rem in and pad .75rem inside their border, so a card's timestamp lands in the head's column and the right-hand facts end on one line. From the second call on, a step's cards fold into one<details class="group">whose summary is a mutedtext-xssentence, not a header competing with the cards —❯ 7 tool calls, 1 failed, 1 sandbox error, 2 still running … 9.4s(callSummary, every state:all succeeded; the non-success counts —N failed,N sandbox error(s),N still running— with successes implied;all running; the calls-began time on hover; total = sum of call durations; the tally cells are kept by name on the step node, never addressed by child index); groups stay OPEN by default and are never auto-folded (revised with the Vue port — an all-collapsed page did not read; the tally bars are the narrative): only a viewer's manual toggle (click on the summary) closes one, and it sticks; a failure/sandbox error or a still-running call forces a group open even past a superseded manual close; the CARDS inside stay collapsed except failed/infra (item 13, unchanged); Expand all keeps everything open. Single-call steps stay a bare card. A turn with no step after it (the answer's own thinking, or the run ended mid-thought) is flushed as its own head row reading wrote the reply below / the run ended here. There is no tail row (a dashed-rule row rotating a verb —Noodling…— under a card that spins AND saysrunning…is three loading states at once, and a clock counted from the last received frame reads57sfor a 20-minute command after a reconnect's replay notice). In-progress work draws where it will end up, looking like what it becomes: a running card ticks its elapsed in the facts slot its settled duration lands in (CallCardreads the page'sRunnerClockKey; a history page provides none, so nothing there ticks); a pending model turn — every call settled, the model silent — is a pending-turn row at the foot of the log (#thinking): the dashed outline of the card that has not landed yet, reading∿ · <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 stampedturnnames one, item 15;modelwhen nothing is known), a verb from a fixed list (Thinking,Pondering,Mulling it over, …) DERIVED from how long this silence has lasted — a new word every 6 s, every silence starting atThinking, nothing rotating while no one waits — and the elapsed since the last stamped event, amber past the same minute the finishedthoughthead turns amber (a first cut painted it as a barethinking <span>head on a rail; it read as nothing, so the fun words and the pulse came back as a row shaped like the others); the real step replaces it when the turn lands, so a silent model is never a blank page; the model badge is worn where a reader learns something (TurnVm.showModel): the run's firstthoughthead, and every head where the model changed — a run on one model names it once, and the heads between carry only their cost facts; the badge is the pending-turn row's, first in the head's meta row, naming the model that took the turn — the turn's stamp, else the model the run was on (TurnVm.modelfalls back torun_meta's, so a record from before per-turn stamps still names its model on its first head; a turn with nothing known wears none); a model switch stands out: a stamped turn whose model differs from the model the run was on (the previous stamped turn's, elserun_meta's —TurnVm.switched) wears a loud⇄ <model name>chip INSTEAD of the badge (amber border and fill, full ref on hover) in its step head or turn row, the pending-turn badge then names the new model, and a turn on the same model, an unstamped turn, or the first stamped turn of a meta-less run is never a switch; before the first stamped event the live page shows the sameWaiting for activity…placeholder a history page does. Every stopwatch on the page reads one projected runner clock —runnerNowinrunPageModel.ts: 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 itsatis ahead of the projection — so a page load times an open call by its REAL start (a ten-minutenpm testreads 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 (liveWaitnames 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 saysno output yet, never a secondrunning. Slack bold:humanizeMessageText(item 16's ingress-side humanizing of Slack-authored record text) also maps mrkdwn*bold*to Markdown**bold**— only when the asterisks delimit a non-space-edged run on word edges, so globs (src/*.ts) and arithmetic (2 * 3 * 4) are untouched, and code spans/fences are left byte-for-byte; block-level mrkdwn (•bullets) cannot survive becauseparseDirectiveshas already collapsed the request to one line. Scheduled work:/runs/scheduledshows 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'sN eventsprintsstepCount— the content events, span records excluded (tracing.md) — when the row carries it, elseeventCount, always under the wordevents; its tooltip says what it counts (countText/countTip,web/src/lib/indexRow.ts; the tooltip saysspan records includedwhen a row has nostepCountand the fallback total counts them).Run page — gutter layout, run context, a real 404. Layout: the header is a full-width band (back link · title ·
∿ connectedat the left, Stop/Kill in the middle, site nav at the right); the connection mark is the pulse glyph∿colored by state (green connected, amber connecting/stopping, red disconnected, grey finished) and the tail reuses it. Each step's timestamp lives in a fixed left gutter (--gutter: 9.5rem) beside the rail, padded.75remoff it — the short local clock[HH:MM:SS]with the full ISO-with-offset on hover (the Request block already dates the run); the head row is<thought chip> <narration>(the chip is amber by default — thinking time is the thing to notice — and quiet under a minute), the turn's token facts sit on their own dotted line under the prose (8.4k in · 310 out · 7.9k cached), the call tally is a bordered bar like the cards (6 calls ✓ 6 … 7s, calls-began time on hover), and card headers carry no timestamp — the call's start rides on the card's hover title. Facts everywhere read as·-separated lists (a CSS\00b7escape, not a JS one). Expand all / Collapse all is a view control, not a run control: it moved out of the Stop/Kill cluster and is now a plain text button (#fold,Expand all↔Collapse all,aria-pressed) at the right edge of the This run heading's row —THIS RUN · 6 steps, the heading over the time card and the steps — at the same size and weight as the time card'sraw events · Copy debug JSON, never a boxed or iconed button floating on a line of its own. One right edge: every timestamp, duration and control column on the page — the block headings' moments, the step heads' clocks, the span rows, the group summaries' totals, the cards' facts, the pending row's elapsed — ends on one right gutter,--sb-gutter(web/src/assets/main.css,pr-(--sb-gutter)in the templates; a bordered card subtracts its border), whatever its nesting depth; the call card's chevron leads, like every other fold's, so nothing sits after the facts. Run context: a new stream eventrun_meta—{ type:"run_meta", agent, model, repo?, ref?, pr?, headSha? }— is published by the dispatcher right afterinput— at the ledger reservation, from theRepoContextas resolved then — and once more, with the adoptedheadSha, when the attach finds the PR head moved and adopts it (agent-review item 12) — 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 integerpr, a 7–40 hexheadSha; a meta without agent/model is ignored), and the page renders it as the facts bar under the header, above the request —REVIEW · <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/Nfor a positive integer,/commit/<sha>for a 7–40 hex sha shown as its seven characters;web/src/lib/githubLinks.ts), built only for an allow-listedowner/nameshape — a value of any other shape renders as text, never a link; the review's Reading diff control sits at the row's right edge as a link-weight text button with thegit-compareglyph, the same weight as the chips beside it.runFriction.tsignores it;runEventLines.tsaccepts it (agent + model required); the status card never sees it (published straight to the registry). The Request source line leads with a drawn Slack mark (four lozenges as inline SVG — no external asset under the CSP) and the channel name is the link to the thread:⁝ #general · alice. 404:GET /runs/:idfor an unknown run, an expired one, a wrong token or a tokenless live run answers 404 as a page — the shared shell with aRunNotFoundSeed({ page:"runNotFound", retentionDays };web/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 bodyno run found.Runs index — started column, activity tooltip, pager, expiry divider; run page — total duration. Toggle: item 17's
Show alllink is a checkboxShow completed(checked on?all=1); changing it navigates — the view is a server mode, not a client filter — and the retention note is a real tooltip on the?help (UTooltip, see below) with a screen-reader copy the checkbox isaria-describedby. Started column: every row leads with when it started the way GitHub says it —indexFormat.tsformatRelative:just now(< 45 s),1 minute ago…59 minutes ago,1 hour ago…23 hours ago,yesterday,2 days ago…6 days ago, then the date (Aug 28, with the year when it differs) — painted from the server clock and re-ticked by the page every minute; its tooltip is the exactreceived …(once the run carriesreceivedAt, tracing.md) /started …/finished …stamps (renderer's zone). Activity on the dot:RunSummary.activity(new, additive) is the run's latest one-line activity — the newestassistantline,tool_callsummary, oranswering, whitespace-collapsed and capped at 120 chars, set byRunRegistry.publish()and forwarded throughRunViewfor live rows — and the status dot's tooltip readsnow: <activity>(starting…before the first event) so a glance answers "what step is it on" without opening the run; a finished row's dot reads<status> in <duration>. Nativetitles are gone from the row. Tooltip component: informational tips ride Nuxt UI'sUTooltip(the old hand-rolledinstallTooltips/data-tipcomponent is gone) — shown on hover and keyboard focus, placed/flipped/clamped by Reka UI's floating layer, text only, multi-line tips preserved. Informational tooltips are not shown on touch: the 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=1pages areINDEX_PAGE_SIZE= 25 rows (the service cursor already existed; the page was rendering the 200-row cap): anav.pagerunder the list —Older runs →when the page was full (item 17's cursor); on a cursor page← Newest runs · runs finished before <Mon D, H:MM AM/PM>together on the left (the cursor stamp viaformatDateTime, renderer's zone — item 21 replaced the earlier centeredN shown, which said nothing a reader could use); none on the default view. A cursor page lists finished runs only — the service leaves the live rows off it, they all sort ahead of any cursor — and its feed only repaints rows it already has (the row is looked up for upserts and removals alike): a run starting or finishing now belongs on the newest page, never at the top of an older one. Expiry divider: with run history on (a knownretentionDays), every finished row carriesdata-expires-at = finishedAt + retention; rows leaving within a day get classleaving, agone <YYYY-MM-DD HH:MM>fact in the renderer's zone (exact time on hover), and sit under ONE cut —⏳ Leaving within a day — each row says when it is removed— placed before the first such row (they are the oldest, so it is one cut in the newest-first list). The page re-places the divider after every feed change and once a minute (a row can age into its last day while the page is open) and removes it when nothing is leaving; the sorted row insert skips it. With history off there is no divider and no stamps (every finished row leaves within a minute; the toggle's tooltip says so). Run page: once finished the header says how long the run took — history pages from the record (finished · completed · 2m 27s,RunHistorySeed.durationMs), the live page atendfrom the first→last event stamps (finished · 2m 27s,stopped (soft) · 41s). A turn with no narration reads no commentary (not went straight to tools — the calls are the rows below). A tool whose summary is only its name (submit_verdict) shows the chip alone, no duplicated word. 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_LAYERinweb/src/lib/uiTheme.ts, set on each component'scontentslot 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 (isolateonli.run), so its body's z-index never reaches the page.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 userbadge. Both legacy stop statuses use that label. An interrupted run remains red. The activity tooltip retains the last recorded activity.statusLabelsupplies 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 readsvia <Surface> · <identity>on one line — the identity is the resolved display name (IncomingMessage.userName→RunMeta/RunSummary/RunView/RunRecord.userName), falling back to the id suffix, never a rawslack:U…. Without a thread the mark 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 (↗, classlinked) — a pointer cursor and a chip-style hover state say "clickable"; no caption — opening the thread in a new tab. Only for anhttp(s)://URL; a record is data, and any other scheme renders the plain mark. The run page follows the same outbound rule: the Request block's thread link and the run-meta GitHub links (repo · ref · #PR · head) open a new tab. Turn heads: a step whose turn produced no prose is ONE row — the duration chip with the token facts beside it, no "no commentary" filler for the eye to land on (the filler survives only for a turn with no facts at all); under prose the facts sit ABOVE the head row as a smaller, dimmer line pulled tight against it — metadata reads as a superscript, never as the lede. Run meta:run_metacarries the resolvedeffortbeside the model (one resolution per run — agent/model/effort are fixed at dispatch and cannot change mid-run; if per-turn switching ever lands, the turn blocks would carry it); the meta line reads agent · model · effort · repo (GitHub link) · branch as a quiet unlinked tag (nobody clicks "main"; the sha is gone for the same reason) · the #PR link led by a drawn GitHub mark. Tooltip icons: a source mark's tip leads with an allow-listed icon — Slack's drawn mark (SlackMark.vue), or 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 runswhile n runs are live and carries a drawn dot favicon — green while anything runs, gray when idle — server-rendered in the shell and re-derived by the client on every feed change (src/channels/favicon.tsdata URIs; the CSP'simg-src 'self' data:exists for exactly this favicon plus bundled assets — everything else stays blocked). The dot is one grammar with four tones (FAVICON_BY_TONE: green · amber · red · grey, the same tonesStatusDot.vuepaints in a row), worn only by pages with a state to claim — the runs index and run page (live/idle), and the residents index, whose dot is the fleet's worst resident (resident-repos.md item 42); every other page wears the neutral mark.GET /favicon.icoserves the raw idle-dot SVG (public, cacheable, exact path) — the fallback every page without an inline icon link asks for; the catch-all used to answer it with a text/plainok. An SSE RE-connect reloads the page for a fresh server snapshot: the backend may have restarted, and rows it never knew would otherwise never receive theirremovedevents — a stale tab once pinned the count at runs from a previous backend. Other surfaces render the standard set today; their extended sets (an HTTP caller, an MCP client name) plug into the same mark when the adapters carry them. Repo tag: a repo run shows the repo as a smallowner/nametag 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 tohttps://github.com/<owner>/<repo>(a new tab — outbound links never take the operator off the dashboard; the source mark's thread link likewise) with the full slug on hover; fromRunView.repowhen the run had one, else from a label scope shapedowner/repo(anything else stays a plain scope, never a link). One grid: the row is a stretched link — one<a class="row">covers the<li>(namedopen run <label>for assistive tech) under a<div class="body">whose own links (repo tag, source mark), buttons and tooltip cells take the pointer, everything else falls through to the row; a click on a tooltip cell (dot, started, elapsed) is routed to the row's href by the page — so a link may hold no link and no button, yet the whole row is one click. The actions cell is always present at a fixed width (empty on a finished row), so the facts (elapsed, event count) sit in the same column on a running row with Stop · Kill and on a finished row. The stop buttons' hints rideUTooltip(no native titles remain). Expiry divider: one dashed line under its label, no doubled border or top margin (the row above already draws the line); a leaving row'sgone <when>fact sits in the outcome column (before the facts), in human form —gone Aug 30, 9:17 PM(formatDateTime:Mon D, H:MM AM/PM, the year when it differs from now's, renderer's zone) — so elapsed and event count keep their columns. Snippet budget:composeRunLabel's snippet cap is 100 chars (was 60) and the label cap 160 (registry cap 200): a laptop-width row holds that much after the started column, chips and facts, and a 60-char snippet left half of every row empty. The request snippet has no full-text hover: the row is fed from the run label, which carries only the snippet — the full request is the run page's first event.Run header — the outcome chip; one status vocabulary. The header's two axes are separated for good: the ∿ pulse says only what the CONNECTION is (green connected · amber connecting · red disconnected) and does not exist on a history page (nothing is connected there); the RUN's state is its own element. The run outcome is separate from the connection state.
completedreadssucceeded;failedstays red; both stop statuses readstopped by userin a neutral chip. An interrupted run stays red. An unknown status readsended. A provisional record readsunfinished — no finish recorded. Beside the duration the header shows what the run cost (costs.md item 4c):· $0.038from the seed'scost(formatUsd),· unpricedwhen 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 readsrunning · <stopwatch>— the WHOLE run, on the one duration definition (tracing.mdrunDurationMs): from the moment our process received the message (receivedAt, falling back to the registry'sstartedAt) to the server clock the seed carried (serverNow) projected forward by the browser time since the seed arrived — never an event stamp, never a server stamp minus the browser's clock — ticked each second and frozen atendat the value it had; the index row,runs listand the history seed print the same number — while connected;stopping/stop failed:keep the slot while a stop is in flight. At the liveenda stopped run readsstopped by user; a successful Kill returns to the run list. Other ended runs show the greyended+ duration (an inline ⚠️ failure still arrives as ananswer, so the live page never guesses "succeeded"; a reload shows the record's word). Between thefinishedframe andendthe header readsdelivering… · <total>(phase: finished— the total frozen at the frame's server stamp, the pulse still, the actions gone, the tab idle; a stop in flight keeps its word), and atendthe duration gains the delivery caption from the frame's stamps (tracing.md):delivered in 2s(sealedAt − finishedAt, whenreplyOkis true),reply failed(false), nothing when no reply attempt was measured (a command run that fell through to the agent, a backstop or sweep seal, a stored stream's{}); a history page reads the caption from the record'ssealedAt/replyOk. On the index a finished row whose stream is not sealed yet and whose record is not written (delivering,web/src/lib/indexRow.ts) is amber whatever its status and its dot tip ends· delivering the reply; a seal or a persisted record ends it. A run whose reply never settles stays amber until the registry evicts it.Run page — a loaded skill is its own row. A
skill_useevent (run-visibility.md item 1; skills.md) is folded by the timeline into{ kind:"skill", step, skill:{ name, description, agent, source?, bodyBytes, at } }on the current step — the step whoseuse_skillcall it belongs to (a skill before any step opens one); only anhttp(s)sourcesurvives the fold, a nameless event is ignored, and the skill is never a call (the step's call list is unchanged). The page renders it inside the step's card area as a violet-railed row —[when] 📚 skill <name> <description …> source · 12.3 KB into context— wheresourcebecomes a link (target=_blank,rel=noopener noreferrer) only for the fold-vettedhttp(s)URL and the size comes frombodyBytes; the row joins the step's group like a card, so folding/expanding the step carries it along. Theuse_skillcall card above it keeps saying what the tool did; the row says what was loaded.Duration heat — slow reads warm, over budget reads red, everywhere a time is printed. Every rendered duration on the run page and the runs index used the same muted grey, so a 15-minute
pnpm typegenthat ate a third of 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 inmain.css) so every step of the ramp keeps the same contrast against the page and only hue (amber 88° → red 28°) and chroma (0.06 → 0.17) carry the signal; below the floor the text inherits its muted colour, because most calls are quick and should stay quiet. The anchors are the runtime's own numbers:toolruns 2 s → 20 min (the bash tool'sBASH_TIMEOUT_MAX_MS),turn15 s → 5 min (the friction analyzer's 60 s slow-turn threshold lands on the ramp, not below it),run1 min → 90 min (the coding agent's wall clock). The helper hands the DOM one scalar,--heat-t, and the.heatutility inmain.cssdoes the colour math (oklch(var(--sb-heat-l) calc(…) calc(…))), so the ramp is a theme concern, not a script one; levels 0–3 ride ondata-heatfor tests and styling, and from level 2 the text is also medium weight. Over budget is categorical, never a hotter shade of slow: a command the sandbox killed at its deadline isdata-heat="4", its duration fact istext-badbold with atimed outlabel before it, whatever its duration — and a timed-out call with no computable span still wears the label after its last fact. The signal is exit 124 only: every executor renders its own deadline kill as anexit 124:line (execution.md item 11,bashTimeoutNote) and keeps a foreign SIGKILL out of that path, so 137 (the OOM killer, akill -9) is a plain failure, not a timeout. Painted sites: the call card's duration fact (always its last fact) and the card'sdata-heat; the step head'sthought …chip on theturnscale (replacing the binarytext-warn; the model'squickflag stays as the sub-minute fact); the group summary's tallied tool time, which also goes over when any call in the group timed out; a finished index row's stopwatch on therunscale (a live row stays green — its clock is moving). The fold carries what the paint needs:CallVmgainsexitCodeandtimedOut,TurnVmgainsdurationMs.The timeline — the run's shape, from its spans and stamps alone (tracing.md). Under the This run heading and above the steps — it is the summary of this run —
#timeline, headed Where the time went (web/src/components/run/TimelineSection.vue; its view-modelbuildTimeline,web/src/lib/timelineVm.ts, is pure) reads the page model's span set and loss intervals (spanSet()/losses(), folded per frame from the same stream the log reads,traceVersionbumping per frame) and the header's own window —receivedAt(elsestartedAt) plus the header's total, i.e. tofinishedAton a record and to the projected server clock while live — so its lede closes to the header's total by construction. Finished: the total (4m 12s, the header's) over a bar and its legend — swatch · word · time per item,getting ready 34s · thinking 2m 16s · in tools 1m 10s · finishing up 8s · Switchboard overhead 4s, the word's definition on hover (TERM_DEFINITIONS; a swatch wears its segment's paint class —TERM_PAINTinweb/src/lib/termPaint.tsis the one word→class mapping the segments, the swatches and the phase heads' markers share, each counted bucket its own token in both themes: getting ready blue, thinking the data green (--sb-ok), in tools violet, finishing up muted, the residual hatched) — the printed items summing to the printed total;vm.ledekeeps the one-sentence form (4m 12s — 34s getting ready · …) for the bar'saria-label. Live: the non-zero buckets over the elapsed window, then the bucket currently open as a drill-down —· currently thinking 1m 26s, the deepest open counted span's elapsed, a subset of its bucket and never an addend, omitted when the deepest open span is uncounted or background; the log's tail row names that same span (a model turn…,attaching the workspace…) instead of a rotating verb while one is open — then· currently deliveringafter thefinishedframe and, afterend,delivered in 2s/reply failed/ nothing perreplyOk(item 22's caption). Below the informativeness gate (fewer than two buckets at 5 % or 2 s) the lede is the total and the dominant word (40s — getting ready). The root'squeuedBeforeMs/queuedBehindMs— set when the root starts (startRequestRoot'soriginAt/queuedBehindMs), never after, since a root's only streamed event is itsspan_start— print asqueued … before we saw it/… behind the previous runfrom a minute; a root carryingrestartOfRunId(a restart from its request, run-history.md item 54) printsrestarted from run <id>instead of abehindwait — 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 throughTimelineInput.callTitle, else the display table) with whitelisted facts only (token expired,budget clipped,timeout 20m 00s,waited 4m 00s,exit 1,timed out,2 attempts,failed), and each a link to its row:RankedItem.anchoriscall-<callId>for a tool step andspan-<spanId>otherwise, the idsCallCard,SpanRowand a step's turn stamp on their elements; a click asks the model to open whatever folds the row (reveal: the step's group or the phase head, as a reader's own toggle) and scrolls there with a one-time highlight; the self-time footnote is the heading's hover. On an ended run a step still open at the run's terminal event was cut, not run: it is closed at that event, markedcut by <terminal cause>(cutMarkOfover the record's status — the interruption / the failure / the hard stop / the stop, the genericcut at the run's endwhen 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, thedispatch.*steps and the attach's grafts fold under a Getting ready head, the stepsclassOfcounts as finishing up under a Finishing up head (phaseOfSpan,PhaseGroupVm; each open while its phase is in progress, closed on its own when the loop starts / delivery begins / the run ends, a reader's toggle winning from then on),thought …heads every model turn and the cards are the tool calls, so a reader correlates the legend with the rows; therequestandrun.agentspans are the page's structure and draw no row (isStructuralSpan). A record cut to its budget (the seed'struncated) prints its lost stretch asnot recorded (too large); a live page whose replay was elided printsnot loaded (the record has the full shape); a record with no root shows the total andgetting ready: not recorded (too large); a record written before span schema (the seed'suntimed, tracing.md item 14) shows the total andno timing data— no bar, no ranked steps, no captions — the one neutral empty state, whatever the record carries. Raw span names and the partition live only behindCopy debug JSON; the raw-events link (/runs/:id/events) appears in history mode only.runOwnerOf(src/core/runOwner.ts) names the partition owner fromrun_meta's agent, so a command run'srun.commandis its tools.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'sartifactevents 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;artifactsis a reserved path word likescheduled, 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 same404 no run foundas 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'sartifactevents 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 canonicalpublicationSettlementon the run takes precedence (reclaim and interrupted finish retain a present invalid receipt asnull); 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 asapplication/octet-streamundercheckpoint.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'scontentType(never the object's, never sniffed:X-Content-Type-Options: nosniff), its length,Content-Security-Policy: sandboxso an HTML or SVG file cannot script as this origin,Cache-Control: private, no-store, and aContent-Dispositionthat isinlinefor the four raster image types (INLINE_IMAGE_TYPES: png, jpeg, gif, webp) andattachmentfor everything else — SVG and HTML included — under the recorded name'ssafeBasename. Byte ranges: the players seek by them, so every answer saysAccept-Ranges: bytesand a request's singleRange(bytes=start-end,bytes=start-,bytes=-suffix) is handed to the store'sgetand answered206 Partial ContentwithContent-Range: bytes start-end/sizeand the part'sContent-Length, the other headers as on the whole object; a range starting past the end is416withContent-Range: bytes */sizeand 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: awritethat answers false waits fordrainORclose, 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 adrainthat will never come. A named key whose object is gone (the bucket's lifecycle rule) answers410 artifact expired: files are kept for <retentionDays> days. Without anartifacts:section the route answers 404 and the seeds carry noartifacts. The seed: when a store is configured both run seeds carryartifacts: { urlBase: "/runs/<id>/artifacts/", retentionDays }, the live seed alsotoken— the page builds each row's URL asurlBase+ 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.filesin 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: everyinputevent names its message (messageId— Slack'sts; the run id for a channel without message ids;inbox-<seq>orat-<ms>for a follow-up with none,followUpMessageId), a receivedartifactevent names the message it arrived with (the samemessageId; 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 theattach_filecall that posted it (callId, the id the call'stool_call/tool_resultcarry;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 itsmessageIdnames — 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 theattach_filecall card that posted it — the call itscallIdnames — 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 smallFiles · Nheading 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 from0frto1fr, off underprefers-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; avideo/*file is a<video controls preload="auto">and anaudio/*file an<audio controls preload="auto">—auto, notmetadata: 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'inPAGE_CSP, besideimg-src 'self'); atext/*, 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) readsexpired after N daysin place of the panel and its name is text. The whole-object answer carries its length end to end: the route writesContent-Length, and the bot shim (deploy/cloudflare/knownLength.ts, wired indeploy/cloudflare/worker.ts) re-frames every bodied container answer that names a byte count through aFixedLengthStreamof that size, so the runtime declares the length instead of relaying a stream of unknown length — live, a 31 MB200reached the browser without its length while the206s 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'sartifactsthrough one provided value (ArtifactLinksKey); with noartifactsin the seed the rows are text. The turn that wrote the reply is captionedwrote the replywith 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.A quoted conversation is its own frame (record 0037). The run timeline folds a
referenceevent into onereferencechange besidecontext— 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 instate.referencesoutside 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.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>(keythe unit key<instance>:<unit>, decoded;unita reserved path word likescheduled, so a bare/runs/unitroutes nowhere) serves the shell with aUnitSeed: exactly whatruns unit <key>answers under the viewer'sruns:readpredicate — 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 byweb/green/routes.tsand 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 yetbefore 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; elseStartedonly when the row recordsstartedAt, otherwiseState 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'srunId), then Runs by round: one row per run in the orderruns unitanswers them (coding 0, review 1, coding 1 …), each wearinground n · thread, the agent chip, the outcome, the started time, the one duration and the count, and a tokenlessopen ↗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 tokenlessGET /runs/:id/events(web/src/lib/sseReplay.tsparses the frames; the named transport frames are left out), normalized as the history seed is when the record carries span schema (untimedotherwise), 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 — throughGET /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'sland: once the record is read, that one step drawn under the timeline with the run page's ownStepBlock.vueat the run page's own anchor —span-<turn span>, elsecall-<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 nologIndexlands 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 sayspast a compaction gap at turn n; a hit no run's range holds saysbefore 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 carrieschildren— 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 itsrun_meta(instanceId, on the secondrun_metathe ship branch publishes at the hand-off — the page resolves the LASTrun_metacarrying one, record 0060), and its history seed carriesunits— the instance's unit rows asUnitFacts(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/runsindex 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 readruns findingsmakes — and the seed carries it asview.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, seedsfindingsLedger: { unit, rows }; the facts bar then linksFindings · <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.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 (whoTextinweb/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 wordunknown— 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 ondata-surface), because one person arrives over several credentials (authorization.md item 15: a bound token's runs are the person's) andslack alice,http aliceandcli alicemust 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'svia <Surface> · <identity>sentence. Fromsmthe 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=1andruns list --mineAPIs remain compatible for native and unscoped clients; they are not the green browser's owner selector.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/exitcompatibility remains; all writes retain the actual credential and ordinary authorization.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
Acceptnamesapplication/jsonand nottext/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 onAccepttoo), the same stamps: the page sender (makePageSender, thePageSenderevery view receives) stampscapabilities,viewingAsand the documenttitleon 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 aRouterLink(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.navigategoes through the router for an app path oncemain.tshands the router'spushin (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.leaveis always the browser's), and abeforeResolveguard (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 (pageAddressis 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.vuekeys oneSeedScopeper address (pageAddress) around theRouterView, 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 — andSeedScopeprovides 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 ofwindow.location.searchat 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, sonpm run screenshots:genand a hand in the preview navigate in place too.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 onpublish()— 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 newesttool_call's stamp, and the call in flight with the bound it declared — and the LIVE summary carries them aseventsLast5m(always present on a live row, so a run hung before its first event still reads as one),lastToolCallAtandinFlight: { tool, since, boundMs? }; a finished row carries none (no pace to misread), andRunView/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 bashtimeout(seconds, unbounded when absent — harness-pi.md) rides thetool_callevent asboundMs; a call that declared none is never marked. The row (rowPace/rowStalled/rowBound/paceTipinweb/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), orno tool call for N minin 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 markbash 2083s, bound 600sin red once the in-flight call outran its declared bound — each with a tooltip saying what it measures; a stalled row also wears astalledbadge (amber; red when the mark shows) anddata-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 600sinstead of— running bash (2083s)once the call is past its bound (quietSuffix;inFlightCallAftercarries the bound beside the tool), and every live frame's title ends with· <pace>— the samepaceTextwords — so the meta-monitor's number is on the product, not in Cloudflare logs. The pipeline row (inheritedPace/inheritedPaceTipinweb/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 readswaiting on the runnerwhen 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).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 asinstanceId— the registry keeps it onpublish()from the LAST event naming one (ship_handoffat the hand-off, then the hosted secondrun_meta—RunState.instanceIdinsrc/core/runRegistry/state.ts, projected bysummaryOf), a ledger row reads it from its mirrored events (instanceIdOfEventsinledgerView), a persisted row from its record — so a pipeline's rows can name their parent wherever they are read: a unit's run byparentInstanceId, a conductor's child byparentRunId. The index (groupRunsinweb/src/lib/indexRow.ts;RunsIndexPage.vue,RunRow.vue): a row whose parent is ON THE PAGE nests under it — indented, wearingnestedanddata-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 owngone …cell. The way up (RunLineageSeedinsrc/channels/webSeed.ts;RunsService.unitLineage): a history seed carrieslineage— the record'sparentRunId, or through itsparentInstanceIdthe instance's own record (InstanceFacts.runId) and the unit whose thread the record'sthreadKeyis (its key, id, title and which of its two threads — coding or review), admitted exactly aslistInstanceUnitsis, 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 whoseinstanceIdthe child'sparentInstanceIdnames — and no unit (the live path reads no store). On both seeds a parent LIVE in this process's registry carries its capability token onlineage.parentand 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 apart offacts strip (#lineage) above the facts bar:pipeline ›(parent run ›when no unit is known) to the parent's page andunit U16 · <title> ›to the unit page, the thread named beside them; no strip when the seed carries none.
PR images are not run-reader links
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
| Criterion | Evidence |
|---|---|
| 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 &; 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.
| Criterion | Proof |
|---|---|
| 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.
| Criterion | Proof |
|---|---|
| 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 |
| Criterion | Evidence |
|---|---|
| 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
| Criterion | Evidence |
|---|---|
| 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.
| Criterion | Proof |
|---|---|
| Scheduled work retains independent page proof for surviving plain-English outcome labels. | [unit] web/src/pages/scheduled.test.ts::ScheduledPage::shows the surviving… |