← Back to Blog

Day 83: v0.5.3 and the Per-Agent Runtime

Monday. v0.5.3 ships at the end of the day, after a morning spent on the #199 fix that's been the longest-running open issue in the repo. The visible change is in the chat sidebar; the underlying change is structural. Pinchy's chat used to behave like a single-page chat that happened to show many agents; from today it behaves like many chats that happen to share a layout.

The Runtime Used to Belong to the Page

The old shape. Each chat page mounted its own assistant-ui runtime via a local hook. Navigating to a different agent unmounted the current page, tore down the runtime, and remounted a fresh one for the new agent. If you had a long-running turn going on agent A and clicked agent B to check something, the turn on A was abandoned mid-stream — the WebSocket closed, the runtime got garbage-collected, and the assistant's reply had nowhere to land when it finished generating. Yesterday's disconnect-drain fix in the client-router meant the reply at least made it into the cache, but the in-memory chat state on agent A was gone by the time anyone came back.

The new shape. A ChatSessionProvider mounts one runtime per agent at the (app) layout level — outside the chat page itself, inside the auth boundary. The runtime is keyed by agent ID and held in a per-session zustand store. Navigating to a different agent unmounts the current page but the runtime stays mounted; the next visit to the same agent picks the runtime back up where it left off, with its in-memory message history intact. Streams in-flight on agent A keep streaming while you read agent B's history. The shape is the same one any modern chat app has had for years; Pinchy hadn't needed it until the agent count per workspace started climbing.

The migration is two PRs of plumbing followed by a single feature commit. The plumbing: the runtime bundle's types get tightened so a missing agent ID is a compile error rather than a runtime undefined; the bundle's remove() exit path gets a test asserting it actually frees the underlying resources; a placeholder runtime guards the case where the chat page mounts before the provider has finished hydrating; the E2E helpers get extracted into a shared module so the new specs can drive cross-agent navigation. The feature: the chat page reads its runtime from the provider via context instead of constructing one locally. The diff is small at the call site and large in implication.

Sidebar Indicators

The new architecture made a UI surface possible that hadn't been: the sidebar can now show what each agent is doing, in real time, without opening the agent's chat. Two indicators landed today. A subtle pulse on the agent's row when a turn is currently streaming — the same animation as the assistant's typing indicator, scaled down to a row-level dot. A red dot on the agent's row when the most recent turn ended in an error and hasn't been acknowledged by the user (clicking through to the chat clears it). Both share the same data path: the per-agent runtime exposes its current state, the sidebar subscribes via zustand, the indicator re-renders without prop-drilling.

The matching tests are the kind that the per-agent runtime made possible. A concurrent spec drives two agents in parallel: send a message to A, immediately switch to B, send a message to B, switch back to A — the spec asserts both replies arrive in their respective sessions without crossover. A draft spec asserts the composer text on agent A is preserved across a navigation to B and back. An error indicator spec asserts the red dot appears when an agent errors and clears on the next chat open. One follow-up commit landed during review: the sidebar-pulse assertion was being run after page.goto, which sometimes raced the runtime mount; the fix asserts the pulse on the chat page itself, where the runtime is guaranteed to be alive.

A small stabilisation followed the concurrent spec. The original test was asserting on exact message ordering across two parallel streams, which intermittently failed when the second stream started a tick before the first; the simplification asserts both responses arrive rather than both responses arrive in this order, which is what the property actually is. Integration spec 16 also got a small race-fix: a deferred disconnect bubble was previously rendering before the assistant-ui index had caught up to the chunk that closed the turn, leaving a phantom disconnected banner on top of a successful reply. The fix defers the bubble until after the index is consistent.

Background Run Audit

The new shape made one more thing possible: chat runs that complete while the user isn't actively viewing the agent now emit a chat.background_run_completed audit event. The event carries the agent identity, the run duration, and the turn outcome (success, error, model-unavailable, silent-stream); it does not carry the conversation content. The split is deliberate — admins need to know that background runs are happening and how they're ending, but they don't need to see what was said. The matching component tests assert the event is emitted on the right edges (when the runtime transitions to a finished state without an active observer), and that an agent-ownership check prevents one user's runs from being attributed to another in the audit log.

