Skip to content

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/statusCoalescer.ts (coalesceStatus forwards terminal delivery receipts without stale trailing progress), src/core/runsService.ts (item 9: listUnitRuns, listInstanceUnits, listChildren, searchSession, listFindings under visibleTo), src/core/commands/runs.ts (item 9: runs unit, runs children, runs search, runs findings), src/core/runEvents.ts (the RunEvent type, redactSecrets, summarizeToolResult, parseExitPrefix, prepareToolOutput); src/core/harness/pi/bridge.ts and src/core/harness/pi/harness.ts (emit tool_call/tool_result/run_note/assistant and the span records onto the run's onEvent); src/tools/status.ts (update_status: the checklist the card shows, through ToolContext.reportProgress); 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 (registerRun publishes input, run_meta and context at the reservation; openAckCard is 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 (runLoop consumes onEvent → live card refresh with a one-line activity trace; publishes answer, pr_description, pr_opened and review_posted directly 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, inFlightCallAfter — the title's thinking / running-tool suffix, and the bound-exceeded mark of live-view.md item 32); 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/runsService.test.ts and src/core/commands/runs.test.ts (item 9), src/core/runEvents.test.ts, src/core/runRegistry.test.ts, src/core/runRegistry/projections.test.ts, src/core/harness/pi/bridge.test.ts (the events the bridge emits), 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 ​

  1. Typed run-event stream: the harness's bridge emits tool_call (the tool + a summary of the call) then tool_result (the tool, ok, a redacted one-line summary) for every tool use pi makes, onto the run's onEvent — a small seam so consumers (the status card, the live page) never reach into the loop (harness-pi.md item 5). Two span records join the union (tracing.md): span_start ({ spanId, parentSpanId?, name, attrs?, at }) and span_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 no turn or mcp_tool_use event kind (a record from before spans carries such objects as kinds no reader knows), run_meta.model is optional (a command run resolves none), a content event may name the span it ran under (spanId), and a tool_call and a tool_result may name the session-log row their turn landed on (logIndex, run-history.md item 53). parseRunEventLines accepts the two records (structurally checked) and wrapEvent wraps a span_end.error and a tool_result.output like every other free-text field. Wrap-up/budget notices still go to onProgress as text and into the stream as typed run_note events; every event carries at (epoch ms) and an infra-level tool failure is marked infra: true — see run-friction.md. Operator stops add two note kinds carrying a mode (soft/hard): stop_requested (published by the registry when /runs asks) and stopped (emitted by the harness when it honors the ask) — see harness-pi.md item 6. The harness adds three: compacted (pi summarized its older context; the summary names the token counts), harness_error (the extension failed, pi asked a dialog no one answers, or emitted a kind this build does not know — named) and tool_refused (the harness's gate refused a tool call, the tool and the rule in the summary); no new event type — the stream is the vocabulary the native loop wrote, and the kinds only that loop wrote (sandbox_dead, fleet_busy, sandbox_restarted, stuck_loop) stay on the union for the records from before its deletion. The stream also carries the run's final answer as an answer event ({ 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). text is the CANONICAL Markdown from the typed-output boundary and raw the 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, full authored text for operator binds and directive-stripped on legacy routes, with an attachment count suffix, redacted, uncapped; published by the dispatcher directly to the registry right after the run is registered, never through onEvent) and assistant ({ type:"assistant", text } — the model's prose that rode alongside tool_use in one completion, redacted, uncapped; emitted by the bridge before that turn's tool_calls and never for the final text-only completion, which is the answer). With context (item 6 — the thread turns the model was given, published right after input) the full event list matches the RunEvent union in src/core/runEvents.ts exactly: 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_input (later complete arguments for an existing call), tool_result (its outcome), run_note (the harness'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), review_posted (the pull request, head and verdict the review post-step landed; agent-review.md item 18) and ship_round (one agent:ship round boundary — started plus its settle outcome; agent-ship.md item 12). The friction analyzer counts only the tool events and run_notes: the five narrative events (input, context, assistant, answer, run_meta) and the six side facts (skill_use, review_artifact, pr_description, pr_opened, review_posted, ship_round) count toward neither runMs nor eventCount. Tools publish too (skills.md): ToolContext.publish is the run's own emitter handed to every relayed tool, so a fact only a tool knows lands in the stream stamped and ordered with the bridge's events. The first such fact is skill_use — { type:"skill_use", skill, description, agent, source?, upstream?, bodyBytes }, where upstream is the structured vendoring provenance {repo, commit} from Skill.upstream (skills.md items 9–10) — published by use_skill on a successful load, between its own tool_call and tool_result (the generic call only says use_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 no publish on the context (CLI, most tests) the tool simply does not publish. runEventLines accepts it (skill + agent + numeric bodyBytes required); the analyzer neither counts it in eventCount nor 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 through RunRegistry.publish is stamped with a monotonic per-run seq (1-based) — input, context, assistant and answer included — so replays and history readers can resume from a position. Pairing and truth on the tool events (live-view.md item 13): both tool_call and tool_result carry callId — the provider's tool_use id — so a consumer pairs them explicitly, not by position. tool_result.ok means the tool succeeded: false when the tool threw AND, for bash, when the command exited nonzero — every executor renders that as an exit <code>: first line for the model, and parseExitPrefix is the single reader of that contract (numeric code → exitCode; an errno string → failed with no code; ordinary output that merely mentions exit 1: later is a clean 0). exitCode rides on bash results (0 on a clean run). A tool_result also carries output: the tool's text, control-stripped → redacted → capped at TOOL_OUTPUT_CAP (8000 chars, …[N more chars]), for the run page's expandable card — the status card and the friction analyzer keep reading summary. describeToolCall names the target of non-bash calls from the conventional input keys (path, name, url, query): use_skill code-review-and-quality, web_fetch https://…. The input event carries source ({ url?, channel?, user? }) from IncomingMessage.sourceUrl/channelName/userName when the adapter supplied them. A route event ({ preset, reason, model }, routing-and-config.md item 21) is a side fact like pr_opened — published by the dispatcher straight to the registry after run_meta, head material, never a step in the card's or the analyzer's counts — and run_meta.agentSource names how the run's preset was chosen. An artifact event ({ direction: "in" | "out", key, name, size, contentType }, execution.md item 20, record 0033) is the same kind of side fact for a file that moved through the artifact store — received from the thread (in) or sent by attach_file (out): it names the store KEY and never a URL (parseRunEventLines refuses a payload carrying url, so a producer that stored a signed URL by mistake is skipped, not repaired); the run page's proxy route (live-view.md item 26) mints the signed GET per request. The friction analyzer ignores it like skill_use; the record cap leaves it alone (it has no text field).
  2. Live in-channel card: the dispatcher refreshes the status card immediately on each event. At verbose and debug it paints current activity below the checklist so progress shows per tool, not only every 5 s; at quiet it paints the checklist alone, without internal tool or pace narration. The checklist is truthful at every moment (update_status): its markers are facts, not intentions — an item is ✱ from the first command that does it and ✓ only after the model has read the result that proves it, never in the same turn as the command and never for a reporting step not yet done. The rule is ONE sentence, statusCardRule in src/agents/registry.ts, interpolated into every tool-running preset's prompt (coding, review, research, general, explore, on every host variant; the conductor states the same rule for its children: ✓ only once await_runs/get_run_status said so), restated in the tool's description and answered back on every call (STATUS_RESULT) — because the card now draws the running command beside the checklist, and a ✓ item whose command is still running reads as a lie. The activity is typed, never re-parsed from text (StatusUpdate.activity, a StatusActivity): a bash call that carries its command on the tool_call event is a command part ({ kind: "command", tool, command } — the full command under COMMAND_CAP, not the 200-char summary), and every other event is a line part holding its one-line trace (→ <call> / ✓|✗ <tool>: <result> / 💭 thought for …); cardActivity (src/core/dispatch/reply.ts) makes the part from the event, activityText (src/core/statusCardFrame.ts) flattens it for a text surface (the CLI, the [tool] log line — a command on one line behind → $). Each channel draws the part in its own dialect: Slack draws a command as a code block at debug (routing-and-config.md item 28; verbose carries the caption alone through compactActivity in src/core/dispatch/reply.ts; quiet carries no activity, and the update_status round trip is bookkeeping below verbose: the checklist itself repaints the card, so no → update_status / ✓ update_status: … meta line rides as activity) — a caption → <tool> closes the rich_text_section, and a rich_text_preformatted element follows it inside the same rich_text block (a sibling, so the no-fold property below holds), the command cut by structure (clipCommand: the first 6 lines kept and the rest counted as … +N lines, then a 600-char cap with the lone-surrogate guard) so a heredoc-fed script reads as its opening lines and never balloons the card; a line part is one more line of the section. The coalescer's identical-frame key includes the part's kind and text. 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 — CardShell.live alone carries it, a close never does — 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 normal end keeps the checklist as left: ✓ marks only work the agent checked off, while ○/✱ stay open. An open item, a typed tool refusal or an unknown tool result closes ⚠️ rather than claiming a clean finish. A settled tool error remains in the run history but does not by itself claim a refusal or an unverified result, including after restart. An answered run alone proves no requested step; the run events retain each call and result even when the answer disagrees. A fully checked run with no incomplete tool effect can close ✅. A budget-ended run closes ⚠️ with the checklist as left, even if its ledger status is completed and a partial write-up arrived; a stop or failure (❌/⏹/⛔) likewise keeps the honest partial state. A private run keeps source detail off its card. The budget signal travels from the harness's time_budget_exhausted note through the loop outcome to the reply card; a restart with the answer already in hand derives it from the carried note, not an empty new-generation control. An empty update_status never 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 typed link element labeled Live run at 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 a rich_text block, never a section: 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 every chat.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 literal text elements also neutralize mrkdwn injection (<!channel> in tool output renders as characters) by construction. The title's quiet suffix tells model time from tool time — at debug, item 28 (quietSuffix + inFlightCallAfter, 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 a tool_call has no tool_result yet (the wait is the tool's, however long — the executor's deadline ends it, execution.md item 11, not the model). Without the distinction a pnpm typecheck in flight for an hour reads thinking (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). The card speaks at the request's verbosity (routing-and-config.md item 28): its label is *<agent>* — the model (on `<model>`) joins at verbose — plus the notes the stages add with CardShell.note(level, text) at or below the request's level — a routed run's route reason: <reason>, the workspace and a clipped budget at debug (item 21), work left behind at every level; no close carries an override footer. 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 six not started (reason) gate closes, the ship preflight's refusal, the ❌ setup failed · reason close 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).
  3. Secrets never enter the stream: redactSecrets strips 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: Slack xox*; GitHub ghp_/github_pat_/x-access-token:; Anthropic/OpenAI sk-*; AWS AKIA* and GCP AIza*; Stripe sk_live_/whsec_; Authorization: Bearer/Basic/token headers; Cookie/Set-Cookie values; URL/connection-string basic-auth (scheme://user:pass@) and curl -u user:pass; PEM PRIVATE KEY blocks; and a name-gated assignment pass that hides the value of any …SECRET/…KEY/…TOKEN-style identifier (so AWS_SECRET_ACCESS_KEY=… is caught, while PORT=3000, REACT_VERSION=…, MONKEY_BARS=…, SHAs, and digests are not). An identifier that also carries a failure-flavored component (failed/error/expired/invalid/…) names an ERROR CODE, not a secret — github-token-mint-failed: HTTP 422 … keeps its diagnosis instead of surfacing as github-token-mint-failed: «redacted» 422 … (src/core/runEvents.test.ts::does not redact the diagnosis after an error code that mentions a credential word). Redaction always runs before length-capping (redactAndCap), so a secret near a truncation boundary can't leak as a raw fragment.
  4. Result summaries are bounded: summarizeToolResult returns 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.
  5. Failures are visible: a throwing tool emits tool_result with ok:false and the (redacted) error, rendered with a ✗. So does a tool that answers the model with a text opening error: instead of throwing and declares that it does (RunnableTool.failsInText — attach_file, the submit_* tools, the run tools): toolTextFailed reads that opening in both loops (the native runner and the pi bridge, which is handed the declaring names), so a failure the model reads as one is recorded as one and can be counted; bash keeps its exit N: reader, and a tool that relays content it did not write (read_file, web_fetch, use_skill) is judged by neither — a file whose first line says error: was still read.
  6. 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 the input (the request), then one context event ({ type:"context", text, seq, at }) per thread-context turn fed to the model (prefixed user:/assistant:); the answer is published inside the run try, before finish() (a content publish on a finished run is a silent no-op — the test for this goes red if the publish is moved after finish; span records, timing rather than content, stay accepted until the seal — live-view.md item 4). input and context text 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, &amp; &lt; &gt; unescaped once); the answer is model prose, not mrkdwn, and is never unescaped. All three pass redactSecrets and 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, and runHistory.includeContext: false suppresses context events entirely (request and answer still flow). The run label is redacted at create() (redactAndCap(label, 200)), so a secret in the request snippet never reaches the index or a RunSummary. The dispatcher-published events bypass the card trace (lastActivity), the per-step [tool] log and the friction analyzer's timing/count (runMs/eventCount are 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>* · preparing workspace… (the model joins the label at verbose, routing-and-config.md item 28) — 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.

  1. Reading across runs stays under the reader's predicate (authorization.md items 5–7; agent-ship.md items 17 and 18, agent-conductor.md item 11, session-log.md item 11; src/core/runsService.ts, src/core/commands/runs.ts). Four reads compose what several runs said or did — a ship unit's runs in round order (runs unit), a conductor's children (runs children), a person's search over one session's log (runs search), a pull request's findings ledger (runs findings) — and each is bounded by the same runs:read predicate the run list is (predicateFor(actor, "runs:read", "run")), so a reader sees across runs exactly what they could see one run at a time. A unit's listing carries the runs the predicate admits and is not_found — byte-identical to an unknown unit — for a reader admitted to none of them, unless a run of the unit's requester in its channel would be admitted (a unit not started yet, read from its own channel; a reader admitted by channel visibility alone waits for the first run to carry the stamp), so a unit of another channel never shows its thread keys or round outcomes; a children listing sits behind the parent's own point read and carries the children the predicate admits; a search answers empty for a reader admitted to no run of the session — the same empty a session nobody ran gets — and the log is never asked for such a reader; a findings ledger is joined from the runs the predicate admits alone — a private review left out changes the rows, never reveals them — and is not_found for a reader admitted to none, byte-identical to a pull request no run names. Nothing these reads answer carries a capability token; the reads that carry stored free text — a search's snippets, a ledger's finding titles and disposition notes — leave it wrapped as untrusted content on every machine surface, as runs events does, the search having no chat surface and the ledger's text renderers unwrapping for the person reading the table; the two listings carry run metadata alone, as runs list does, and are chat surfaces too.

Validation criteria ​

CriterionEvidence
Structured Pi diagnostics remain visible in runs get --include messages and runs events; Pi strings are marked as untrusted while closed source, phase and numeric status remain structured[unit] src/core/commands/runs.test.ts::provider diagnostics in machine history::fences structured Pi diagnostics for an investigating agent through %s
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)
A normal end leaves unfinished checklist steps open and closes ⚠️; typed refused or unknown tool effects also prevent an unqualified ✅. Repeated tool refusals stop before a model-written ending and retain the unfinished checklist. A fully checked clean run stays ✅. Budget close, stop and failure keep partial progress, including an already-written partial answer recovered after restart; private cards reveal no source detail[unit] src/core/dispatcher.test.ts::closed-card checklist and review verdict run link::an ordinary close keeps completed steps checked and unfinished steps open, ::a clean close with completed steps stays green, src/core/dispatcher.test.ts::agent:explore — the first repo-cold preset::a refused Explore command keeps its unfinished step open, ::a typed refusal prevents a green card even if Explore checks every step, src/core/dispatcher.test.ts::closed-card checklist and review verdict run link::a failed run keeps the honest partial checklist…; src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::a restarted source read cut at the budget answers without invented findings and leaves the unfinished checklist open, src/core/dispatch/runLoop.test.ts::a resume with the answer in hand (the \finish` plan)::a budgeted source answer recovered after a restart keeps its original partial checklist on the reply card, ::a normally ended run classifies a pre-restart tool %s on its final card, src/core/dispatch/runLoop.test.ts::keeps a resumed private Slack result out of cards, answer and reply after its DM becomes shared`
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)
2: the activity is typed — a bash tool_call with its command is a command part with the full command, any other event a line part with its trace; a text surface flattens a command to one → $ … line[unit] src/core/dispatch/reply.test.ts::cardActivity::*, src/core/dispatcher.test.ts::closed-card checklist and review verdict run link::debug paints typed activity beside the checklist, while quiet paints the checklist alone and every close drops activity (red-verified: 11 failing before the change)
2: the checklist rule is one sentence in every tool-running preset's prompt (✱ on the item's first command, ✓ only after its result is read, never in the same turn), in the tool's description and in every call's result; the conductor's is the same rule for children[unit] src/agents/registry.test.ts::the status-card rule is one sentence for every tool-running preset::*, src/tools/status.test.ts::update_status::*
2: Slack draws a command part as a → <tool> caption plus a rich_text_preformatted code block beside the section in the same rich_text block, cut by structure (6 lines + … +N lines, 600 chars, no lone surrogate); a line part is one more section line[unit] src/channels/slack/statusCard.test.ts::render (status card rich_text body)::activity::*
2: a live frame carries the activity beside the detail and a close never does; a frame whose only change is the activity is not coalesced away[unit] src/core/statusCardFrame.test.ts::createCardShell — every paint comes from one builder::a live frame carries the typed activity beside the detail; a frame without one has no activity key; a close never carries it, src/core/statusCoalescer.test.ts::coalesceStatus::a frame whose only change is the activity part is NOT skipped as identical
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.
The bridge emits tool_call then tool_result per tool use, the native loop's events for the same turn[unit] src/core/harness/pi/bridge.test.ts::the bridge speaks the loop's vocabulary — the same turn, the same events::tool_call, tool_result and the model's narration are the events the native loop emitted for the same turn
Failing tool → tool_result ok:false with the error[unit] ::run-visibility events::marks a failing tool with ok:false
5: a non-bash tool whose text opens error: → tool_result ok:false through the bridge; text that only mentions the word later stays ok:true[unit] src/core/runEvents.test.ts::toolTextFailed::*, src/core/harness/pi/bridge.test.ts::the gate's coverage — every call that ran was vetted::a relayed tool whose text opens error: is recorded 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; non-bash calls name their target[unit] src/core/harness/pi/bridge.test.ts::the bridge speaks the loop's vocabulary — the same turn, the same events::tool_call, tool_result and the model's narration are the events the native loop emitted for the same turn, src/core/harness/pi/bridge.test.ts::the bridge speaks the loop's vocabulary — the same turn, the same events::a nonzero bash exit reads as the native result does: not ok, the code on the event, ::a non-bash tool's failure is not ok with no exit code; the call's one line names its target the native way, 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::opens the span at the start, stamps its id on both events, ends it with callId, ok and exitCode
Both harnesses record redacted JSON arguments on tool_call.input, beside the existing paired result output. Nested credential fields are hidden even without a recognized token prefix. Missing, unserializable and capped inputs have explicit markers. Machine history fences inputs and routing call results as untrusted evidence. Existing record retention and source visibility still apply.[unit] src/core/runEvents.test.ts::tool input evidence::*; src/core/harness/pi/bridge.test.ts::the bridge speaks the loop's vocabulary — the same turn, the same events::tool_call, tool_result and the model's narration are the events the native loop emitted for the same turn; src/core/commands/runs.test.ts::provider diagnostics in machine history::fences routing question and rejection evidence while retaining typed timing
Original string leaves and property names are redacted before JSON encoding and capping. Multiline and quoted assignments retain no credential suffix; named nested fields stay hidden and existing Bearer text remains readable. Structured helper and verifier results are serialized once after redaction.[unit] src/core/runEvents.test.ts::tool input evidence::*; src/core/harness/pi/bridge.test.ts::the bridge speaks the loop's vocabulary — the same turn, the same events::redacts original string arguments…; src/core/harness/opencode/bridge.test.ts::tool argument retention::*; src/core/dispatch/operator.test.ts::runOperator — the loop over a scripted model::redacts original provider strings…; src/core/dispatch/operatorTelemetry.test.ts::routing telemetry retention::*
Permission-derived OpenCode targets are not complete arguments. Missing actual arguments have an unavailable marker. The first later stream or complete store input is a call-linked tool_input fact, including after settlement; pending store parts and duplicate full observations cannot claim completeness or reopen execution. History input updates stay untrusted and private source bytes stay hidden.[unit] src/core/harness/opencode/bridge.test.ts::tool argument retention::*; src/core/runEventLines.test.ts::parseRunEventLines::keeps call-linked argument updates and rejects incomplete update frames; src/core/commands/runs.test.ts::provider diagnostics in machine history::fences routing question and rejection evidence while retaining typed timing; src/core/runEvents.test.ts::tool input evidence::keeps later private arguments out of the shared diagnostic stream
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::*
The bridge emits assistant only for text alongside a tool call (before the tool rows), never for the final answer; redacted, uncapped[unit] src/core/harness/pi/bridge.test.ts::turns, narration and the answer — the loop's rules::*
Dispatcher publishes input first (full authored text for operator binds, legacy directive-stripped text, 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/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;
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 call (its declared bound riding along, live-view.md item 32), its tool_result closes it, every other event leaves it[unit] src/core/statusCardLabel.test.ts::quietSuffix::*, src/core/statusCardLabel.test.ts::inFlightCallAfter::*
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, a footer line ending every close's detail and never the ack or a live frame, 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 streamSee 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 `<urlurl-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 run's emitter: a relayed tool's publish lands in onEvent stamped with the call's span, ordered with the tool events (tool_call → skill_use → tool_result)[unit] src/core/harness/pi/relay.test.ts::runRelayedTool — a native tool run in the bot for pi::runs the tool with the run's context under the call's span — a tracing executor, the span on what it publishes — and answers pi's content
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`
artifact accepted by parseRunEventLines (direction in/out + its join key — messageId on in, callId on out — + key + name + numeric size + contentType), refused when it carries a url field, a malformed direction or no join key; invisible to the friction diagnosis with in and out events present[unit] src/core/runEventLines.test.ts::parseRunEventLines::accepts \artifact`…, src/core/runFriction.artifact.test.ts::analyzeRunFriction — artifact is invisible to friction::the diagnosis is identical with or without the artifact events`
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…`
9: a unit's listing is not_found for a reader outside the predicate — another channel's, another user's, none — and whole for one admitted to any run of it; a unit not started follows its requester's channel and a public-only reader waits for the first run[unit] src/core/runsService.test.ts::RunsService.listUnitRuns — a unit's runs in round order::a reader outside the predicate is told not_found…
9: an instance's unit list is empty for a reader its requester's channel does not admit, and the unit page is the run 404 for a viewer the predicate admits to nothing of the unit (live-view.md item 28)[unit] src/core/runsService.test.ts::RunsService.listUnitRuns — a unit's runs in round order::listInstanceUnits answers the instance's unit rows…, src/channels/liveView.test.ts::the unit page and what a run is the parent of (item 28)::an unknown unit, a malformed key…
9: a children listing and a search are empty for a reader outside the predicate; the search's log is never asked for one[unit] src/core/runsService.test.ts::RunsService.listChildren — a conductor's children in start order::*, src/core/runsService.test.ts::RunsService.searchSession — a person's search over one session's log::a reader outside the predicate, a session nobody ran…
9: on the command surface a channel-pinned caller is told not_found by runs unit and runs children and gets no hits from runs search; snippets leave wrapped as untrusted; the listings are chat surfaces and the search is not; no output carries a token[unit] src/core/commands/runs.test.ts::runs unit / runs children / runs search — the unit is the reading unit::*, src/core/commandConformance.test.ts::*
9: runs findings is read under the predicate — a reader outside the private review reads the ledger the public runs tell, a reader outside every run naming the pull request is told not_found as for one no run names; titles and notes leave wrapped on the JSON surface[unit] src/core/runsService.test.ts::RunsService.listFindings — a pull request's findings ledger from the records (agent-ship item 18)::only runs the reader may see enter the join…, src/core/runsService.test.ts::RunsService.listFindings — a pull request's findings ledger from the records (agent-ship item 18)::is not_found — one answer…, src/core/commands/runs.test.ts::runs findings — a pull request's findings ledger on every surface (agent-ship item 18)::*
A completed tool error followed by a successful alternative preserves the failed result in history and closes a fully checked run ✅ without the refusal warning[unit] src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::a completed tool error stays in the run history without a refusal warning on the final card