* feat(gateway): proxy channel conversation avatars
* feat(discord): capture conversation avatars
* feat(slack): capture DM sender avatars
* test(discord): bind guild avatar mock
* feat(ui): render channel conversation avatars
* fix(ui): align sidebar owner fixtures
* fix(gateway): version channel-avatar routes by media revision
A stable per-session URL let AuthenticatedAvatarRouteLoader's blob and
sticky-404 caches pin a mounted row to a stale or blank avatar after the
backing media changed. Append an opaque digest of the media reference so
replacement and 404-recovery change the route identity.
* test(ui): align sidebar owner facet
* fix(ui): keep owner chip until channel avatar loads
A session with a channelAvatarUrl suppressed its owner chip even while the
blob was loading, auth was not ready, or the route 404ed, leaving an empty
lead slot. The chip now rides as fallback content inside the avatar element
and yields only to a usable image. Covers 404 and auth-pending states;
avatar rows keep renderedOwnerId unset so an owner-viewer stays visible in
the facepile.
* perf(ui): keep channel avatar fallback within budget
* perf(ui): lazy-load the channel avatar element
The avatar element and its authenticated blob loader rode the startup
bundle through session-leading-indicator, pushing startup JS 51 B over the
CI gzip budget. Channel avatars are not startup-critical: register the
element on the first avatar row; the owner-chip fallback covers the
one-time upgrade window. Startup JS returns ~1 KiB under the ceiling.
* build(ui): raise startup baseline for channel avatars
CI-measured startup JS is 344379 B against a 343289 B baseline (+1090 B).
The avatar element and blob loader are code-split out of startup (previous
commit); the residual is the sidebar lead-slot render branch and row
plumbing, which cannot be deferred. Baseline updated via
check-control-ui-performance --update-baseline with CI bytes per the
script's contract; well inside the 4096 B ratchet step and 358400 B
ceiling.
* fix(agents): finalize guided creation safely
Run channel post-write hooks only after config publication, defer portable auth copying until agent creation succeeds without overwriting newer credentials, and provision existing workspaces before publishing updates.
Keep JSON-only guided creation interactive while routing wizard output to stderr so stdout remains one machine-readable summary.
* fix(terminal): preserve note call signature
* fix(agents): pass committed config to setup hooks
* ci: split heavy codex changed-test shards
Cap non-isolated Codex extension processes at 20 files so 4-vCPU changed-target jobs do not starve real-time watches or hit the no-output watchdog.
* test(ci): align codex shard cap fixture
* docs(cli): clarify agents add JSON mode
* refactor(steering): gateway-owned start-or-steer via one captured-operation lifecycle
chat.send with queue mode steer now atomically captures the selected
session's current direct reply operation under the writer barrier and
injects into exactly that operation; with no direct owner it starts a
new run instead of failing with active-leaf-changed. Client-supplied
expectedRunId keeps exact-match semantics; the transcript-branch CAS
(expectedLeafEntryId) now guards only non-steer sends.
All three steering paths (gateway chat.send, channel queue steering,
server /steer) share one begin/finalize lifecycle on the reply-run
registry, including the captured-instance abort from 5a15e1a39c.
Deleted: the duplicate accepted/rejected/unconfirmed policy machine in
steer adoption, the raw embedded /steer runtime path, the leaf-bound
injection identity and reject-before-ack compat, and the dead target
tool-authority fingerprint. messageInjectionAttempted becomes a typed
messageInjectionDisposition (none | accepted | rejected); rejected
injections take one visible followup fallback, so non-injectable
runtimes queue instead of silently dropping.
Deliberate semantics: a registry-less active embedded run is no longer
steered by raw session id (correlation-only authority) - the message
becomes a visible followup; /steer injects under its own command
authorization instead of the inbound tool-authority gate it could
never satisfy.
* docs(protocol): describe gateway start-or-steer contract for chat.send
Targetless steer is no longer a leaf-bound compatibility path that can
reject with active-leaf-changed; it targets the selected session's
current state (inject into the direct run, else start a turn).
expectedRunId stays an exact-run fence; expectedLeafEntryId is the
non-steer transcript-branch CAS.
* fix(steering): require matching tool authority for /steer injection
The authorized-sender command gate is weaker than tool-authority equality. Make /steer and gateway injection present the same projected evidence as channel steering so mismatches fall back to a normal prompt under the sender’s own authority.
* feat: credit linked session participants as co-authors
Authenticated profiles can link GitHub and receive automatic co-author credit in shared coding sessions.
* style: format rebased co-author registries
* fix: mark profile schema DDL boundary
* fix(gateway): stop terminal PTYs on session archive
Bind agent terminals to the durable session incarnation, drain exact ownership during archive, and terminate every job-control process group in the PTY session.\n\nCloses #125769
* test(gateway): cover terminal cleanup on archive
* test(gateway): align terminal outcome assertions
* test(gateway): preserve session exports in invoke test
* fix(gateway): await terminal exit before archive
* test(codex): consolidate supervised instruction coverage
Move the duplicated two-attempt regression into the canonical thread lifecycle test so the exact two-worker extension shard stays bounded on low-core CI.\n\nRelated: #125783
* feat(gateway): reach a Gateway behind an identity-aware proxy from the CLI
Operator CLI surfaces (tui, attach, call, probe, onboarding, and configure) can present configured gateway.remote.edgeAuth headers to an identity-aware proxy. Headers are origin-bound, WSS-only, never follow redirects, cannot set transport-owned headers, and are redacted. Identity-proxy upgrade rejections are classified and remediated instead of being reported as an unreachable gateway.
* test(gateway): cover config-aware probe calls
Keep status probe expectations aligned with the resolved configuration forwarded for origin-bound edge-auth resolution.
* fix(gateway): preserve edge auth across wizard saves and enforce wss before secret resolution
Preserve gateway.remote.edgeAuth only when the configured Gateway scope is unchanged, and reject non-WSS targets before resolving any edge-auth SecretInput.
* fix(ui): align session owner filtering terminology
Owner filters now use owner terminology consistently across the Gateway, Control UI, and generated native protocol clients. The old terms were prerelease-only, so no migration or compatibility aliases are needed.
* fix(gateway): preserve creator provenance filtering
Keep creatorId scoped to immutable createdActor provenance while ownerId queries the current projected assignable owner. This preserves independent creator and responsibility filtering after reassignment.
* test(ui): wait for remembered draft before reload
CI exposed an IndexedDB draft-persistence race: the test reloaded before the write completed and then re-filled the message while restoration arrived. Wait on the canonical durable-draft helper before reload so the reload cannot race the write.
* fix(ui): keep session menus clear of hovercards
Horizontal sidebar previews were positioned from the inner link rect instead of the complete session row, allowing the hovercard to overlap trailing actions.
Anchor preview placement to the full row while preserving the link trigger and ownership flow so the session menu remains clickable.
* perf(ui): keep hovercard fix within startup budget
Use the sidebar direct-row invariant to anchor the hovercard outside trailing actions without a selector lookup. This preserves the fix without increasing the startup budget.
* fix(qa): preserve Matrix CLI timeout precedence
Keep the primary timeout operator-visible when descendant settlement also reports secondary cleanup trouble. Preserve the complete AggregateError as the public wrapper cause so settlement evidence remains inspectable.
* test(ui): isolate Markdown table clipboard boundary
With isolate:false, the shared module registry can retain clipboard imports before this test installs its module mock, leaving the spy disconnected from the production call.
Stub navigator.clipboard.writeText instead so the test exercises the real copyToClipboard boundary regardless of module load order, then restore the original descriptor.
* fix: show sessions waiting for concurrency slots
* test: align queued session integration fixtures
* test: distinguish queued and reactivated followups
* fix: preserve queued state in workboard and android
* fix: project queued status through chat history
* test(ui): keep queued sidebar case under line cap
* fix(errors): drop cause text the message already states
Cause-chain dedupe compared whole strings, so a wrapper that embeds its cause
verbatim printed it twice, and an errno detail was followed by its own bare
code. Skip any cause segment already contained in the accumulated message.
* test(backup): stop pinning the duplicated errno suffix
Both debug-view assertions required the bare code to follow the errno detail
that already names it. Assert the detail itself instead, so they pin the
message content rather than the redundancy.
* fix(errors): narrow cause dedupe to wrapper-embedded messages
Suppressing every contained segment also dropped trailing bare codes, which
cron, fs, and backup tests pin deliberately as this formatter's convention.
Restrict containment to cause messages so the wrapper duplication is fixed
without changing the code suffix, and restore the backup assertions.
Responses replay parsed persisted thinking signatures without validating their JSON shape, so corrupt or unrelated values could prevent every later turn in a session.
Parse signatures in the shared replay owner and accept only reasoning records. Cover malformed syntax, null, arrays, and wrong item types through both provider- and transport-style converters.
* feat(gateway): carry machine class through session moves
Profile move targets accept an optional machineClass with new-session
parity: validated in the protocol, persisted on the durable move intent
(bare nullable column, lazy same-version ALTER), replayed exactly by
restart recovery, and selectable from the Control UI move dialog.
Moving to the current profile with a different class resizes the
session's worker.
* fix(gateway): project placement facts on chat startup
chat.startup and chat.history built sessionInfo without the placement
projection sessions.list applies, so clients merging that row erased a
live worker placement and its move intent — the Runs on Cloud chip
disappeared after any turn until a full reload.
Reuse the canonical ensureColumn helper for the move table's additive
column instead of a hand-rolled PRAGMA read.
* feat(agents): unify agent status into a durable progress_card
Replace the write-only update_plan to-do tool and the fragmented plan
rendering with one durable status artifact per session: progress_card
({plan?, markdown?}, replace-on-write, 8 KiB markdown / 50-step caps).
Cards persist in a lazy-additive session_progress_cards table in the
per-agent DB (no schema-version bump), broadcast progressCard.changed,
and render from the store with exactly one live placement per view
(session rail when visible, else the composer-adjacent bar); transcripts
collapse to one-line receipts, and the sidebar hovercard shows other
sessions' cards inline (markdown + <progress>, DOMPurify allowlist, no
iframes). The three stream-derived plan renderers and their dedup
heuristics are deleted.
Codex runs disable the native plan tool per thread
(tools.update_plan.enabled=false) and receive progress_card via the
dynamic-tool bridge; compaction restore now reinjects the card (steps +
bounded markdown). Card writes still emit the legacy plan stream event so
native apps and channels keep working until their per-platform
migrations. Policy names map update_plan -> progress_card; the shipped
tools.updatePlan=false kill switch is honored.
Net -277 production LOC; -480 test LOC.
* test(agents): regenerate Codex prompt snapshots for update_plan thread-config disable
* chore(protocol): allowlist progressCard.changed for native apps pending card migration
* fix(ci): repair progress card integration checks
* fix(codex): canonicalize native progress cards
* test(gateway): reconcile progress card method order
* test(codex): stabilize native approval fixture
The default OpenAI Responses compact_threshold was 70% of contextWindow alone; for gpt-5.6-sol (1.05M window, 272k active budget) that resolved to 735,000, above the ~252k prompt budget the runtime admits, so provider compaction never fired before local overflow handling. Threshold now uses 0.7 x min(contextTokens, contextWindow) (190,400 for Sol) on both the payload-policy and host-preflight paths; explicit responsesCompactThreshold still wins.
* fix(agents): report interrupted tool outcomes honestly
Prevent restart recovery from claiming that an interrupted tool call completed when no successful matching result was recorded.
* fix(agents): keep ambiguous recovery restart-safe
Classify failed replay-unsafe tool results at the shared restart-recovery owner so interrupted side effects remain unavailable until their external state is verified.
* fix(agents): distinguish missing tool outcomes
Carry the existing missing_tool_result fact into projected Codex transcripts so restart recovery restricts only genuinely unknown outcomes, while confirmed failures remain retryable.
* feat(sessions): teach session tools the Control UI link rule
Gate guidance on publicOrigin plus enabled Control UI, with exact literal-URL fallback after short-link misses.
* feat(ui): linkify session keys in chat markdown
Match agent session keys structurally in plain text and inline code, then delegate canonical chat navigation.
Allowlist data-session-key through markdown sanitization.
Pathfinder: keep internal Control UI route anchors in-app by removing target="_blank" and external-link rel attributes while preserving external link behavior.
* feat(ui): session hovercard + titled session chips backed by controlUi.sessionPreview
* fix(gateway): scope controlUi.sessionPreview to caller-visible sessions
Hover previews now apply the same createSessionListEntryFilter predicate as
sessions.list, so identity-bearing non-admin callers cannot preview-by-key
incognito rows or non-owner drafts the sidebar hides. Regression test proves
the viewer/admin split; pre-fix run leaked ok-status metadata.
* feat(sessions): carry the session-link rule in tool result envelopes
Deferred-description mode hides prose tool descriptions at decision time. Carry the shared Control UI session-link sentence in successful session lookup result envelopes so every tool mode sees the rule.
* fix(ui): upgrade session chips on appearance, not first pointer event
* fix(android): regenerate gateway protocol methods
* test(gateway): track session preview release train
* perf(ui): lazy-load session hovercard registration
* fix(ui): keep session hovercards off sidebar navigation
* fix(ui): cancel routed session-link navigation
* fix(sessions): advertise forced-literal ~key URLs so short-ID collisions cannot misroute
* test(sessions): update forced-literal guidance expectation
* fix(ui): collision-proof raw-key navigation and SPA-route internal session URLs
* perf(ui): preserve session route lazy boundary
* fix(ui): defer unseeded session-preview fetches to hover intent
* fix(sessions): hard-cap the model-visible session-link base
* feat(sessions): stamp agent identity on spawned sessions and return spawn receipts
Agent-spawned sessions recorded the requesting session key as createdActor.id,
so the Control UI creator chip rendered an opaque key. Spawn producers now stamp
the canonical requester agent id; parent-authority validation moves to a new
trusted requesterSessionKey field. projectSessionActor enriches agent actors
with configured identity name/avatar at read time, and visible sessions_spawn
returns a sessionUrl + owner receipt with URL-first acknowledgement guidance.
* feat(sessions): assignable session ownership with owner facet and menus
GitHub-assignee-style ownership: sessions get a mutable owner (defaulting to
the immutable createdActor) stored in additive bare-nullable SQLite columns
with first-use lazy ensure. New operator.write sessions.assignOwner validates
targets, requires an identified caller, authorizes by session visibility, and
records assignedBy/assignedAt inside the write transaction. The sessions agent
tool gains assign_owner; the Control UI adds Assign-to-me/Assign-to menus in
sidebar rows and chat headers, renders the effective owner chip, and the
creator facet/filter now keys on effective owner. Sharing authority stays
anchored on createdActor.
* feat(sessions): record session participants and stack them in the owner chip
Records every distinct external prompter (human profile/channel sender, or a
requesting agent) per session in an additive session_participants table at the
turn-admission boundary — best-effort, deferred, never blocking the turn; the
session's own agent and viewers are never recorded, capped at 32 per session.
The session row projects a bounded participants list (owner excluded) plus a
total count with the same actor enrichment as owner/createdActor. The sidebar
chip becomes a pair-stack when others have prompted (owner front, one peeking
participant or +N behind), the chat header shows the full facepile, and an
authenticated involvingMe list filter adds an Involving-me sidebar predicate.
Participant projection is excluded from logical-session CAS equality so display
history never invalidates session writes.
* fix(sessions): identify built-in agent tool callers for owner assignment
The sessions tool's assign_owner dispatched through the in-process synthetic
client, which carries neither a signed agent-runtime identity nor a human
profile, so agent-initiated reassignment always failed with FORBIDDEN. The
tool now captures its trusted requester agent identity and carries it across
in-process dispatch as internal client state (never wire params); the handler
derives assignedBy as signed runtime identity, then trusted agent-tool caller,
then authenticated human. Live-verified end-to-end on a dev gateway.
* fix(ci): split oversized session modules and refresh prompt snapshots
Split the max-lines offenders at concept boundaries for session equality, tool overrides, and protocol owner schemas. Remove the redundant Number conversion from the node:sqlite participant count. Refresh prompt snapshots after drift from the sessions and sessions_spawn tool description updates.
* fix(ci): restore solo-mode chip suppression and conform new method descriptors
Solo-mode root cause: owner-assignment submenu options reused the permanent owner-chip custom element, so hidden menu avatars were counted as attribution chrome. Menus now use viewer avatars while gateway-gated owner chips remain exclusive to collaborative sessions.
Conform sessions.assignOwner to the 2026.8 descriptor and append-only advertised-method inventories, and regenerate the Swift and Kotlin protocol surfaces.
Keep historical v15/v14 fixtures frozen by stripping the new owner columns; the existing range already excludes the participant table. Replace the new raw SQLite schema probes with synchronous Kysely queries.
Clear max-lines by splitting the organizer host contract, pure agent-navigation projections, and ownership/filtering sidebar cases at their concept boundaries.
* fix(ci): integrate ownership series with latest main surfaces
Wire the sessions-page assign-owner action, merge capability imports, narrow the navigation export scope, and apply sessions-create formatting.
The owner-presence regression came from hidden assign-owner menu avatars emitting data-viewer-id, so owner and menu chrome now opt out of presence markers while real facepiles retain them.
* fix(sessions): scope the involving-me filter to profile-backed participants
Session participant history mixed channel-native sender ids with authenticated Gateway profile ids, so involving-me missed real sessions and could accept numeric collisions.
Record the actor_source namespace at each producer, carry it through the internal SQLite projection, and match authenticated viewers only against profile-backed human participants. Legacy NULL sources fail closed for filtering, while channel ids remain available for display.
* build(ui): raise startup budget baseline for session ownership surfaces
Ownership chips, assignment menus, and the participant stack add ~0.7 KiB
gzip to the startup path; CI compression landed just over the previous
baseline+tolerance. Hard cap (350 KiB) unchanged.
* refactor(sessions): drop raw NULL projection for the lazy actor_source column
The Kysely guardrail rejects typed raw sql snippets outside allowlisted
boundaries; select the lazily-ensured column only when present and let the
row projection treat its absence as unknown/legacy.
* build(ui): refresh combined startup baseline
Classify text interrupted by resumed reasoning at the OpenAI-completions producer boundary, so channels deliver only the confirmed final answer.
Defer phase-ambiguous replies until terminal classification while preserving live partial delivery for ordinary completions.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* fix(sessions): flatten Markdown in session list previews
Session-list previews were extracted verbatim from the last transcript
message, so raw Markdown leaked into every surface that renders the
subtitle as plain text — Control UI sidebar, TUI picker, native session
lists, and the sessions_list tool. A finished session read as
"Landed [PR #124879](https://github.com/...)".
Flatten lastMessagePreview at the Gateway producer, inside the
watermark-validated title-field cache, so the cost is amortized and no
consumer re-implements stripping. The flattener is the regex chain that
already existed privately in the Control UI narration line; it moves to
@openclaw/normalization-core/markdown-plain-text and both surfaces now
share one implementation. Session titles keep their own normalization
and are unchanged.
Also let an unread final observer digest outrank the raw last reply in
the sidebar subtitle, so the utility model's headline wins the slot it
was written for. The finalDigestUnread gate is untouched, so an
already-read digest still falls back to the flattened preview.
* fix(ci): register Markdown preview module
* fix(sessions): preserve literal preview punctuation
* fix(sessions): flatten previews before truncation
* fix(test): adapt session projection callback
* refactor(gateway,ui): one bounded display projection; delete marker strip sites
Persisted transcripts are marker-free since the write-boundary projection
(#124793), the historical migration (#124888), and TTS facts (#124913), so
display surfaces stop compensating. sessions.list.lastMessagePreview and its
siblings (sessions.preview/describe, TUI picker, sessions_list tool, MCP) now
share one bounded role-aware projection (240 chars, tool/system/thinking and
suppressed control replies excluded, directive-only rows fall through). The
web reply chip reads the typed openclawDelivery fact instead of parsing text;
chat.history preserves the field to the UI. Post-hoc display strips are
deleted across web/TUI/MCP/sessions-list; live streaming cleaners stay.
Stale gateway-protocol preview comments corrected; no schema change.
Assertion-safety baseline pruned for shrunk files (sanctioned direction).
Production net -173, tests net -137. Fixes the sidebar [[reply_to_current]]
preview leak and the empty-code-pill overstrip of quoted markers.
* fix(agents): preserve restart recovery transcript reads
* refactor(gateway): remove obsolete transcript exports
* fix(gateway): normalize injected delivery directives
* fix(ci): scope projection and recovery checks
* chore(ci): shrink plugin SDK surface budgets
* test: deflake loaded side question and worker checks
* test: align display projection CI fixtures
* style: format display projection fixture
* feat(workboard): reference the automation job that owns a board's prompt
Boards can now record automationJobId, linking a board to the cron
automation whose prompt/model/schedule categorize its cards. The cron
job remains the single owner of that configuration; workboard stores
only the reference. The board toolbar shows an Automation chip linking
to the cron page when set. Deleting a board never deletes the job.
Proof: pnpm test extensions/workboard packages/workboard-contract
ui/src/pages/workboard ui/src/lib/workboard (9,492 passed), autoreview
clean (codex/gpt-5.6-sol). check:changed blocked by remote capacity
(Blacksmith down, Daytona lease cap); local fallback lanes green except
a parent-branch lint finding fixed on that branch.
* fix(ui): describe workboard automation destination
Keep Gateway admission sidecar-gated while surfacing retryable startup progress in TUI and Control UI.
Unify deferred startup ownership and shutdown draining so late workers, plugin services, and sidecars cannot outlive transport teardown.
Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* feat(tts): add structured reply speech fields
* fix(tts): keep transcript directive parser acyclic
* fix(tts): drop unused directive fact export
* fix(outbound): preserve reply-payload metadata through response prefixes
The response-prefix rebuild spread the payload without its metadata carrier,
dropping structured speech facts (and any other payload metadata) whenever a
channel responsePrefix applied. Rebuild via copyReplyPayloadMetadata.
* fix(tts): keep voice-only sends visible when the speech runtime is cold
A structured voiceText-only send with no visible text returned unchanged from
the cold-runtime guard, so delivery normalized the empty payload to null and
the send silently vanished. The cold exit now applies the same visible
fallback as synthesis failure: explicit spoken text materializes as payload
text when no other content exists.