Silent Stream-Ends

A subtler chat bug got its own fix today. Until now, a turn that ended without producing any user-visible output — typically a model that timed out inside OpenClaw's embedded layer on a cold-path tool call — left the chat indistinguishable from a successful empty reply: spinner gone, no error, no retry button. The new shape detects the case at the chat-error layer (a stream that closed with no consumer-visible chunks) and surfaces the standard error bubble with a Retry button and the Try again in a moment hint. A matching audit emission writes chat.silent_stream, throttled per (agentId, model) so a flaky provider can't flood the trail through user retries. The classifier also picks up timed out as a transient-error signal, so transient timeouts now route through the retry-able bubble path rather than the terminal-error path.

OpenClaw Bumps and Other Plumbing

The OpenClaw runtime got two bumps in the same session: 2026.4.27 → 2026.5.3 first, then 2026.5.3 → 2026.5.7 after the first bump's hot-reload behaviour required a follow-up fix in the openclaw-config writer. The matching openclaw-node SDK bumped to 0.8.0, which exposes provider/model overrides on the chat options — so the chat now forwards agent.model as an explicit override to OpenClaw rather than relying on the agent's default. The override path is what makes the model-unavailable fallback from Day 81 actually take effect: an agent that's been re-pointed at deepseek-v4-pro in the database needs the chat to actually use that model on the next turn, not the cached one.

Two small reliability fixes in the same file. pushGeneration cancellation in the WS-disconnect wait now has a regression test, after a near-miss where the cancellation was being dropped silently. And a persistent EACCES on openclaw.json read now surfaces as a structured error rather than being swallowed by a defensive catch — the same pattern as the audit fire-and-forget fix from last week, applied to the config-read path.

The agent-settings page got a small bug fix: saving an integration was leaving the page in a dirty state, even though the save had succeeded. The fix clears the dirty flag on a successful integration save, with a deterministic wait in the matching component test (rather than a hard-coded delay) so the assertion is robust to CI runner speed.

The split <final> envelope across chunk boundaries fix is the kind of thing that's easy to write and hard to find. OpenClaw's stream chunks the assistant's reply at arbitrary byte boundaries, and the <final> envelope marker can land split across two adjacent chunks. The chat's chunk parser had been assuming the envelope arrived atomic in a single chunk, which was true the overwhelming majority of the time and untrue often enough to produce occasional malformed messages. The fix buffers across chunks and parses the envelope as a stream.

Two CVEs

Two Next.js CVEs got patched today via dep bumps. 16.2.4 → 16.2.5 for GHSA-wfc6-r584-vfw7, then 16.2.5 → 16.2.6 for GHSA-26hh-7cqf-hhc6 the same morning. The matching eslint-config-next bump kept the lint rules aligned with the runtime version. Two upstream patch bumps in the same hour is the shape Next.js has been settling into; the fix is to keep up.

v0.5.3 Goes Out

The release commit at the end of the day rolls up the long #199 work (per-agent runtimes, sidebar indicators, background-run audit), the silent-stream-end fix, the OpenClaw bump, the chat.silent_stream audit, the attachment preview modal that closed yesterday's PR #316, and the structured model-unavailable bubble from Saturday. No database migration, no compose-file change, no env-var change. The upgrade is a one-line sed on PINCHY_VERSION and a docker compose up -d. The v0.5.2 section in the upgrading guide gets frozen; the v0.5.3 section is the live one now.

Day 83

The week of incremental fixes that made up v0.5.0, v0.5.1, and v0.5.2 was the cost of the architecture move that v0.5.3 finally lands. Per-agent runtimes are the kind of change that affects almost every chat interaction in the product — concurrent runs, draft preservation, background activity visibility, navigation persistence — and the change is invisible to anyone who never noticed the old behaviour was wrong. The next week is the one where the work goes back to features the new shape enables rather than the architecture itself: a sidebar that knows what's happening across many agents is the substrate for a couple of UI conversations that have been queued for months.

← Day 82: Attachments That Open Day 84: Rebuilding the Snapshot Chain →

Pinchy is open source and ready to deploy. Clone the repo, run docker compose up, and your first agent is live in minutes.