Skip to content

Explorer: group finished subagents, show running counts - #12

Draft
katulevskiy wants to merge 83 commits into
mainfrom
subagents-finished-groups
Draft

katulevskiy wants to merge 83 commits into
mainfrom
subagents-finished-groups

Conversation

@katulevskiy

Copy link
Copy Markdown
Owner

Mirror of zeronsh#647, opened on this fork to run CI. Not for merging.

Problem

The Explorer's Subagents list mixes everything in one column. Once a few subagents have finished they crowd out the ones still running, and nothing anywhere else in the app says a chat has subagents running: the sidebar shows the same working spinner whether the parent is thinking or ten subagents are.

Change

Subagents section

  • Running subagents stay on top as plain rows (the order from Explorer: list running subagents first, longest-running on top zeronsh/zeron#638).
  • Everything finished folds into a Finished (N) dropdown, closed by default. Inside are Completed (n) and Failed (n) lists, each collapsible and each paging at ten rows with "Show more". An empty list is not drawn.
  • Rows still open the subagent exactly as before. Nothing is hidden or removed: the list is read from the transcript's spawn chips, so there is no discard or hide.

Running pill (● N)

  • Sidebar chat rows, beside the row's own activity indicator rather than replacing it: in compact rows it takes the time stamp's place while subagents run, and in regular rows it leads the status label. It also shows when the parent has settled but subagents are still running in the background.
  • On the titlebar's explorer button, so running subagents can be found while the panel is closed: the button's face breathes in the activity colour and a small count badge sits on its corner. Both are drawn over the button, so the titlebar layout does not move.
  • Beside the Subagents title, which stays visible when the section is collapsed.

To badge chats that are not open, the session row now carries running_subagents: the engine publishes it as subagent sinks open and settle, and clears it when the run ends. It is #[serde(default)] on the wire, written in both the workspace and registry docs, and read through view::running_subagents, which counts a row that has not been refreshed within SESSION_STALE_MS as zero, like the working indicator. The UI folds the staleness-checked count into its session presence key so a stale count clears without a new frame.

Screenshots

Rendered with GPUI from a fake transcript (three running, two completed, one failed). Left: Finished closed, the default. Right: Finished opened.

Explorer: Finished closed and opened

The explorer button with the panel closed (its face pulses):

Explorer button

Sidebar, compact (left) and regular (right): the first two rows have 3 and 2 subagents running, the fourth has 12 while its parent is idle.

Sidebar rows

Counts of one, two and three characters ("99+" is the cap). The badge and the sidebar pill draw their digits in Geist Mono, whose metrics centre the digits on the line (checked from the font tables). Both are painted rather than laid out, so the digits are placed exactly: in the pill the dot is centred on the digits' middle from the same snapped baseline (0.0 device pixels apart on a 1.875x capture), and the badge's digits are centred on its real middle. The padding after the "+" of a capped count is trimmed by a pixel, since its thin arms read as extra space.

Titlebar badge with 3, 12 and 99+

Sidebar pills with 12, 9, 12, 99 and 99+

Every single digit in the badge, captured at 1.875x. Measured against the badge on 1.25x and 1.875x captures, each digit is centred to within half a device pixel, and exactly for most.

Badge with digits 1 to 9

The images live on a separate pr-assets-subagent-groups branch of my fork, so they are not part of this diff.

Tests

  • files::subagents: grouping, default open state, slot counts as groups open and close, paging.
  • view::running_subagents: counts while fresh, zero once stale or absent.
  • workspace doc round trip carries the count.
  • e2e::session_row_counts_running_subagents_until_the_run_ends: a scripted run with two subagents reports 2, then 1 as one settles, then 0 when the run ends.
  • cargo test -p zeron-ui --lib passes (1361). Run in parallel it occasionally fails one of the text-selection tests (a different one each time; they share a global selection); it passes with --test-threads=1.

Notes

  • Collapsing or opening a group changes the section's height without animating it.

jsgrrchg and others added 30 commits September 30, 2026 14:50
* Question panel: paint an opaque background so the transcript doesn't show through

* Question panel: frost it like the composer pill instead of a new opaque fill

The panel stands in for the composer pill but was the only input surface
painting a translucent fill without the backdrop blur, so the transcript
showed through unblurred. Wrap it in frost::frosted and use the pill's
composer_surface_bg, matching the pill and queue tray on every material
and wallpaper tint, and drop the separate input_solid_bg token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Wing <contact@winglee.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A provider CLI installed as a Homebrew cask or formula
(Caskroom/<token>/<version>/… or Cellar/<token>/<version>/{bin,libexec}/…)
gets an Update action that runs `<prefix>/bin/brew upgrade --cask|--formula
<token>` as argv. The brew binary is the one in the prefix that owns the CLI.
The installable version comes from formulae.brew.sh and `brew info`. When an
upstream release is newer than Homebrew's, the row names the brew command.
Update still runs it, but idle auto-update waits for Homebrew. npm globals
under Homebrew's Node keep their own updaters. Detection is macOS/Linux only.

Co-Authored-By: grahamwinter <wwinterr@pm.me>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Fix subagent spawn identity across resumes and recover stale chips

* Claude resume: scan native history off the runtime, skip sidechains

Long sessions' transcripts reach 100+ MB; parsing every line as JSON on a
tokio worker before each cold resume stalled the runtime. Read with std I/O
under spawn_blocking, parse only lines that can name a spawn or its agent,
stop on read errors, and ignore a subagent's own nested spawns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Wing <contact@winglee.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Appearance: add background positioning and zoom

* Wallpaper: keep the preload queue when a background's framing changes

The preload key compared the whole background setting, so applying a crop
or zoom flushed three decoded wallpapers and re-decoded them. Key on the
managed path instead, and extend the warm-switch test to cover it. Also
rustfmt the new background-mask code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Wing <contact@winglee.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
PR caches are scoped to their merge ref, so no other PR could read them, and main never saved one because in-progress main runs were cancelled. Only main writes the Rust caches now (PRs restore them), main runs are no longer cancelled, and Cursor compatibility also runs on pushes to main so it seeds its cache.
New macos.yml holds all macOS CI work: updater/preview/native UI tests and the WebKit fixtures from one nextest compile on the dev profile, the gpui frame-source test, and the iOS checks (Rust mobile core tests and the Swift bindings check now on Linux, simulator tests on macOS behind the original path filter). scripts/ci/run-macos-fixture.sh wraps the native fixtures with bounded retries.
macos-frame-recovery, ios-tests and their path-filter job move to macos.yml; the preview networking tests on macOS run there as well.
…sh#632)

* Fix drag-select failing when several transcripts share a frame

The markdown selection registry was one thread-local list that every
transcript's frame reset cleared. With a side chat or subagent tab painted
beside the main chat, only the last-painted transcript kept its text in
the registry, so a drag anchored in any other one resolved to nothing.
Triple-click still worked because its span is stored at mouse-down.

Give each transcript a surface id (its entity id). Its reset clears only
its own entries, entries are stamped with the surface they were painted
in, and a drag resolves only against the surface it started in.

Adds a two-pane regression test.

* Keep forked panes' selections apart and prune closed panes' entries

A side chat's forked history repeats its parent's entry ids, so both
transcripts paint the same selection keys and a selection in one washed
the other too. The selection now records the surface it began on; the
wash and drag resolution use that instead of a separate thread-local.

A surface that stops painting (closed side chat or subagent tab) never
resets again, so its registry entries lived for the rest of the session
and every frame's reset walked them. Drop a surface's entries once a
full cycle of another surface's resets passes without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Wing <contact@winglee.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A composite action installs the toolchain, a checksum-pinned cargo-nextest
and apt packages (bounded retries, cached .debs), and wires the main-seeded
Rust cache. scripts/ci/test-core.sh runs the harness, engine, sync, update,
doc and preview tests as one build and one nextest run. nextest.toml retries
the two tests that are timing-sensitive under nextest. Not used by any
workflow yet.
- ui-tests: session-sync-regressions becomes core-tests (one build for the
  harness, engine, sync, update, doc and preview tests); ui-tests and
  linux-browser use nextest, the dev profile and a shared rust-cache
- cursor-compatibility: the Rust half runs from core-tests on PRs and main;
  it only runs here for release, cursor-sdk-update dispatch and manual runs
- preview-tests: the Linux leg moves into core-tests
- linux-installer: install desktop-file-utils with bounded apt retries
* feat(proto): define workspace entry mutation contracts

* feat(engine): move workspace entries without replacement

* feat(engine): delete workspace entries and publish mutations

* feat(files): coordinate workspace mutations across open surfaces

* feat(files): reuse context menus for copy path and add to chat

* feat(files): rename entries inline and confirm deletion

* feat(files): preserve workspace drag origin and enable Windows handles

* feat(files): move tree entries with scoped drop targets

* feat(files): polish tree drag feedback and navigation

* test(files): cover workspace mutations and composer drag regressions

* fix(files): style delete menu action as destructive

* fix(files): reuse icons for all tree context menu actions

* fix(files): show only entry name in delete confirmation

* fix(files): give delete dialog more spacing and shorten action label

* fix(files): refresh mutation revisions after completed saves

* fix(files): refresh editor presentation and syntax after rename

* fix(files): show rename caret and preserve text input focus

* fix(files): copy full paths from the owning workspace

* fix(engine): compare canonical workspace paths on Windows

* Route chat drops through the shared dropzone with origin checks

The branch replaced the main chat's chat_dropzone() call from zeronsh#612 with an
inline copy, so the main chat validated drag origins but side chats did not.
Both now go through chat_dropzone(), which hands workspace payloads to
attach_workspace_drag() for the target composer. The dropzone fixture now
stamps tree/search payloads with a live explorer origin, and also checks that
an origin-less (stale) payload attaches nowhere.

Also correct the Windows drag note: tree rows drag on every platform; there is
no dedicated drag handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Wing <contact@winglee.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Fix command and skill labels in the message queue

* Test queue row labels for commands, skills and files

Extract the row label into queue_row_text so the projection is covered by a
unit test alongside the attachment-trailer handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Wing <contact@winglee.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Right panel tabs: close button on the right

The ✕ replaced the surface icon on hover in the leading slot. The icon
now stays put on the left, the title takes the free space, and the ✕
fades in on hover in a trailing slot at the tab's right edge (same size,
color and hover wash). The unsaved dot moves into that slot and swaps
with the ✕ on hover. Chip padding is now symmetric (4px).

