Run visibility
You can see what an agent is doing while it works, in real time. The runner emits a typed stream of run events — each tool call and a redacted one-line summary of its result — and the Slack status card now updates live on every event instead of only on the 5-second heartbeat, so activity is visible as it happens rather than feeling stalled between checklist updates. Secrets are stripped before anything enters the stream. This is the foundation the live-view page (live-view.md) consumes.
Card edits are coalesced (src/core/statusCoalescer.ts, STATUS_UPDATE_MIN_MS = 3 s): the dispatcher still refreshes the card on every event, but the channel sees at most one edit per interval — the first frame after a quiet stretch goes out at once, a burst (a turn that reads five files = ten events) replaces the pending frame and one trailing edit carries the newest, a frame identical to the last one sent is skipped (a heartbeat with nothing new), and done writes its terminal frame immediately with no stale trailing edit after it. Slack's chat.update is ~1 req/s per channel; without this the excess queued behind the client's retry-after handling and the card fell behind reality. The friction ring the dispatcher keeps for the post-run diagnosis (RUN_EVENTS_CAP = 5000) drops tool_result.output on the way in — the analyzer never reads it — so a long run retains kilobytes there, not the tens of megabytes the outputs would add up to; past the cap it evicts the oldest quarter in one block rather than one event per publish (the analyzer wants the newest events, never an exact count).
- Code:
src/core/runEvents.ts(theRunEventtype,redactSecrets,summarizeToolResult,parseExitPrefix,prepareToolOutput);src/runner.ts(RunOptions.onEvent, emitstool_call/tool_result/run_note/assistantand the span records);src/core/statusCardFrame.ts(createCardShell: the one builder for every card paint — ack, spinner frames, the pre-run closes, the done frame);src/core/dispatch/provision.ts(registerRunpublishesinput,run_metaandcontextat the reservation;openAckCardis the ack card and its setup heartbeat);src/core/dispatch/messages.ts(contextMessageTexts— the context events' text, attachments as metadata lines);src/core/dispatch/runLoop.ts(runLoopconsumesonEvent→ live card refresh with a one-line activity trace; publishesanswer,pr_descriptionandpr_openeddirectly to the run registry);src/core/dispatch/run.ts(the shutdown notice every live frame carries);src/core/dispatch/reply.ts(activityLine— the card's one-line activity trace;attachmentSuffix;STATUS_PREFIXES);src/core/statusCardLabel.ts(quietSuffix,inFlightToolAfter— the title's thinking / running-tool suffix);src/core/statusBudget.ts(createStatusBudget— the process-wide edit budget, item 8);src/channels/slack.ts(SlackIO.status,createStatusClient— the Slack handle drawing from it, item 8);src/channels/slack/statusCard.ts(render— the card's Block Kit frame);src/load/cardsLoad.ts(simulateCards, the budget's receipt). - Tests:
src/core/runEvents.test.ts,src/core/runRegistry.test.ts,src/core/runRegistry/projections.test.ts,src/runner.test.ts(run-visibility events),src/core/statusCardLabel.test.ts,src/core/statusCardFrame.test.ts,src/core/statusBudget.test.ts,src/channels/slack.test.ts(SlackIO.status — status budget),src/channels/slack/statusCard.test.ts(render),src/load/cardsLoad.test.ts.
Behavior
- Typed run-event stream: the runner emits
tool_call(the tool + a summary of the call) thentool_result(the tool,ok, a redacted one-line summary) for every tool use, viaRunOptions.onEvent— a small seam so consumers (the status card today, the live page next) never reach into the runner's loop. Two span records join the union (tracing.md):span_start({ spanId, parentSpanId?, name, attrs?, at }) andspan_end({ spanId, parentSpanId?, name, startedAt, durationMs, status, error?, attrs?, at }) — timing, never content: published, counted and stored like every other event, invisible to the card, the analyzer's counts and the stream's clock; there is noturnormcp_tool_useevent kind (a record from before spans carries such objects as kinds no reader knows),run_meta.modelis optional (a command run resolves none), and a content event may name the span it ran under (spanId).parseRunEventLinesaccepts the two records (structurally checked) andwrapEventwraps aspan_end.errorand atool_result.outputlike every other free-text field. Wrap-up/budget notices still go toonProgressas text and into the stream as typedrun_noteevents; every event carriesat(epoch ms) and an infra-level tool failure is markedinfra: true— see run-friction.md. Operator stops add two note kinds carrying amode(soft/hard):stop_requested(published by the registry when/runsasks) andstopped(emitted by the runner when it honors the ask) — see run-loop.md item 8. The stream also carries the run's final answer as ananswerevent ({ type:"answer", text, raw? }, redacted, uncapped), published by the dispatcher once per run before the channel reply — the run record is the source of truth and Slack/GitHub project from it (live-view.md item 11).textis the CANONICAL Markdown from the typed-output boundary andrawthe model's own text when normalization changed it (llm-output.md item 5). Two narrative events complete the record (live-view.md item 12):input({ type:"input", text }— the request as received, directive-stripped, with an attachment count suffix, redacted, uncapped; published by the dispatcher directly to the registry right after the run is registered, never throughonEvent) andassistant({ type:"assistant", text }— the model's prose that rode alongsidetool_usein one completion, redacted, uncapped; emitted by the runner before that turn'stool_calls and never for the final text-only completion, which is theanswer). Withcontext(item 6 — the thread turns the model was given, published right afterinput) the full event list matches theRunEventunion insrc/core/runEvents.tsexactly:input(the request as received),context(one prior thread turn fed to the model),run_meta(what the run is about — agent, model, effort, repo/ref/PR/head; live-view.md item 19),assistant(the model's prose between tool calls),tool_call(a tool use),tool_result(its outcome),run_note(the runner's lifecycle notices),skill_use(a skill loaded into the model's context — below),review_artifact(a review run's reading diff, or the PR's description as data — submitted by the coding run at PR open, or parsed from the body by the review; reading-diff.md items 4 and 7),answer(the run's final answer),pr_description(a coding run's accepted typed PR description; pr-description.md),pr_opened(the PR the coding post-step opened or edited; pr-description.md item 5) andship_round(oneagent:shipround boundary —startedplus its settle outcome; agent-ship.md item 12). The friction analyzer counts only the tool events andrun_notes: the five narrative events (input,context,assistant,answer,run_meta) and the five side facts (skill_use,review_artifact,pr_description,pr_opened,ship_round) count toward neitherrunMsnoreventCount. Tools publish too (skills.md):ToolContext.publishis the runner's own emitter handed to every tool, so a fact only a tool knows lands in the stream stamped and ordered with the runner's events. The first such fact isskill_use—{ type:"skill_use", skill, description, agent, source?, upstream?, bodyBytes }, whereupstreamis the structured vendoring provenance{repo, commit}fromSkill.upstream(skills.md items 9–10) — published byuse_skillon a successful load, between its owntool_callandtool_result(the generic call only saysuse_skill <name>; this carries what was loaded, for which agent, from where — the pinned upstream URL when vendored — and how many bytes went into context). A refused load publishes nothing; with nopublishon the context (CLI, most tests) the tool simply does not publish.runEventLinesaccepts it (skill + agent + numeric bodyBytes required); the analyzer neither counts it ineventCountnor treats it as a step or a turn boundary; the run page renders it as its own row inside the step (live-view.md item 21). Every event published throughRunRegistry.publishis stamped with a monotonic per-runseq(1-based) —input,context,assistantandanswerincluded — so replays and history readers can resume from a position. Pairing and truth on the tool events (live-view.md item 13): bothtool_callandtool_resultcarrycallId— the provider'stool_useid — so a consumer pairs them explicitly, not by position.tool_result.okmeans the tool succeeded: false when the tool threw AND, forbash, when the command exited nonzero — every executor renders that as anexit <code>:first line for the model, andparseExitPrefixis the single reader of that contract (numeric code →exitCode; an errno string → failed with no code; ordinary output that merely mentionsexit 1:later is a clean 0).exitCoderides on bash results (0 on a clean run). Atool_resultalso carriesoutput: the tool's text, control-stripped → redacted → capped atTOOL_OUTPUT_CAP(8000 chars,…[N more chars]), for the run page's expandable card — the status card and the friction analyzer keep readingsummary.describeToolCallnames the target of non-bash calls from the conventional input keys (path,name,url,query):use_skill code-review-and-quality,web_fetch https://…. Theinputevent carriessource({ url?, channel?, user? }) fromIncomingMessage.sourceUrl/channelName/userNamewhen the adapter supplied them. - Live in-channel card: the dispatcher refreshes the status card immediately on each event, appending a one-line activity trace (
→ <call>/✓|✗ <tool>: <result>) below the agent's checklist — so progress shows per tool, not only every 5 s. Full command output still goes to stdout for operators. The closed card (✅/❌/⏹/⛔) keeps the run link and the checklist and drops only the transient activity trace — the finished card is the one place a reader looks after the fact, and the run page outlives the run and shows the final answer per live-view.md item 11. A clean ✅ close checks every checklist item off (leading○/✱markers become✓— the run completing is the proof they happened, and the model rarely re-posts the checklist after its last step); a stop or failure (❌/⏹/⛔) keeps the honest partial state. An emptyupdate_statusnever erases the checklist (ignored; the last non-empty one stands) — the closed card is the run's durable progress record, and an agent "clearing" its status while wrapping up would otherwise blank the finished card. The run link is structured, not inlined (StatusUpdate.link = { url, label }): each channel renders it in its own short form — Slack as a typedlinkelement labeledLive runat the top of the card body, the CLI as the bare URL. The coalescer's identical-frame key includes the link, so a link-only change is never dropped. The Slack card body is arich_textblock, never asection: Slack's client collapses a section's mrkdwn behind "Show more" at five rendered lines (measured: 8/12/16/20-line sections all fold to five; rich_text at 30 lines does not), and a folded card re-renders expanded-then-collapsed on everychat.update, shoving the thread up and down on each heartbeat. The card is link + checklist + activity ≈ 6+ lines, permanently past a section's fold — shortening the run link to a hyperlink alone does not stop the jumping, because the fold counts rendered lines, not URL width. rich_text has no per-block fold, and its literaltextelements also neutralize mrkdwn injection (<!channel>in tool output renders as characters) by construction. The title's quiet suffix tells model time from tool time (quietSuffix+inFlightToolAfter,src/core/statusCardLabel.ts): after 20 s without an event the title gains— thinking (Ns since last tool)when the last event was a result (the wait is the model's turn), or— running <tool> (Ns)while atool_callhas notool_resultyet (the wait is the tool's, however long — the executor's deadline ends it, execution.md item 11, not the model). Without the distinction apnpm typecheckin flight for an hour readsthinking (3601s since last tool), and a run stuck on a dead sandbox looks like a stalled model call. The closed card carries neither; the catch-up sweep strips both forms from an interrupted card (slack-channel.md item 8). Every paint of the card comes from one builder (createCardShell,src/core/statusCardFrame.ts): the 👀 ack, each spinner frame (glyph, label, elapsed seconds, quiet suffix, deploy notice, detail lines, link), the sixnot started (reason)gate closes, the ship preflight's refusal, the❌ setup failed · reasonclose and the done frame (icon, elapsed, detail, link) — so what a title carries changes in one place, and the live-card prefixes the catch-up sweep keys on derive from the same glyph list (tracing.md). - Secrets never enter the stream:
redactSecretsstrips known credential formats before summaries leave the process — a safety gate, since the stream is shown in-channel (and, later, on a shared page). Coverage: Slackxox*; GitHubghp_/github_pat_/x-access-token:; Anthropic/OpenAIsk-*; AWSAKIA*and GCPAIza*; Stripesk_live_/whsec_;Authorization: Bearer/Basic/tokenheaders;Cookie/Set-Cookievalues; URL/connection-string basic-auth (scheme://user:pass@) andcurl -u user:pass; PEMPRIVATE KEYblocks; and a name-gated assignment pass that hides the value of any…SECRET/…KEY/…TOKEN-style identifier (soAWS_SECRET_ACCESS_KEY=…is caught, whilePORT=3000,REACT_VERSION=…,MONKEY_BARS=…, SHAs, and digests are not). Redaction always runs before length-capping (redactAndCap), so a secret near a truncation boundary can't leak as a raw fragment. - Result summaries are bounded:
summarizeToolResultreturns the first non-empty (redacted) line, capped at 200 chars, with a size note for larger output — enough to see what happened without dumping the payload. - Failures are visible: a throwing tool emits
tool_resultwithok:falseand the (redacted) error, rendered with a✗. - The full exchange is in the stream — as the narrative events of item 1, not a separate variant. Right after
registry.create()the dispatcher publishes theinput(the request), then onecontextevent ({ type:"context", text, seq, at }) per thread-context turn fed to the model (prefixeduser:/assistant:); theansweris published inside the runtry, beforefinish()(a content publish on a finished run is a silent no-op — the test for this goes red if the publish is moved afterfinish; span records, timing rather than content, stay accepted until the seal — live-view.md item 4).inputandcontexttext is humanized first (humanizeMessageText: Slack<url>/auto-link<url|url-ish>→ the whole url, custom<url|label>→label (url)— never compacted, so the page can link it —<@U…>/<#C…|name>→@user/#name,& < >unescaped once); theansweris model prose, not mrkdwn, and is never unescaped. All three passredactSecretsand are uncapped — the run record is the source of truth; the registry's byte-bounded backlog (live-view.md item 2) and the record's per-event budget (fitRecordToBudget, run-history.md) bound what is kept. Attachments never enter the stream: on the request they are a count suffix ([+2 images, 1 document]); on a context turn each becomes one metadata line,[attachment: name · mime · N bytes]— no base64, no file body. Context is bounded to the newest 20 turns within 256 KB of redacted text, andrunHistory.includeContext: falsesuppresses context events entirely (request and answer still flow). The run label is redacted atcreate()(redactAndCap(label, 200)), so a secret in the request snippet never reaches the index or aRunSummary. The dispatcher-published events bypass the card trace (lastActivity), the per-step[tool]log and the friction analyzer's timing/count (runMs/eventCountare identical with or without any narrative event); each one logs exactly one[event] <thread> type=<type> bytes=<n>line — never the text. The name-gated redaction pass also sees through a quoted JSON member name ({"password":"…"}), since pasted JSON is the common shape in a request.
Acknowledge before preparing. The status card is posted the moment the agent is resolved — 👀 *<agent>* on `<model>` · preparing workspace… — BEFORE repo/PR resolution, memory retrieval and executor selection, which together can take minutes (a resident attach, or a cold sandbox clone + install) and used to be dead silence in the thread. The same card then becomes the run card (the first spinner frame replaces the 👀 title) and ends ✅/❌ as before. If setup stops before a run — repo allowlist refusal, the ask-once branch question, or a thrown setup error — the card is closed with a one-line reason (🚫 … not started (repo access), 🌿 … not started (which branch?), ❌ setup failed · <error>) so a spinner is never left behind; the reply text is unchanged. Refusals that happen before an agent is resolved (unknown/unauthorized agent) still get no card — there is nothing to acknowledge on behalf of. 7. The request's own steps are on the stream (tracing.md items 17–18). A run's stream opens with the request's root (span_start request) and the setup steps that preceded the run (dispatch.* span pairs, backfilled at registry.create from the request's root), then input, run_meta (now carrying traceId), context; the loop's spans and content follow live; after the answer the card close and the reply are post.card_close / post.reply spans, and the root's own end lands after the seal, so it never reaches the stream. A command run has the same shape — the root, dispatch.channel_visibility, input, run_meta { agent: "command", traceId } (no model, no repo), run.command, answer, post.reply. Readers of the run's story see the content events only (isSpanRecord); the timing rows come from the spans. 8. One status-edit budget per process; the terminal frame never waits behind it. Slack rates chat.update per app (Tier 3, about 50 a minute), not per card, so N live runs share one allowance — seven cards at the dispatcher's cadence already exceed it, and the WebClient's default answer to a 429 is to pause its whole request queue and retry for up to 30 minutes — with the reply queued behind its own card close. The Slack status handle therefore draws every card edit from one process-wide StatusBudget (src/core/statusBudget.ts, STATUS_EDITS_PER_MINUTE = 50): a token bucket at the published rate where progress frames (heartbeats, tool lines) take a token only while a fifth of the rate is left in reserve, only once per card per fair share (the progress rate split across the live cards, so lockstep siblings all get a turn — a card silent for LIVE_CARD_WINDOW_MS (60 s, twelve missed heartbeats) is swept from the share, so a run that died without closing never widens the others' share for good), and at most once per channel per second (STATUS_EDIT_CHANNEL_SPACING_MS); a refused progress frame is dropped, never queued — the next heartbeat repaints — and the close's log line counts them. A terminal frame reserves a slot instead: taken now when a token is free and the channel is clear, otherwise the bucket goes negative, the channel's next free second is booked, and the frame is sent when the reservation is funded — on an unref'd timer, so done() returns after at most one round trip and the reply never waits. Card edits and the shimmer ride a second Web API client (createStatusClient: rejectRateLimitedCalls, one transport retry) so a 429 on a heartbeat cannot pause the queue the reply's chat.postMessage is in; a rate-limited terminal frame is re-sent after Slack's Retry-After up to TERMINAL_RESENDS (10) times, off the reply's path, then given up with a warning. The card's post and a resumed card's first edit stay on the main client — they must land. The no-op handles (HTTP, MCP, CLI) never draw from the budget. load:cards (load-harness.md item 9) drives the real coalescer and the real budget against the fake Slack limits: fifty cards on the budgeted client see zero refusals and land every terminal frame, where the pre-budget client lost most of them.
Validation criteria
| Criterion | Evidence |
|---|---|
| Ack card posted before executor selection; 👀 title names agent+model; the same card carries the run and ends ✅ (no second card) | [unit] src/core/dispatcher.test.ts::acknowledges the thread with a 👀 card BEFORE executor selection … (red-verified: statuses were empty at selection time before the change) |
Setup stopped before the run closes the card with a reason: ask-once branch → not started; thrown setup error → ❌ setup failed + the error reply | [unit] ::closes the ack card with a reason when setup stops before the run …, ::closes the ack card with ❌ when setup throws …; resident-repos row "needs-ref → ONE question" |
| Live: a mention on a repo with no warm resident shows the 👀 card within ~2 s, then the spinner, then ✅ — no multi-minute gap | [agent] Mention the bot (any agent — the ack path is agent-agnostic, the dispatcher posts before executor selection) on a repo with no warm resident; time the thread: 👀 card within ~2 s of the mention, then the spinner frames, then ✅ — never a multi-minute silent gap before the first card. |
| The closed ✅ card keeps the run link (and the checklist) | [unit] src/core/dispatcher.test.ts::live run-view wiring …::puts the per-run capability link on the status card when PUBLIC_BASE_URL is set (asserts the LAST frame too; red-verified: without the fix the final frame carries only the checklist) |
| The ✅ close checks every checklist item off (✱/○ → ✓, ✓ stays); ❌/⏹/⛔ keep the partial state | [unit] src/core/dispatcher.test.ts::closed-card checklist and review verdict run link::the ✅ close checks every checklist item off… (red-verified); failure path: ::a failed run keeps the honest partial checklist… |
An empty update_status never erases the checklist — the closed card keeps the last non-empty one | [unit] src/core/dispatcher.test.ts::closed-card checklist and review verdict run link::an empty update_status never erases the checklist… (red-verified) |
The run link rides on StatusUpdate.link, never inline in detail; Slack renders it as a typed link element above the detail (a link-only frame still gets its body block); a frame whose only change is the link is not coalesced away | [unit] src/core/dispatcher.test.ts::…::puts the per-run capability link… (no /runs/ in any detail), src/channels/slack/statusCard.test.ts::render…::renders frame.link as a typed link element…, ::renders a link-only frame…, src/core/statusCoalescer.test.ts::a frame whose only change is the link is NOT skipped… (red-verified: 5 failing before the change) |
The Slack card body is rich_text, never a foldable section; untrusted detail lands in a literal text element (<!channel> cannot fire); the title stays escaped mrkdwn | [unit] src/channels/slack/statusCard.test.ts::render (status card rich_text body)::renders the body as a rich_text block, never a foldable section, ::carries untrusted frame.detail verbatim in a literal text element…, ::escapes frame.title… (red-verified: 6 failing against the section-based render) |
| Live: during a run the Slack card never grows a "Show more" fold and heartbeat edits don't shift the thread | [agent] Fold probe in a channel the bot is in: post 8/12/16/20/30-line section cards — all fold to five lines + "Show more"; 20/30-line rich_text cards render in full; a live-shaped rich_text card (link + checklist + activity) stays unfolded through 6 chat.updates toggling the activity line. |
Runner emits tool_call then tool_result per tool use | [unit] src/runner.test.ts::run-visibility events::emits tool_call then tool_result for each tool use |
Failing tool → tool_result ok:false with the error | [unit] ::run-visibility events::marks a failing tool with ok:false |
callId pairs each result to its call; bash nonzero exit → ok:false + exitCode (never infra); clean run → exitCode: 0; non-bash carries no exitCode; output is redacted + escape-stripped alongside the summary; non-bash calls name their target | [unit] src/runner.test.ts::run-visibility events::pairs each tool_result to its tool_call by callId…, ::carries the bash exit code and marks a nonzero exit as ok:false…, ::a clean bash run carries exitCode 0…, ::non-bash tools carry no exitCode…, ::carries the redacted, escape-stripped tool output…, ::names the target of non-bash calls… (red-verified: 12 failing before the change) |
parseExitPrefix: numeric / errno / clean / mid-text mention / leading escape or whitespace; prepareToolOutput: strip → redact → cap at 8000 with a note, empty → "" | [unit] src/core/runEvents.test.ts::parseExitPrefix::*, ::prepareToolOutput::* |
Runner emits assistant only for text alongside tool_use (before the tool rows), never for the final answer; redacted, uncapped | [unit] src/runner.test.ts::assistant text turns in the event stream::* |
Dispatcher publishes input first (directive-stripped, attachment suffix, redacted); card shows assistant as a capped 💬 line | [unit] src/core/dispatcher.test.ts::live run-view wiring …::publishes the request as a redacted \input` event…, ::shows an `assistant` turn on the status card…` |
| Secrets redacted from result summaries | [unit] src/runner.test.ts::run-visibility events::redacts secrets in tool_result summaries; src/core/runEvents.test.ts::redactSecrets::* (redaction red-verified) |
| Result summaries first-line + capped + size note | [unit] src/core/runEvents.test.ts::summarizeToolResult::* |
| Redaction is conservative (prose/config untouched) | [unit] ::redactSecrets::leaves normal text (incl. the word 'token' in prose) untouched, ::does not over-redact ordinary config / output (no false positives) |
| Redaction covers realistic env/curl/cloud/connection-string shapes | [unit] ::redactSecrets::redacts realistic env / curl / cloud / connection-string secrets (fixtures are real leaked payload shapes) |
| Redact-before-cap: no fragment leak at a truncation boundary | [unit] src/core/runEvents.test.ts::redactAndCap::redacts BEFORE capping — a secret near the boundary never leaks as a fragment; src/runner.test.ts::run-visibility events::redacts a secret in a long bash command before capping… |
2: the quiet suffix is empty inside 20 s; past it, no tool in flight → thinking (Ns since last tool), a tool in flight → running <tool> (Ns), never thinking; a tool_call opens the in-flight tool, its tool_result closes it, every other event leaves it | [unit] src/core/statusCardLabel.test.ts::quietSuffix::*, src/core/statusCardLabel.test.ts::inFlightToolAfter::* |
2: on a live run a tool running past 20 s puts running bash (Ns) on the heartbeat frames — never thinking — and the label returns to thinking once its result is in; the closed card carries neither | [unit] src/core/dispatcher.test.ts::in-flight tool label on the live status card …::a tool running past 20 s is labelled… |
2: every card paint comes from one builder — the 👀 ack, the rotating spinner frame with elapsed time in clock style (floored, 3m 04s; tracing.md item 5), suffix/notice/joined detail/link, a label note reaching later frames, the eight pre-run closes pinned as a table with no duration and no link, the done close with icon/duration/detail/link for any outcome, and the live prefixes derived from the glyph list | [unit] src/core/statusCardFrame.test.ts::createCardShell — every paint comes from one builder::* |
| Live card refresh per event (perceived-latency fix) | [agent] Run any tool-using agent (agent:review run \echo A`) and watch the Slack card: it ticks per tool call/result (→/✓` lines appear as each tool runs), not only every 5 s. |
| External live-view page consuming this stream | See live-view.md (per-run capability token + SSE; consumes this same event stream; the Access-gated index and the tokenless finished-run page are bound to the viewer's actor — authorization.md items 5–7). |
Card edits coalesced: first frame immediate, a burst → one trailing edit with the newest frame, identical frames skipped, done immediate and never followed by a stale edit | [unit] src/core/statusCoalescer.test.ts::coalesceStatus::* |
| 8: the budget — progress frames stop at the reserve (a fifth of the rate by default), at each live card's fair share (a card silent past the live window is swept out of it), and at the channel spacing; a terminal frame takes a token at once or reserves one (negative bucket, funded-at wait, channel slot booked) and progress frames yield until it is funded; refill at the rate, capped, never drained by a clock going backwards | [unit] src/core/statusBudget.test.ts::process budget::* |
8: the Slack handle — the card is posted on the main client, every edit/shimmer/close on the status client; a refused progress frame is dropped, counted and logged at the close; a rate-limited terminal frame is re-sent after Retry-After up to the cap without holding done(), a progress one is not, any other failure is dropped; a terminal frame the budget cannot fund now is sent when it says while done() returns at once; createStatusClient rejects rate-limited calls; a resumed run's edits ride the status client too | [unit] src/channels/slack.test.ts::SlackIO.status — status budget::* |
| 8: fifty cards on the budgeted client — zero refusals, every card painted, every terminal frame landed, the same edits with the limits lifted; today's seven-card shape clean | [unit] src/load/cardsLoad.test.ts::simulateCards::* |
A tool_result's summary and output come from ONE strip+redact pass and equal the two separate functions | [unit] src/core/runEvents.test.ts::prepareToolResult::* |
The parser accepts the span records (a start needs spanId + name; an end also numeric timing and an ok/error status) and a run_meta without a model; wrapEvent wraps a tool result's output and a span end's error | [unit] src/core/runEventLines.test.ts::parseRunEventLines::accepts the span records…, src/core/runEventLines.test.ts::parseRunEventLines::accepts the timeline events…, src/core/commands/runs.test.ts::runs.get / runs.events / runs.friction::wrapEvent wraps a tool result's output and a span end's error too… |
Every published event carries a monotonic per-run seq; a content event published after finish() is dropped (a span record stays accepted until the seal) | [unit] src/core/runRegistry.test.ts::RunRegistry — text events, seq, label redaction …::stamps every published event with a monotonic per-run seq …, ::an event published after finish() is a silent no-op …, src/core/runRegistry.test.ts::RunRegistry — finish and seal::finish keeps subscribers attached; span records publish after finish… |
The answer lands in the registry snapshot (published before finish), after the input; the spy-registry wiring tests see input … answer around the steps | [unit] src/core/dispatcher.test.ts::input / context / answer events in the run stream …::the answer is in the registry snapshot … (red-verified: moving the publish after finish() → expected ['input'] to deeply equal ['input','answer']); ::live run-view wiring …::registers the run, publishes its events, and finishes it, ::publishes the final answer as a redacted \answer` event before finishing the run and before replying` |
| A 20 KB request is published whole (no publish-time cap) | [unit] ::a 20 KB request is published whole … |
A PEM block, a multi-line .env paste and {"password":"…"} inside a large request are absent from the stream; ordinary config survives | [unit] src/core/dispatcher.test.ts::input / context / answer events in the run stream …::a PEM block, a multi-line .env paste and a JSON password inside a 16 KB message never reach the stream; src/core/runEvents.test.ts::redactSecrets::redacts the value of a quoted JSON member whose name marks it secret (red-verified: {"password":"…"} passed through untouched before) |
Label with a secret is redacted at create() (index + feed) | [unit] src/core/runRegistry.test.ts::…::redacts a secret in the label at create() … |
Image + document → metadata lines only in context events, a count suffix on input (no base64, no file body anywhere) | [unit] src/core/dispatcher.test.ts::…::attachments become metadata … |
| 50-turn thread → ≤ 20 context events (the newest, in thread order) within 256 KB | [unit] ::a 50-message thread yields at most 20 context events (the newest) within 256 KB total |
runHistory.includeContext: false → no context events; request and answer unchanged | [unit] ::\runHistory.includeContext: false` suppresses context events …` |
Friction runMs/eventCount/findings identical with and without the narrative events | [unit] src/core/runFriction.test.ts::analyzeRunFriction — narrative events are not steps; \context` is invisible to timing:😗, ::analyzeRunFriction — empty / untimed input::ignores the timeline events …` |
A multi-line request logs exactly one [event] … type=input bytes= line (no text) and never reaches the card trace | [unit] src/core/dispatcher.test.ts::…::a multi-line request produces exactly one log line … |
</script><script>alert(1)</script> and "><img src=x onerror=alert(1)> in seeded text are \u003c-escaped inside the JSON seed island (which can therefore never be closed by seeded text; no inline script executes under the shell CSP); context renders through the same markdown guard as input | [unit] src/channels/webShell.test.ts::serializeSeed::*, src/channels/liveView.test.ts::…::AE9: a persisted … message is inert on the page, web/src/pages/runPage.test.ts::RunPage — history mode::collects context turns into the collapsed Earlier-in-this-thread block with a count; src/channels/markdownLite.test.ts::renderMarkdownInto — safety contract::* |
Slack-authored text (channelId prefix slack:, isMrkdwnChannel) is humanized before publish: <url>/auto-link `<url | url-ish>→ whole url, custom<url |
Text from any other channel (http:, mcp:, cli:, cron) is recorded RAW in input and context — exactly what the model was dispatched; only mrkdwn is unwrapped | [unit] src/core/dispatcher.test.ts::input / context / answer events in the run stream …::humanizing is Slack-only … (red-verified: `expected 'https://github.com/o/r/pull/1 please …' to be '<https://github.com/o/r/pull/1 |
ToolContext.publish is the runner's emitter: a tool's publish lands in onEvent stamped and ordered with the tool events (tool_call → skill_use → tool_result) | [unit] src/runner.test.ts::run-visibility events::a tool's ctx.publish reaches onEvent, stamped and ordered with the tool events |
use_skill publishes skill_use with skill/description/agent/source/bodyBytes on success only; nothing on a refused load; works without a publisher | [unit] src/tools/skills.test.ts::use_skill tool::publishes a \skill_use` event…, ::publishes nothing on a refused load…` |
skill_use accepted by parseRunEventLines (skill + agent + numeric bodyBytes), skipped when malformed; invisible to the friction diagnosis (eventCount, toolCalls, findings, runMs identical with or without it) | [unit] src/core/runEventLines.test.ts::parseRunEventLines::accepts \skill_use`…, src/core/runFriction.skillUse.test.ts` |
RunSnapshot.truncated is true iff the bounded backlog dropped events (eventCount > events.length); the dispatcher passes it to analyzeRunFriction(events, { finished, truncated }) so a diagnosis over a head-truncated stream is stamped truncatedInput | [unit] src/core/runRegistry/projections.test.ts::RunRegistry — terminal status + finishedAt on the summary; truncated on the snapshot (review)::snapshot/snapshotById report \truncated` …`, run-history.md truncated row |
7: the setup spans precede input, the post spans follow answer; a command run's content is input · run_meta · answer around run.command and the reply | [unit] src/core/dispatcher.test.ts::live run-view wiring …::registers the run, publishes its events, and finishes it, ::inline command runs + run receipts …::\friction report` is a run…, ::run history write path …::an inline `friction report` run is persisted like an agent run…` |