* Right panel tabs: label the close button and pin its placement

Give the trailing close slot a "Close tab" tooltip, a button role and
an accessible label, matching the terminal drawer's "Close terminal"
tooltip. Add a regression test that the close sits after the title at
the chip's right edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Wing <contact@winglee.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Compact model picker (on by default, General settings): effort slider with fast-mode toggle, provider button + provider page (Starred first), per-model effort memory, Cursor effort options on the slider, page-scoped keyboard shortcuts, per-edge/per-pixel edge fades (zui 667d0aa).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CI: seed the Rust cache from main and stop cancelling main runs
CI: run Windows tests as parallel jobs on a cheaper release profile
…e, nightly release-profile run

- ui-tests.yml also triggers on scripts/*cursor-sdk* and cursor-*.yml, so the
  Rust half that moved out of cursor-compatibility still runs for those PRs.
- linux-ci installs nextest after rust-cache restores ~/.cargo/bin, so the
  checksum-verified binary is the one used.
- release-profile-tests.yml runs the Linux core and UI suites daily with
  --release (no cache save), backstopping the dev-profile switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve the expected conflicts: ui-tests.yml keeps core-tests and drops the
iOS changes job (now in macos.yml) and the macOS/iOS-only path filters;
preview-tests.yml drops the networking job, whose Rust tests now run in
core-tests (Linux) and macos-native (macOS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… nextest download

- zeron-text's coretext tests are #![cfg(target_os = "macos")]; moving the
  mobile core tests to Linux silently dropped them. macos-native now runs
  binary_id(zeron-text::coretext).
- run-macos-fixture.sh no longer writes /etc/hosts: preview hostnames are meant
  to resolve through WebKit's per-domain proxy, so a DNS fallback could let the
  fixture pass with that path broken. Retries and warnings stay.
- nextest download: https-only, TLS 1.2+, retries, extract only cargo-nextest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
macos-14 is two releases behind what macos-latest provides (macOS 26), and
zeron-text's CoreText tokenizer check fails there (136/20000 differ, limit 20)
while passing on macos-latest in main's runs. Keep the native, frame-source
and iOS jobs on the image the old jobs used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI: run Linux tests with nextest on a dev profile, one shared build
wing-anara and others added 4 commits October 5, 2026 18:46
Lead with the desktop downloads per platform and present the curl
installer as the headless (Linux server) option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deepwiki.com/badge.svg now sits behind a Vercel bot checkpoint, so
GitHub's image proxy gets an HTML challenge page and renders a broken
image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar's project filter still listed one row per checkout ("anara @
work-metal", "anara @ work-laptop"). It now lists one row per repository
across devices — named for its representative checkout, tagged with the
devices holding it ("@ work-metal · work-laptop", or "@ N devices") — and
filtering on it shows every checkout's sessions, in the list, the
Archived shelf and pin-drag validation alike.

The setting still stores one checkout's space id (this device's first),
so existing saved filters keep working and simply widen to their
repository; new sessions from the filter land on that local checkout.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@contribution-manager-katulevskiy contribution-manager-katulevskiy Bot added the review:needs-triage Managed by Contribution Manager label Oct 6, 2026
@contribution-manager-katulevskiy

Copy link
Copy Markdown

Contribution Manager

needs triage · medium risk · untriaged

Open review workspace · Revision fdb0d691

Next actions:

  • Direction decision needed
  • Scope, risk, and platform triage needed
  • Author has marked this PR as draft
  • Independent code review needed
  • Required check: core-tests (missing)

This summary is maintained by the review app. Decisions and test evidence are recorded in the workspace; the app does not merge PRs.

…#816)

* Pin chip padding to Geist so insets don't follow the UI font

A chip reserves its icon slot and insets with non-breaking spaces shaped
in the interface font. The counts are tuned to Geist, whose space is
0.25em; with a monospaced UI font like Geist Mono (0.6em) every chip
grew ~2.4x wider padding, leaving a wide gap between the icon well and
the label.

The padding runs are now shaped in the bundled Geist in the composer,
sent user bubbles and queue rows, while the label keeps the interface
font. Geist and Geist Mono share vertical metrics, so line boxes don't
move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Center chips on their label's caps whatever the interface font

paint_chip assumed Geist's metrics put the label's cap height on the
row's middle. Other interface fonts move the baseline (the line takes the
tallest face's ascent and descent) and GPUI rounds it to a device pixel,
so chips could sit a pixel off their label (Noto Sans Mono: 1px low).
Each chip row now moves its pill to the label face's cap middle, from the
shaped line's metrics and the rounded baseline, in the composer, sent
bubbles and queue rows.

Also: tests that the padding, and only the padding, is shaped in Geist
in the composer, bubbles and queue overrides; the stale "narrow" side-pad
comment; the bubble's run building is its own function for the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: wing-anara <wing@anara.com>
@contribution-manager-katulevskiy contribution-manager-katulevskiy Bot added cm:inbox Contribution Manager: waiting for triage and removed review:needs-triage Managed by Contribution Manager labels Oct 6, 2026
jsgrrchg and others added 20 commits October 6, 2026 19:57
…eronsh#826)

* test(ios): reproduce fixture fallback for unknown chat routes

* fix(ios): return to sessions when a chat cannot be opened

---------

Co-authored-by: José Gurruchaga <jsgrrchg@outlook.com>
* test(codex): document and probe native voice capabilities

* feat(proto): define local voice session contracts

* fix(harness): bound realtime media and prioritize control messages

* feat(codex): attach realtime voice to an idle thread runtime

* feat(engine): own voice leases and enforce subscription eligibility

* feat(mcp): create standalone sessions on selected hosts

Add chat/side kinds with compatible defaults, resolve projects within the selected device, and validate that host's harness and model catalogs before writing. Route transcript reads to the chat host and preserve send baselines across waits so missing session rows and delayed replies cannot return an old response.

Cover invalid requests, remote catalogs, mixed batches, wait races, two-engine execution and interrupt delivery. Add an isolated MCP stdio smoke fixture.

* feat(doc): persist canonical voice transcripts once

* docs(mcp): document standalone and remote session workflows

* feat(audio): add native capture and streaming playback

* feat(audio): handle echo cancellation and voice interruptions

* feat(ui): coordinate voice ownership over local engine RPC

* feat(ui): extract the Bezel orb into a native Zeron component

* feat(ui): replace the composer with a central orb during voice sessions

* feat(mcp): constrain voice orchestration to subscription policy

* build(voice): add microphone permissions and offline audio CI

* test(voice): verify lifecycle, billing boundaries and rollout gates

* fix(voice): preserve text transport and idle billing boundaries

* feat(codex): connect subscription voice through native WebRTC

* feat(engine): activate native voice ownership and preserve delegated work

* feat(ui): enable native voice with central orb and retained drafts

* fix(mcp): keep voice delegation on subscription-backed Codex

* test(voice): document native transport and add opt-in connectivity smoke

* fix(voice): close audit races and validate project subscription context

* Fix Codex voice initialization and macOS microphone permissions

* Allow Codex voice agents to delegate through upstream MCP

* Make voice a persistent orchestrator with a dedicated stage

* Add call controls and elapsed time to the voice stage

* Remove decorative rings around the voice orb

* Add voice selection menu and simplify stage artwork

* Remove voice status labels beneath the orb

* Fix dictation and realtime voice integration after rebase

* Move Codex voice style selection into Voice settings

* Restore window dragging from the voice stage titlebar

* Open the voice stage immediately when starting a call

* Smooth voice orb motion and speaking transitions

* Remove the green audio pulse from the voice microphone control

* Show the live voice orb above the orchestrator chat composer

* Use full chat IDs in MCP reply instructions

* Correlate MCP replies by message IDs instead of remote timestamps

* Keep the voice stage open when launched from Settings

* test(voice): add a split-host Codex voice compatibility probe

* feat(voice): package a standalone desktop media runtime

* feat(proto): define client-media voice control contracts

* refactor(codex): separate voice signaling from local audio

* feat(engine): own remote voice sessions with ephemeral leases

* feat(rpc): route versioned voice controls to execution hosts

* feat(voice): coordinate local media with remote Codex control

* feat(ui): start voice on a selected remote Codex host

* test(voice): cover cancelled preparations and stale session controls

* test(voice): prepare desktop rollout validation and offline CI

* feat(ios): add a pinned native WebRTC compatibility endpoint

* feat(mobile): expose remote voice sessions through UniFFI

* feat(ios): bridge native audio lifecycle to remote voice calls

* feat(ios): add remote voice controls and registered host selection

* test(voice): cover relay and iOS lifecycle and prepare live acceptance

Add half-open and native callback regressions, stage-only diagnostics, complete runtime notices, and development test instructions. Keep real Mac/Fedora/iPhone acceptance opt-in for user validation.

* refactor(orb): share the thinking-orb engine through zeron-orb

Move the renderer-agnostic engine, presets, motion and a new OrbAnimator
(clock, audio response, state crossfades) into crates/orb. The GPUI widget
now delegates to the animator, so desktop and mobile paint identical frames.

* feat(voice): rebuild iOS remote voice around the desktop orb and phone-call audio

- Share the call view (phase, caption, levels, speaking, orb state) in
  voice-session; the client derives speaking because client media has no
  host meter. Keep owner-event headroom so level meters never overflow it.
- Title remote orchestrator chats "Voice session" like local ones.
- UniFFI: typed call state and end reasons instead of JSON, a distinct
  denied-microphone failure, and OrbRenderer frames from zeron-orb.
- iOS: OrbView paints the desktop orb; start voice from the New session bar
  (live strip while a call runs), the session nav bar and the iPad sidebar;
  full-screen call stage with caption, transcript (flagged when Codex waits
  for an answer), mute, route picker and hang-up.
- Phone-call audio: proximity sensor moves output to the earpiece, headsets
  win, calls survive screen lock, headset loss re-routes instead of ending.
  Install the WebRTC audio configuration globally so it is not reverted when
  the audio unit starts.

* fix(voice): install the macOS voice runtime under codex-resources/voice

Codex's helper only initializes from a codex-resources/voice directory and
exits with code 23 on initializeRuntime anywhere else, which the desktop
reported as "Voice could not connect". Bundle the runtime at
Contents/Resources/codex-resources/voice, reject other locations up front,
and report a helper that cannot start as an unavailable runtime.

Ship the runtime byte-for-byte with OpenAI's Developer ID signatures instead
of re-signing it ad hoc.

* fix(ios): reopen the call stage from the live strip and redo voice settings

The live strip in the bottom bar is a plain UIControl, which never sends
.primaryActionTriggered, so tapping it did nothing; listen for touchUpInside.
UI tests cover reopening the stage after the transcript and after minimizing.

Voice settings now follow the Settings list style: the resting orb, the
devices Codex can run on with their status, the voice as a pop-up menu, and
what to expect during a call.

* fix(ios): resume the orb animation when the app becomes active again

At willEnterForeground the app still reports .background, so the display
link never restarted and the orb stayed frozen after returning from Home.

* fix(ios): guard voice preview state in Release builds

* feat(voice): veil streamed caption words in on desktop and iPhone

Move the transcript's renderer-agnostic streaming veil into zeron-veil so
the voice call caption can use the same chunk fades on both platforms. A
new CaptionVeil keeps the veil aligned with the ellipsized tail and fades
the previous utterance out when the speaker turn changes; reduced motion
shows text directly. Desktop recolors GPUI runs; iOS gets UTF-16 spans
through CaptionFader and paints an attributed caption on a display link.
The desktop transcript keeps its behavior; the iOS transcript is untouched.

* fix(ui): step the voice stage aside when opening any session

Opening a session only hid the stage indirectly, when the selection changed,
so clicking the thread already under the stage (or the one you came from
Settings) did nothing. open_chat now hides the stage explicitly; the call
keeps running in the background.

* feat(voice): reuse host orchestrator chats across calls

* fix(voice): reserve orchestrator atomically and preserve rotation retries

* feat(voice): rotate the orchestrator transcript after 1000 entries

* feat(voice): package remote media runtime for Linux and Windows

* feat(voice): use the installed Codex helper everywhere and stop bundling it

Remote voice now plays audio through the client's own standalone Codex
installation, like local voice, instead of a runtime packaged inside
Zeron. The resolver moves to zeron-voice-media and is shared by the
harness and the desktop media endpoint; ZERON_VOICE_MEDIA_DIR remains a
development override. macOS, Linux and Windows packages no longer
contain Codex binaries, GStreamer/GLib or the Visual C++ runtime, so
Zeron takes on none of their redistribution or update obligations.
package-voice-runtime.py stays as a development projection tool.

Missing-runtime messages point to Codex's official installer, since npm
installs lack the voice runtime.

Notices and disclosures:
- THIRD_PARTY_NOTICES: full MIT chain for the orb (Bezel,
  gpui-thinking-orbs, thinking-orbs), Codex NOTICE, OpenAI trademark and
  non-affiliation statement, and WebRTC for iOS.
- The iOS app carries the orb's MIT notice; the Linux tarball now ships
  LICENSE and THIRD_PARTY_NOTICES.md.
- Microphone purpose strings on macOS and iOS state that voice sessions
  stream audio to OpenAI; dictation stays on device.
- Docs describe the voice data flow, and the acceptance checklist lists
  the legal and privacy decisions that block a public release.

* feat(ios): show the voice call in the Dynamic Island and on the Lock Screen

* fix(voice): hide the voice trigger when this device has no Codex

The microphone in the sidebar footer only shows when Codex is installed and
enabled on this device, following the same catalog the composer offers. It
re-reads after Settings → Agents changes and when returning to a window that
hides it. A start without Codex now asks for an install instead of a retry.

* test(voice): seed the real rotation threshold in the remote rotation test

* feat(voice): sound the start and end of a call on desktop and iPhone

A call sounds as it is placed, before the microphone opens, and again when
the user hangs up or a connected call ends. A failure to connect stays quiet
and explains itself. Nothing sounds in between. The iPhone plays them as
system sounds, so they follow the ringer switch.

* perf(voice): skip the rate limits round trip before each call

The pre-call probe read account/rateLimits/read, a ~1 s backend round
trip, and ran twice per call (prepare and negotiate). Its result was never
shown: the native backend makes the final usage decision. Dropping it
saves about 1.7 s per call on desktop and iPhone.

* perf(voice): release the orchestrator runtime 5 minutes after hang-up

The voice orchestrator's Codex runtime and MCP servers hold ~200 MB. Its
idle window was the generic 30 minutes, counted from the runtime's start,
so a call ending late in the window left a runtime reaped right after the
hang-up. It now idles 5 minutes, restarted at each hang-up.

Hang-ups wake the reaper directly, so even a short call restarts the
window, and a reap armed before a call began rechecks for it instead of
cutting the call.

* fix(voice): enable client-owned remote voice by default

* fix(harness): read whole resumed histories and report why stdout failed

509818e capped every JSON-RPC line at 8 MiB on the transport shared by
text chats. A resumed long chat returns its history in one response
(54-71 MiB measured), so resume failed and was reported as an exit even
though Codex was alive. Lift the cap (voice bounds its own messages) and
keep the read error's cause for every waiting and later request.

* fix(harness): name the rejected request in JSON-RPC errors again

dca9a05 made peer rejections structured but dropped the method from
their message, so chats showed 'JSON-RPC rejection -32600: …' without
saying which request failed. Prefix the method again and render it as
upstream did, keeping the code available for auth detection.

* test: skip POSIX mode check on Windows hosts

* fix: launch Codex batch shims from plain paths and answer account/read in fake

* refactor(engine): remove the superseded local session RPC surface

The client-media protocol replaced these methods; nothing calls them anymore.

* refactor(harness): restore the shared JSON-RPC transport

Return the stdio transport every agent uses to its upstream behavior
(unbounded writes, no timeouts or polling timers) and keep only an
additive notification route that is registered while a subscriber
exists.

* fix(ui): let the full-window stage show the window glass

On translucent appearances the stage painted an opaque fill unlike the
new-thread page. Drop the fill there and fade out the page it covers.

* fix(ui): show the full-window stage over Settings

Settings replaces the workspace layout and never mounted the stage, so
opening it from there did nothing. Mount it beside the settings sidebar
and fade the page it covers.

* fix(voice-session): keep the live caption on the newest turn

Turns arrive interleaved: a user final often lands after the answer has
started streaming. Keep each turn's text separately and caption the
newest one, so a late final never takes the caption back and deltas
never restart it. Desktop and mobile now share this state.

* ci: build the desktop UI only in the UI workflow

The offline-linux job compiled zeron-ui without the WebKitGTK and
JSON-GLib packages its build script requires, so it failed every run.
Drop its UI step; the UI workflow already runs those lib tests and now
also runs the orb geometry test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Claude CLI lists Haiku 4.5 as claude-haiku-4-5-20251001 while the
curated catalog uses claude-haiku-4-5, so the exact-ID overlay added a
second "Haiku 4.5" row to the model picker. Treat a -YYYYMMDD snapshot
of a curated ID as that curated row.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eronsh#818)

* Cut startup memory from ~700 MB to ~200 MB on large local profiles

- Registry: local-only replicas apply writes directly instead of queueing
  them forever (178k queued ops / 70 MB snapshot on an affected profile);
  coalesce the unsent tail per row with an edge, preserving push
  notifications, the per-op size limit and multi-op batches; compact
  bloated snapshots on load.
- Docs store: cap the WAL, truncate it at open, and VACUUM once when free
  pages dominate (again after registry compaction).
- Boot: read run journals backwards from the end; share one notify watcher
  for diff_sync and spaces with batched changes and a stream bridge; skip
  archived chats unless a transcript stream has them open.
- UI: notify only when watched state actually changed; fire clock-driven
  transitions (stale, device offline, send grace) at their deadlines
  instead of ticking every second.
- Fix the e2e generated-image test on macOS (/var vs /private/var).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review: watcher races, Linux inotify sharing, deferred VACUUM

- fs_watch: release the batch worker flag under the same lock as the
  "no new changes" check, so a registration racing the worker's exit is
  never lost.
- fs_watch: give inotify/kqueue one watcher per installed root. A shared
  instance let a checkout symlinking into another (npm link) alias its
  watch descriptors, and a failed recursive watch leaked its partial
  watches for the life of the process. macOS/Windows keep one watcher.
- fs_watch: rescan a live registration whose covering OS watch was
  replaced (a nested path taking over from a dropped recursive root, a
  mode change); nothing observed the swap.
- diff_sync: serialize every discard on one lock, whether or not the
  checkout has an entry, so two discards of one root never interleave.
- registry: fold writes into rows only for Local profiles, which can
  never attach an edge. Re-seeding folded rows as full upserts on a later
  attach could revive rows another device deleted meanwhile.
- store: keep only the WAL truncate in DocsStore::open; the engine runs
  the thresholded VACUUM on a blocking task after boot instead of on the
  boot path (engine assembly, iOS client, local import).
- ui: refresh the clock-dependent presentation caches when a clock
  transition fires, so the frame that reverts it (a revived session or
  device) notifies and re-arms the next deadline; side chats now watch
  clock transitions too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Wing <contact@winglee.io>
…zeronsh#813)

* Embed the browser on Windows and preview images in tool calls

- Browser tabs render pages inside Zeron on Windows (WebView2), matching macOS and Linux.
- Expanded tool calls preview the images they touch and free them on collapse.
  Right-click any transcript image to copy its path or the image itself.
- Paths, commands and output in expanded tool calls can be selected and copied.
- Fix: answering an agent question with multiple lines now grows the card instead
  of spilling text below it.

* Address review: repin zui upstream, harden the Windows browser and previews

- Depend on zeronsh/zui dce5c1f (zui#15 merged) and gpui-component
  47ccd07 instead of patching every gpui crate to a personal fork.
- Tool image previews: refuse UNC/device/drive-relative paths in the UI
  and, before any filesystem call, lexically jail requested paths in the
  engine (canonicalizing \\host\share opened an SMB session and leaked
  the user's NTLM hash); read chunks on the blocking pool; bound decodes
  with the shared raster limits; release keys a chip stops showing;
  decode base64 off the UI thread; copy sanitized SVG; cap output lines.
- Windows browser: fail closed on unreadable navigation targets; police
  subframes and cancel external-scheme launches; open tabs only for
  user-initiated popups (handled first); deny permission requests;
  disable web messages and host objects; cancel downloads silently
  unless the page itself is the file; recover from a browser-process
  exit on Reload; give each BrowserData its own InPrivate profile (no
  persistent fallback); sweep only folders of exited processes; never
  remount from an older composition generation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Wing <contact@winglee.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ronsh#820)

* engine: turn snapshots stay out of the checkout's object database

The "Latest turn" snapshot ran `git add -A` under a throwaway index, which
writes a blob for every unignored file into the checkout's own .git/objects.
Nothing referenced those blobs, nothing bounded them, and the git add had no
timeout. A checkout with unignored build directories collected 20 GB of loose
objects that way, and git's auto-maintenance then tried to repack them after
every agent commit: 23 concurrent pack-objects holding 117 GB on a 24 GB
machine.

Each snapshot now writes into its own object directory under the data dir,
with the checkout's database as a read-only alternate, and that directory is
deleted with the snapshot. A turn-diff capture layers its snapshot on the
turn's, so it only writes what changed. Stores left by earlier runs are swept
at start.

Snapshots are also bounded: untracked files over 8 MiB and untracked
directories over 10,000 files or 256 MiB are left out, and the whole snapshot
runs under a 120 s timeout with kill-on-drop children.

* Address review: partial budget, stable layered diffs, snapshot lifetime

- Over the untracked budget, leave out the largest units until the rest
  fits instead of every untracked path: one big directory or a pile of
  assets no longer hides the files a turn creates.
- Layer the "Latest turn" capture on the turn-start tree (read-tree into
  the throwaway index) and leave out what either side left out, so a
  directory crossing the budget mid-turn keeps its turn-start state
  instead of diffing as thousands of added or deleted files.
- Drop a chat's turn snapshot (and its on-disk store) when the chat is
  gone or archived without a viewer, and when a new turn's snapshot
  fails or its cwd is no longer a checkout, instead of diffing against
  an older turn.
- capture_git children are kill_on_drop (all read-only), so a timed-out
  snapshot takes its ls-files calls down too.
- Store paths are absolute: git resolves GIT_OBJECT_DIRECTORY against
  the checkout, so a relative data dir broke every snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Wing <contact@winglee.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…r do (zeronsh#748)

* Harden the Claude and Codex adapters: no early "done", Stop kills, sends never do

Builds on the unmerged runtime-survival work (6b25087c, branch
zeron/prevent-sub-agent-collapse): Cancel/sends no longer tear down the
agent process, perTaskStopAffordance, images ride the steering mailbox,
background work holds the idle reaper.

Premature "done" (sound + notification mid-turn), two Claude causes
reproduced live on CLI 2.1.286 and seen 27 times in real journals:
- a `now` steer aborts the turn and the CLI emits a result for it; that
  result was held, then released by a 5s quiet timer while the steered
  turn ran a long tool.
- `--resume` of a session whose background tasks died emits two empty
  results while the new prompt is still queued.
Every stdin user line now carries a uuid and the CLI's command_lifecycle
frames decide the turn end: a result while one of our messages is still
written/queued is a boundary, superseded when that message starts.

Codex: only the turn in flight settles (stale/duplicate completions are
ignored), steers confirm on their userMessage item via
clientUserMessageId, a stop waits for turn/started (an earlier
turn/interrupt is rejected and the turn ran on), and commands still
running past their turn report as background work.

Stop is the one teardown: the user's Stop ends the turn and the whole
runtime, including shells the CLIs start in their own process groups
(snapshot the descendant tree, kill it after the agent). Sends, steers,
"send now" and model/effort switches keep the runtime: Claude adopts new
config with set_model/apply_flag_settings, Codex on the next turn/start.

Stop/Send ordering (found by the new chaos suite, every harness): a Stop
could run before the send queued ahead of it (ignored, or the earlier
steer re-ran after it), and a send after Stop landed in the dying
runtime and was dropped. Commands now act in the order sent, and a
runtime being torn down takes no prompt (late arrivals re-dispatch).

Also: no false "limit reached" error when overage carries a rejected
window.

Tests: fake-CLI scenarios captured from live traffic (each fails on the
old code), mock-engine race tests, and opt-in live suites against the
real CLIs: runtime_survival_live and adapter_chaos_live (10 invariant-
checked scenarios; 30/30 Claude Sonnet 5.5, 30/30 Codex gpt-6-luna).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review: OpenCode stop/attachments, Codex config, orphan replay, parked Stop

- OpenCode: a turn stop the server rejects or never acknowledges tears the
  runtime down (mailbox closed first) instead of reporting the turn stopped
  while it may still run. The stopped-turn idle check now unwraps v1's
  /global/event envelope; it never matched, so every stop and interrupt
  waited out the 2s grace.
- OpenCode: messages queued mid-turn keep their images.
- Codex: a message carrying a new model/effort/tier mid-turn waits for its
  own turn/start with that configuration; turn/steer carries none.
- Engine: an accepted-but-unconfirmed message replays with the request it
  was sent with (images, configuration, id), not the chat's latest request.
- Engine: Stop ends a parked runtime that still holds background work.
- pi_rpc tests: new RunControls/SteerMessage fields (CI compile failure).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Every harness adopts model switches in place; reaper waits out the wake

Live-tested all nine local harnesses (runtime_survival_live): Claude and
Codex already kept their process across a model switch; the other seven
restarted it. Now:

- OpenCode: the model and effort ride the next prompt (2.x: set on the
  session first). A switch sent mid-turn starts its own prompt.
- Pi: set_model / set_thinking_level on the live process before the
  message it was sent with.
- ACP (Grok, Devin, Hermes, Antigravity): the session's model, effort and
  options are selected again over the protocol between turns; a switch sent
  mid-turn waits for the session to go quiet, ahead of later messages. A
  set_config_option answer without configOptions now records the value set.
- Cursor: the shim names the new model on the switching message's send and
  every send after it; it never joins a batch or native steer with the
  messages sent before it.

Engine: when background work drains, the idle reaper allows a full window
before reaping. The agent reports the drained set a moment before the turn
its completion wakes, and reaping in that gap lost the wake (zeronsh#831, caught
by the new live repro).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Durability matrix across all harnesses, and what it caught

The chaos suite now runs every local harness (claude, codex, cursor,
opencode, grok, devin, pi, hermes, antigravity) and adds queued bursts,
Stop while idle, a long silent command, an agent crash mid-turn, two chats
at once and tool approvals. `restart_durability` quits the app mid-turn and
force-kills it mid-turn (a separate process group, SIGKILLed), then checks
nothing reads as still running, the killed turn resumes and finishes, and
the conversation keeps its context. Prompts are conversational: Grok
refuses exact-token demands as injection.

Fixed along the way:
- A force-killed turn whose reply never reached disk (the doc saves on a
  debounce) was never resumed: recovery only looked for a streaming entry.
- A force-killed Claude chat in a symlinked folder (/var, /tmp on macOS)
  resumed in a fresh session, its history gone: the stored cwd is the
  resolved path until a turn's Done; resume now compares directories.
- Sends held for a turn-boundary agent (Hermes, Antigravity) lost their
  images and model, and each handover pinged "done" while they waited.
  They now run as sent, and no turn ends with a completion while a message
  the user already sent is still on its way (held, or still in the command
  log after the agent outran a burst).
- A Stop did not cancel messages sent before it that were held for the
  turn's end, or whose dispatch was still in flight: the next send thawed
  them, or they started the stopped work again in a fresh runtime.
- ACP agents, Pi, OpenCode and Cursor only killed the agent's process
  group: a command the agent ran in its own session (Devin) outlived Stop
  and quitting the app. Teardown now takes the whole descendant tree, as
  Claude and Codex already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop a needless borrow in the ACP config selection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop is one boundary: no early Done, no replayed steer (review)

From the adversarial review (both reproduced against simulated providers
first, each test fails without its fix):

- Claude: "Send now" over a turn end held for a queued steer released
  that Done the moment the stop was asked, before the CLI answered. The
  host then sent the replacement, and the stopped turn's late result
  settled it early and cleared its pending delivery. The stopped turn's
  one Done now goes out when the stop settles (its own result, or the
  settle window), and a result arriving just after it from that turn is
  dropped.
- ACP: a stop left a steer's in-flight `_session/steering` call alive; its
  promptRequired answer, once the turn was cancelled, queued the cancelled
  message again and it ran after all. The stop now drops the call, and
  steering answers are ignored while a turn stops.

CI: `tree_teardown_reaches_shells_in_their_own_process_group` raced the
fixture's setpgid (two descendants existing did not mean the group did).
The fixture now reports its pid once it leads its group, and is killed
even when an assertion fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Claude: never drop a replacement's own end as a stopped turn's late result

8c575d4 dropped any result arriving within the stop's settle window
unless the CLI had confirmed a new message started. A CLI that sends
neither command_lifecycle nor a replay never confirms one, so a quick
replacement's own result was dropped and the chat stayed Working
(reported in review, reproduced with a simulated CLI).

A result is now dropped as the stopped turn's only when nothing has
happened since the stop's Done: no message written, no output streamed.
Anything else takes the ordinary path — held while a message is
unconfirmed, settled by the existing fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Claude: judge a stopped turn's late result before it expands

e29fb82 counted every non-Done event as fresh activity, but a result
frame expands to Usage then Done: a stale result's own Usage marked it
fresh, and it was forwarded as a second Done (an Errored one fails the
parked session and its runtime). Reported in review, reproduced with a
simulated CLI.

The stale check now runs on the result frame before it expands, and drops
it whole (usage included). Activity since the stop is a message written
or genuine content — text, reasoning, tool calls and results, questions,
subagent events — never usage or a completed-message marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eronsh#777)

* Fix durable remote send and host wake recovery

* Couple accepted chat rows to durable server host wakes

* Make Linux browser captures wait for mapped and presented frames

---------

Co-authored-by: Wing <contact@winglee.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The Subagents section lists running subagents as plain rows, then folds
everything finished into a Finished dropdown (closed by default) with
Completed and Failed lists inside it. Each list collapses on its own and
pages at ten rows.

A "● N" pill shows how many subagents are running: on sidebar chat rows
(in place of the working spinner), under the titlebar's explorer button,
and beside the Subagents title. To badge chats that are not open, the
session row now carries `running_subagents`, published by the engine as
subagents open and settle. It is optional on the wire and stale rows
count as zero, like the working indicator.
The pill hung under the button and crossed the titlebar hairline. Mark the
button itself instead: its face breathes in the activity tone and a small
count badge sits on its corner, both drawn over the button so titlebar
layout is unchanged.
The pill no longer replaces the row's own activity indicator, so compact
titles stop shifting. In compact rows it stands where the time stamp is
while subagents run; in regular rows it leads the status label.
The titlebar badge had no explicit line height and used the UI font, so its
digit sat about a pixel high; the sidebar pill's digits sat half a pixel low.
Both now draw their count through one helper: mono digits in a box exactly as
tall as the pill or badge, with a small optical lift (and, for the badge, a
rightward shift) measured from rendered captures at one, two and three
characters.
Geist Mono's digits are centred on the line box and each digit's ink is
centred in its advance (checked from the font tables), so with the line height
equal to the box height nothing needs nudging. The earlier optical offsets
were fitted to two monitor scales and gave the same result there as none; at
a whole-number scale they would only add error. Residual misalignment is
device-pixel rounding of the baseline, at most half a device pixel.
… 99+ badge

The pill's dot was a box centred in the pill while the digits' vertical
position followed the text engine's own metrics and rounding, so at
fractional display scales the two could differ by a device pixel. The pill
now paints its background, dot and digits in one canvas: the digits' baseline
is snapped to a device pixel and the dot is centred on the digits' middle
from that baseline, so they share a centre at any scale.

A capped label ends in '+', whose thin arms leave the eye a wider gap than a
digit does; trim the padding after it by a pixel in both the pill and the
titlebar badge.
The badge was a laid-out box, whose text landed on a rounded device pixel and
read as the digit sitting left of centre. Paint the badge and its digits in
one canvas instead: the digits are centred on the badge's real middle, with
the baseline placed as in the sidebar pill. Measured for every digit 1-9 on
1.25x and 1.875x captures: centred to within half a device pixel, exactly for
most.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wing-anara
wing-anara force-pushed the subagents-finished-groups branch from fdb0d69 to c7dfc37 Compare October 8, 2026 18:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cm:inbox Contribution Manager: waiting for triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.