Skip to content

Device-local tabs + spaces as a searchable sidebar filter - #14

Closed
wingleeio wants to merge 10 commits into
mainfrom
tabs-spaces-restructure
Closed

Device-local tabs + spaces as a searchable sidebar filter#14
wingleeio wants to merge 10 commits into
mainfrom
tabs-spaces-restructure

Conversation

@wingleeio

@wingleeio wingleeio commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Restructures the shell around wing's proposal: tabs stop being synced session state, spaces stop being a navigation spine.

What changed

Tabs are now device-local. A tab is a local viewport onto a synced session (ui-settings.jsonopenTabs; the vec is the drag order). Closing a tab is local-only — the session keeps running on its host and stays in the sidebar; clicking a sidebar row (re)opens it as a tab. Archiving is a separate, explicit action in the session row's context menu (unchanged — it was already there). The strip is cross-space and gets a context menu: close, close others, close to the right, close to the left (rows that would close nothing are omitted). Existing installs migrate by seeding openTabs from the last selected space's sessions, so nobody's tabs vanish on upgrade.

Spaces are a searchable dropdown (same recipe as the ref picker: ranked substring search, keyboard nav) that filters only the sidebar session list. "All spaces" is the first row; "Add space…" (the existing ⌘K palette) is the last; rename/remove live on row context menus inside the dropdown.

New sessions. + opens the canvas defaulting to the sidebar's filter space — or the last selected space under "All". The composer gains a space chip on the canvas only; picking a space re-keys the ref selector, worktree plan, and harness/model catalogs through the existing selected_space observer (no new invalidation paths).

The synced Loro doc schema is untouched — this is purely a view-layer restructure.

Screenshots

Cross-space tab strip; sidebar sessions with the dropdown trigger on top:

overview

The spaces dropdown — searchable, "All spaces", device tags, "Add space…":

dropdown

search

Filtered to soccertcg — note the two comet-space tabs stay open (the filter never touches the strip):

filtered

New-session canvas defaults to the filter space, with the new space chip:

canvas

The canvas space picker, and the ref footer re-keying after switching to a git space (Current checkout / main appears because comet has git; soccertcg didn't):

picker

re-keyed

Tab context menu (last tab → no "close to the right"):

tab menu

Demo video

Filter → open-from-sidebar → + with space pick → send (mock harness streams) → close the streaming tab locally (session keeps running in the sidebar) → reopen with transcript intact → close others:

demo

MP4 version · assets live on the assets/tabs-spaces branch, not in this diff.

Verification

  • cargo test --workspace green (settings round-trip covers the new fields; tab-neighbor logic keeps its unit tests).
  • Exercised end-to-end against a seeded mock engine on a headless compositor — every capture above is the real app.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@wingleeio

Copy link
Copy Markdown
Contributor Author

Follow-up from wing's review: the filter trigger's caret now sits at the right edge of the row, and long space names truncate against it (label flexes; caret is flex_none).

truncated trigger

(The dropdown rows and session-row sublines already truncated against their @ device tags — verified in the same pass.)

wing-anara and others added 2 commits August 4, 2026 08:42
Tabs detach from session existence: a tab is a device-local viewport
onto a synced session (UiSettings.openTabs, the vec is the order).
Closing a tab is local-only — the session keeps running and stays in
the sidebar, where a click reopens it; archiving stays an explicit
sidebar-row action. The strip goes cross-space and gains a context
menu (close / close others / close left / right). Old files migrate
by seeding openTabs from the last space's sessions.

Spaces stop being a navigation spine: the sidebar section becomes a
searchable dropdown (ref-picker recipe) with "All spaces", filtering
only the session list and hosting space management. The new-session
canvas gains a space picker chip defaulting to the sidebar filter,
else the last selected space; picking a space re-keys the ref
selector and harness/model catalogs through the existing
selected_space observer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The label now flexes, so long space names ellipsize against the
trigger width instead of pushing the caret out of the row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wingleeio
wingleeio force-pushed the tabs-spaces-restructure branch from d9f7abf to de7f886 Compare August 4, 2026 08:44
anchored_menu pins to the trigger's top-left, which covered the
button. New anchored_menu_below primitive pins the floating layer to
the trigger's bottom edge — a dropdown proper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wingleeio

Copy link
Copy Markdown
Contributor Author

Dropdown now opens below the trigger instead of covering it (new anchored_menu_below popover primitive pinning to the trigger's bottom edge):

dropdown below

The selected row's wash (menu_row active styling) is the selection
signal; the trailing check fought the @device tag for the row end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wingleeio

Copy link
Copy Markdown
Contributor Author

Check glyph removed from the space-filter dropdown — the selected row's wash carries the selection signal:

no check

The selected row's wash (menu_row active styling) is the selection
signal everywhere — ref/checkout/model pickers, the canvas space
picker, and the account device menu — matching the space-filter
dropdown. menu_check retires with its last caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wingleeio

Copy link
Copy Markdown
Contributor Author

Wash-only selection is now consistent across all menus — removed the check glyph from the ref, checkout, and model pickers, the canvas space picker, and the account device menu (popover::menu_check deleted with its last caller). The current/worktree/@ device tags get the row end to themselves:

space picker
ref picker

wingleeio and others added 5 commits August 4, 2026 11:38
…-down

Dropdowns/menus previously vanished the frame their state dropped. Every
menu state is now a popover::Popup<T> with an exit phase: close paths
funnel through a begin_close that keeps the card mounted ~100ms playing
menu-out (fade + 2px lift, interactivity deadened), then a reap timer
drops the state.

The exit progress reads the Popup's closing instant at render time
rather than with_animation's element-state clock — that clock replays
from 0 on a missed frame, which flashed the dying menu back to full
opacity. The frosted backdrop blur ignores element_opacity, so its
radius rides the same progress down to zero instead of popping off at
unmount.

Also: clicking an open trigger now just closes its menu (the outside-
click dismissal no longer immediately reopens via the same click), the
same just-dismissed guard the user menu already carried.

Converted: spaces filter dropdown, user menu, session/tab/space context
menus, composer pickers, settings device menu. The @-mention completion
stays instant on purpose (keystroke-driven dismissal would read as lag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The host device was only visible inside the space pickers' rows. The
sidebar filter trigger now shows it right after the space name (caret
stays at the right edge), and the composer's space chip shows it as the
muted suffix — amber "· offline" when the host's heartbeat is stale,
same as the rows. Formatting shared via AppState::space_device_tag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both the sidebar filter dropdown and the composer space picker listed
spaces in sync-arrival order. They now share AppState::spaces_sorted
(case-insensitive by display name, id tie-break): search still ranks
prefix matches first — alphabetical within each rank — and the picker's
opening highlight indexes the sorted order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wingleeio

Copy link
Copy Markdown
Contributor Author

Superseded by #40 — the spaces-dropdown half is merged into that branch (relabeled projects), and the device-local-tabs half is moot since #40 removes the horizontal tab strip entirely.

@wingleeio wingleeio closed this Aug 10, 2026
davidhs26 added a commit to davidhs26/comet that referenced this pull request Aug 21, 2026
* linux: draw caption controls under client-side decorations

On Linux the window had no minimize/maximize/close at all wherever the
compositor ends up in client-side-decoration mode (GNOME/Mutter has no
SSD support; gpui's WindowControlArea hit-testing is Windows-only, so
the Windows caption cluster never applied). Meanwhile leaving
window_decorations unset requested SERVER decorations, which stacked a
compositor titlebar on top of zeron's own chrome under sway/labwc.

- Request client decorations on Linux (WindowOptions.window_decorations)
  to match the unified-titlebar design; the compositor can still
  override via xdg-decoration and the shell re-resolves every frame.
- Draw zeron-styled caption buttons (24px cluster rhythm, hand-drawn
  linear glyphs) with explicit handlers - minimize_window / zoom_window /
  remove_window - since the native control-area hit-test is inert on
  Linux. Mounted at the root like the Windows cluster so they survive
  gates and the splash.
- Follow the desktop's button layout (GNOME button-layout via
  cx.button_layout, observe_button_layout_changed): right side by
  default; "close:..." layouts put buttons on the left and the app
  cluster shifts past them at the shared 2px rhythm. Controls the
  compositor can't do (per wm_capabilities) drop out; close always
  stays. Server decorations draw nothing - no double controls.
- Reserve titlebar right padding for the caption strip (tabs strip,
  changes-pane header, settings bar) via titlebar_right_pad.
- Give gate/splash pages a real drag strip on Linux/Windows
  (titlebar_drag_region - the WindowControlArea::Drag surface alone is
  dead on Linux).

Rig-verified under labwc (CSD: all three buttons work - minimize
unmaps, zoom maximizes and flips to the restore glyph, close exits;
close hover shows the red wash) and sway (SSD: no double controls);
left-side layout verified end-to-end through xdg-desktop-portal-gtk
with button-layout "close:".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(proto): model checkout change request status

* feat(engine): resolve GitHub pull requests with gh

* feat(engine): stream cached checkout change request status

* feat(ui): track pull requests for local and remote sessions

* feat(ui): show pull request badges in sidebar and composer

* feat(ios): support streaming device RPC

* feat(ios): show host pull requests in session surfaces

* test: cover pull request status across device boundaries

* fix(ui): frost pull request tooltip

* fix(engine): resolve gh from login shell path

* fix(engine): preserve links on unsupported streams

* fix(ui): retry pull request watch after host upgrade

* fix(engine): use origin for untracked branches

* fix(engine): resolve uncached remote default branch

* fix(engine): evict inactive change request cache entries

* test(engine): isolate login shell gh path fixture

* github: add FUNDING.yml so the repo shows a Sponsor button

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: adopt external pi sessions into workspace (ID01-420) (#1)

* feat: adopt external pi sessions into workspace (ID01-420)

Add pi_adopt module that discovers pi harness sessions created outside
the Zeron engine (CLI runs, pi-worker) and surfaces them as regular
workspace chats so every client (iOS included) can list and open them.

Discovery reads two sources:
- pi-acp session map (~/.pi/pi-acp/session-map.json)
- pi agent session store (~/.pi/agent/sessions/*/*.jsonl) as fallback

Each session becomes a Chat row with deterministic UUIDv5 id, resolved
space (created if needed), and idle Session row. Rows are idempotent —
re-scans never duplicate or rewrite existing rows.

Feature flag: ZERON_ADOPT_PI_SESSIONS=1 (off by default). With the flag
absent, the binary behaves identically to upstream.

Includes integration tests (2 passing) and proper CancellationToken-based
shutdown.

* docs: notas de implementación y verificación de pi_adopt

* fix(pi-adopt): post-review fixes — skip adoption without space, downgrade map warn to debug

- If resolve_space returns None (create_space transient error), skip
  adoption for this scan instead of writing an invisible chat row
- Downgrade recurring session-map warning to debug (45s spam)
- Update NOTAS with adversarial review findings

* fix(pi-adopt): don't guess a dangling space_id after create_space

Review (kimi-coding/k3): if create_space no-ops on an existing
(device,path) and the re-read fails, the derived UUIDv5 would
dangle and SpacesSync's orphan sweep would delete the chat.

Also: run the scan in spawn_blocking, log skip reasons at debug,
and document the spec-5 warning→debug deviation.

---------

Co-authored-by: David <davidhsutton@gmail.com>

* ci: fork gate (check + engine tests); upstream deploy/release manual-only

Los workflows heredados apuntan a la infra de zeron.sh (R2/releases) y no
aplican al fork; quedan en workflow_dispatch. El gate del fork es ci.yml:
cargo check --workspace + tests del engine. (ID01-424)

* landing: add nine new tweets to the marquee

Five to the top row, four to the reverse row, avatars mirrored into
assets/tweets. Marquee durations scaled (72s->117s, 80s->126s) so the
longer tracks keep the same scroll speed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* harness: native Claude driver over stream-json (A1)

Resurrect the pre-ACP claude driver (ca05336^) and modernize it against
CLI 2.1.228, replacing the claude-agent-acp adapter for HarnessId::ClaudeCode:

- Permissions ride the stdio control channel (--permission-prompt-tool
  stdio, validated live: can_use_tool requests arrive and allow/deny
  responses are honored). Tools auto-allow (unattended parity);
  AskUserQuestion round-trips through the engine input bridge.
- Done is the CLI's own result frame, eagerly — background subagents
  never hold the turn. The CLI's native wake turn (second init on the
  same session id + second result, live-verified) flows through the same
  stream; the engine's existing parked-session resume path turns it into
  the done→Working→done wake with no engine changes.
- Subagent frames (top-level parent_tool_use_id) are wrapped in the new
  AgentEvent::Subagent variant instead of dropped — never folded into
  the parent feed (they'd split contiguous text around phantom tool
  calls, and their message boundaries must not rotate the parent's
  assistant message id). The parent doc fold ignores them; routing to
  per-subagent docs lands with the subagent-visualization commit.
- --thinking-display summarized (newer models emit no readable thinking
  otherwise) alongside --verbose/--include-partial-messages.

Tests: fake-CLI scenarios (init dedupe, tagging, eager-done+wake, ask-
user round-trip, steering, interrupt escalation, error mapping), a
verbatim live-captured 2.1.228 background-subagent frame replay, and an
ignored live-CLI smoke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* net: cap mobile warm dials, overlap checkpoint with backfill, flush sends at state

Three cold-open wire cuts for thin links (450kbps target):

- iOS caps background room dials at 8 (attention order). All 46 sessions
  still hydrate from disk instantly; sidebar status rides the registry
  room; an undialed chat connects the moment it's opened. Foreground and
  path-recovery kick sweeps skip dial-held stores — no more 46-socket
  stampede on every flap.
- Both clients send the rows request BEFORE the checkpoint HTTP fetch and
  stream the backfill in parallel with the download, buffering rows until
  the import lands (order pinned by test). The download used to push the
  whole join back by its own duration.
- Both clients flush pending pushes right after the state answer instead
  of after full convergence — a message typed offline now lands ~2 RTTs
  after the socket does, not after checkpoint + backfill (server batchId
  dedupe makes replays no-ops; deadlock-shaped regression test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: checkpoint downloads survive redials, registry single-dial, registry-first dial gating

On-device NLC (Edge profile) round 2 fixes:

- ChatRoomClient: one checkpoint download per chat that OUTLIVES socket
  generations (a saturated link trips the silence lease mid-download —
  pongs queue behind the blob — and the redial used to discard the bytes
  and start over, looping a fresh-chat open forever). Partial downloads
  Range-resume across attempts; download progress defers the silence
  lease (120s backfill deadline stays as the backstop); a completed blob
  applies even if its socket died (CRDT import + max-cursor are socket-
  independent), so the next session joins RowsOnly.
- RegistryClient.kick: don't redial while a handshake is in flight —
  foregrounded() fires on scene activation at launch and killed the dial
  mid-TLS every cold open, doubling the spinner (6.9s → measured single
  dial 4.5s on Edge). Mirrors ChatRoomClient's existing guard.
- AppModel.preloadSessions: warm chat dials wait for the registry to
  connect (10s cap) — the sidebar's own handshake+state gets the thin
  pipe to itself; open views still dial instantly via releaseDial.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* sync: pull-first HTTPS transport — 1-RTT bootstrap, WS demoted to enhancement

The airplane-wifi fix. The WS needs 4+ serial round trips (DNS, TCP, TLS,
upgrade, hello) before byte one, and hostile middleboxes strip the upgrade
entirely — the user-reported 'never connects on the plane'. A plain HTTPS
GET is 1 RTT on a warm connection and indistinguishable from a webpage to
any proxy. So: push/pull/poke.

Edge (worker + DOs):
- GET /registry/{org}/rows?since= — the WS hello's exact delta answer
  (full/gcFloor rules shared with handleHello); ?beat=1 doubles as a
  presence beat. POST /registry/{org}/push — the WS push twin via a shared
  applyPushBatch core (validate, atomic apply, rows broadcast to live
  sockets); LWW clocks make replays no-ops.
- GET /chat2/{id}/rows?after= — one request collapsing connect→hello→
  state→rowsReq→backfill: u32-LE length-prefixed frames (state with
  frontier payload, rows, rowsDone), byte-identical encoding to the WS.
  POST /chat2/{id}/rows?batchId= — push twin, batchId-deduped.

Desktop (Rust): RegistryTransport/ChatTransport seams; with a transport,
construction resolves immediately (local-first), an HTTP pull bootstraps
in ~1 RTT while the socket dials, and every backoff cycle pushes pending +
pulls over HTTPS — a WS-hostile network degrades to ≤30s polling instead
of silence. Engine wires both via the existing bearer/reqwest plumbing
(registry: URL derived from the WS provider; chat: EdgeChatTransport).

iOS (Swift): WorkspaceStore pull-first bootstrap + 20s poll-while-down +
HTTP flush of pending writes; the header spinner now means 'server state
reached this session' (flips on the ~1-RTT pull, not the 4-RTT socket).
ChatRoomClient.pullSync: HTTP push of pending sends (permanent rejections
retire, mirroring the error-frame path), framed rows pull applied through
the exact socket path, checkpoint fetch shared via fetchInFlight.

Validation: new edge/scripts/pullpush-check.mjs green against wrangler dev
(push/ack/no-op replay, delta-at-cursor, presence beat, framed pull,
excludeOwn, batchId dedupe); existing smoke + chat2-check (65) + reset
suites green; zeron-sync 34, engine+rpc 180; iOS sim build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* sync: audit fixes — heal waits for known server state, iOS retires pushes only on edge verdicts

Pre-merge audit findings:
- With immediate-ready construction, the host's bootstrap heal could read
  all-zero placeholder stats 3s after construction (server state not yet
  known on a slow link) and upload a spurious full-doc checkpoint. New
  ChatStatsSnapshot.server_known gates it; the heal waits (bounded 2min)
  for a real state answer.
- iOS pullSync retired pending batches on ANY http 400/413 — a captive
  portal answering a POST with junk would silently drop a real message on
  exactly the networks this path serves. Retirement now requires a parsed
  edge error code (bad_push/too_large/empty), mirroring the WS error-frame
  discipline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* harness: native Codex driver over app-server JSON-RPC (A2)

Resurrect the pre-ACP codex driver and modernize it against codex-cli
0.146.1 (the app-server API is experimental — the pin is noted in the
module header), replacing the codex-acp adapter for HarnessId::Codex:

- Done stays turn/completed (turn.status == "failed" now maps to an
  errored Done even without a turn.error; the error notification's
  nested params.error.message shape is accepted alongside the old flat
  one — both live-verified).
- Approvals ride the app-server request RPCs as before (wire policy
  "never" for unattended parity, stray requests round-trip as yes/no
  questions); the experimental item/tool/requestUserInput content
  questions now reach the input bridge and answer keyed by wire id.
- Subagents are full child threads. A registered child's notifications
  route through an EXPLICIT table (route_child_notification), pure and
  tested: item lifecycles AND message/reasoning deltas become tagged
  AgentEvent::Subagent events (live capture shows child threads DO
  stream deltas on this wire — token-level live transcripts without
  touching the rollout files), child turn/thread bookkeeping is
  consumed so it can never settle the parent turn, and unknown methods
  fall through to the parent path — fail open, never silent loss (the
  reference implementation shipped two bugs from a catch-all swallow:
  a child error left the agent running forever, and a swallowed
  serverRequest/resolved stuck approvals). Registration comes from
  subAgentActivity items on the parent thread; the root's own activity
  marker never registers (registering the root as its own child
  intercepted its turn/completed and hung the thread Working).

Live-validated: basic turn end-to-end (marker file written, clean
turn/completed) and a two-child fan-out capture archived under
tests/fixtures/codex/. Ignored live smoke included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pi-adopt): filter noise sessions (ephemeral cwds + utility prompts) (#2)

Skip adoption of sessions in /tmp-style ephemeral workdirs (E2E test
workspaces) and of machine-to-machine utility prompts (title generator).
Observed in production: first rollout adopted title-gen sessions and
pw2-e2e workspaces, flooding the mobile sessions list. (ID01-420)

Co-authored-by: David <davidhsutton@gmail.com>

* harness: Cursor driver via the pinned @cursor/sdk shim (A3)

Move HarnessId::Cursor off ACP (that surface strips subagent transcripts
— live-verified: task rawOutput is {durationMs, isBackground} only) onto
Cursor's SDK, per Cursor's own guidance to integrators.

What wire the SDK wraps, investigated first: @cursor/sdk@1.0.28 does NOT
spawn the cursor-agent binary — it is Cursor's agent runtime bundled
in-process, speaking proprietary protobuf/ConnectRPC to api2.cursor.sh
with client-side tool execution (sandbox helper binaries, MCP spawning).
There is no speakable stdio wire to drive from Rust, so the driver is a
thin zeron-owned Node shim (shim.mjs, JSONL frames over stdio) around the
PINNED SDK, materialized into a managed npm install
(adapter_install::ensure_installed_shim) and spawned as `node <shim>`.

- Done comes from the SDK run's terminal result — a crisp turn end by
  construction; steers queue and deliver at turn boundaries (parity with
  the prior ACP behavior).
- Subagents: tool-call-delta carries the child's FULL nested transcript
  (NestedTaskUpdate) tagged by the spawning task's callId; the shim
  forwards it tagged and the driver wraps it as AgentEvent::Subagent —
  token-level live, no file tailing needed for cursor.
- The permission-free print surface is never used. askQuestion is
  disallowed at agent creation: the SDK exposes no public answer channel
  (SDKRequestMessage carries only a request id), so a question would
  block the run forever. Known gap vs the ACP ask_question bridge.
- AUTH: the SDK's credentials are SEPARATE from `cursor-agent login`
  (verified live — Agent.create succeeds unauthed, the turn then fails
  with "Invalid User API Key"). Turn/startup errors carry the exact fix
  (set CURSOR_API_KEY) as the error chip.
- Beta churn: unknown SDK update kinds are ignored, never fatal — a
  drifted SDK degrades the stream, not the chat.

Shim validated live against the real SDK (ready frame + clean actionable
auth error end-to-end); driver covered by fake-shim scenarios (mapping +
tagging, steer-as-next-turn, interrupt, fatal auth, crash tail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* landing: point downloads at v0.2.4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* harness/engine: retire adapter machinery for claude/codex/cursor (A4)

AcpHarness now serves ONLY agents built ground-up on ACP — Grok and
Hermes — plus pi via pi-acp until a native driver exists.

Removed with the claude/codex/cursor ACP paths:
- The claude-agent-acp / codex-acp specs, managed-install prewarm
  entries, and constructors (the pi pin keeps the npm install machinery;
  the cursor shim reuses it for @cursor/sdk).
- The `_session/turn_ended` extension handler — the upstream adapter
  dependency (adapter PR #997) is gone; the native claude driver forwards
  the CLI's own wake-turn result instead.
- The Claude cost-frame settle machinery (COST_HINT_GRACE, turn-end cost
  arming, the quiet-settle exemption): all of it compensated for the
  adapter dropping prompt replies, which the native driver cannot do.
  The blanket quiet settle stays for the remaining ACP agents.
- Cursor's ACP extension surface (cursor/ask_question, create_plan,
  update_todos, task, generate_image handlers + the parameterized model
  picker opt-in and variant-id plumbing).

Engine: new Harness::deterministic_turn_end capability — native drivers
whose every turn shape (user-prompted AND agent-initiated wakes) ends
with a wire Done return true, and drive_run stops arming the 120s/20s
quiesce watchdogs for them (ZERON_TURN_QUIESCE_MS still forces a window
on for diagnostics). ACP agents keep the watchdog backstop.

Tests: claude/codex/cursor ACP suites and the cost-frame/turn-ended/
quiet-exemption tests removed with the machinery they pinned; managed-
install tests repointed at the pi pin; the login-shell resolution test
now proves the native claude driver consults the same PATH snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* harness: Grok ACP hardening (A5)

Grok stays on ACP — its only surface with supervised permissions
(headless is --always-approve only) — hardened against the field
reports of silent non-response and multi-second stalls:

- Spawn args are now `grok --no-auto-update agent --no-leader stdio`.
  Flag placement verified against 1.0.4 (--no-auto-update is top-level;
  --no-leader lives on the agent subcommand — NOT the spellings the
  investigation guessed). --no-leader starts a fresh agent even when
  `[cli] use_leader` makes `agent stdio` ATTACH to a shared leader via
  ~/.grok/leader.sock; --no-auto-update kills the launch-time update
  check. Field-hang REPRO (leader on a private socket, SIGSTOP, attach):
  no initialize response for 10s then a client-timeout death on 1.0.4
  (older builds hung forever — the reported signature); the same wedged
  machine completes a turn in ~1s with --no-leader. npm fallback pin
  bumped to @xai-official/grok@1.0.4 to match the verified flags.
- `_x.ai/session/prompt_complete` is now the AUTHORITATIVE turn end
  (live-confirmed on 1.0.4, including the echo of a zeron-minted
  `_meta.promptId`): the notification swaps the in-flight prompt future
  for a synthesized response, so the ENTIRE existing response-settle arm
  (steering settlement, drain ordering, park/queued-steer) runs
  unchanged and the possibly-hung RPC drains discarded in the
  background. Stale completions (already-settled prompt ids, ring of
  32), foreign sessions, and out-of-order ids can never settle a newer
  turn. The prompt response remains the fallback — whichever lands
  first wins, exactly once.
- Prompt-stall watchdog (spec-gated, grok 30s;
  ZERON_ACP_PROMPT_STALL_MS overrides): TOTAL wire silence after a
  prompt — healthy grok acknowledges within milliseconds — surfaces a
  visible error chip naming the likely wedge and closes the run with an
  errored Done, instead of indefinite Working. Any wire traffic clears
  it; stderr already feeds the harness log via the rolling tail.
- Usage: grok settles token counts on the response `_meta`
  (inputTokens/outputTokens — verified live); usage_from_response reads
  it alongside the adapters' first-class `usage` object.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* subagent visualization: per-subagent docs, spawn chips, right-pane tab (B)

Main doc stays light: subagent content NEVER enters the parent chat doc.
The spawn tool call becomes the index chip — subagent_ref (the subagent
doc id), subagent_status (running/done/failed; distinct from `resolved`,
which the eager-done policy fires while the subagent still runs), and a
one-line live tail — all additive doc fields old readers ignore.

Engine (drive_run): tagged AgentEvent::Subagent traffic routes to a
per-subagent doc, id `{chatId}--sub--{toolUseId}` (hashed when unclean/
over the edge ID_RE budget — same id names the ChatRoom and the frozen
blob). Sinks fold events into one streaming entry, flushed on the same
coalesced STREAM_COMMIT_MS tick as the parent; the held doc Arc pins the
doc warm while running. The chip refreshes through the live fold while
its segment streams, else in place via the new
SessionDoc::update_subagent_chip (newest-entry-first scan — the parked/
wake norm), with tail stamps throttled to ~1/s. On the tagged terminal
event the entry finalizes and the transcript FREEZES: entries JSON
uploads as a static R2 blob (blob/{chatId}/{subDocId}) so viewers of
finished subagents never wake the room; dropping the sink unpins the
doc and the room idles. Every failure path degrades to chip-only.
Routing runs BEFORE the parked gate, so a parked session's background
traffic reaches the subagent doc without un-parking the chat.

UI: RightSurface::Subagent tab beside Diff/Terminal, opened from an
"Open subagent" affordance in the chip's existing blob-affordance slot
(analytic heights untouched). Strictly read-only Transcript::for_doc
instances — no composer, no steering, no permission UI, no own-turn
machinery, no global attachment-protection writes. Running subagents
watch their doc over the unchanged WatchDocMessages RPC (any doc id);
finished ones fetch the frozen blob first, doc watch as fallback.
Closing the tab drops the watch task, which cancels the server-side
watch and unpins the doc from the WARM_DOC_CAP LRU.

zeron-doc: SegmentWriter::resume/into_state (cross-flush sink seam) and
the in-place chip update API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Also: the cursor shim's crash message now waits briefly for the reaped
exit status (try_wait raced the child's death — the error chip read
"still running" with no stderr tail).

* ios: pull's checkpoint fetch must drain the socket's frame buffer

The HTTPS pull and the socket handshake race on a frontier-missing chat:
the pull's inline fetch set fetchInFlight, the socket's handleState saw it,
skipped its own fetch, and armed checkpointBuffer expecting the completion
path to drain it — which the inline fetch never did. Every socket row
(backfill AND live broadcasts) buffered until the 120s backfill deadline
redialed: a frozen transcript on exactly the fresh-chat open the pull was
meant to speed up (user report, 2026-08-18). Route the pull's fetch
through completeCheckpointFetch, whose tail applies the blob and drains
whatever buffer a racing socket armed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* sync: pull-path failures are loud on both platforms

The 2026-08-17 cellular incident was undiagnosable from device logs: the
phone's sockets fleet-wedged on 2-bar 5G while HTTPS demonstrably worked
(sends + uploads flowed), the pull path failed to fill the transcript —
and logged NOTHING, because every failure exit was a silent try?/guard.
That violates this codebase's own rule (roomLog header: sync must never
fail silently). Every pull/push exit now names its failure (no-token,
transport error, http status, malformed body, frontier-still-missing),
and the Rust offline-sync failures log at warn instead of debug so real
users' daemon.log shows them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* sync: empty checkpoint frontier is NOT contained — fetch, never skip history

Root cause of the 'Add Tweets' invisible-transcript incident (2026-08-18),
diagnosed from room stats + phone log archives + the absent on-device doc:
the room's checkpoint (seq 5, rows 1-5 trimmed) carries an empty frontier
label, and both clients treated empty-frontier as 'already contained' — so
every FRESH reader skipped the checkpoint, imported rows 6..75 raw, and
Loro parked everything depending on the chat's founding ops: transcript
invisible past the reader's own-lineage rows, no import errors (parking
is not an error), and snapshot export fails with pending ops so the doc
never persists. Converged devices (synced from row 1 pre-trim) unaffected,
which is why the bug only shows on new devices/fresh installs.

- Both clients: drop the empty-frontier shortcut; empty fails the decode
  path → not contained → fetch. Fetching is always safe (full-state merge;
  an empty-doc seed applies as a no-op) — the shortcut contradicted the
  adjacent comment's own principle ('never silently skips history').
  Applying the poisoned room's real checkpoint bytes heals it client-side
  with no server repair.
- Edge: reject checkpoint POSTs with an empty frontier when seqCovered>0
  (empty stays legal for the M1 empty-doc seed) so no new rooms are
  poisoned; chat2-check codifies the guard (66 checks green).
- Regression tests: EngineChatSink::contains_frontier(&[]) must be false;
  plan-level decision-table entry for the fresh-reader fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* sync: an encoded-empty frontier is a vacuous claim — fetch, never skip

The first frontier fix (zero-length bytes) didn't heal the poisoned room:
the phone's next cold open STILL skipped the fetch cleanly. Mechanism
proven by failing test: the room's frontier is a VALID ENCODING of an
EMPTY version vector — every doc vacuously 'includes' empty, so the
containment check passed for readers holding nothing and they skipped the
chat's founding ops anyway. One representation deeper than the last fix.

Both platforms now treat a decoded-but-empty version vector as NOT
contained (a size>0 checkpoint claiming empty state is a contradiction),
with an info log at the decision so any future skip names itself.
Regression tests: encoded-empty frontier → fetch; real contained frontier
(doc with actual ops) still short-circuits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* sync: cursor amnesty — a cursor above the checkpoint is re-verified, once

The 'Add Tweets' wedge, closed with artifact evidence: the phone's
persisted doc was EXACTLY the 2728-byte checkpoint (state at seq 5, which
includes the user's own message) under cursor=75. Rows 6-75 had imported
as PENDING during the frontier-bug era (deps missing → Loro parks
silently, import reports success, no logs), the cursor advanced anyway,
and parked ops vanish on export — so the persisted doc lied under a
cursor that guaranteed nothing below it would ever be re-read. Every
transport then truthfully reported 'converged' forever.

Fix (both platforms): once per client, on the first state answer, clamp
the cursor to the room's checkpointSeq. Rows since the checkpoint refetch
(re-imports are no-ops; cost bounded by the trim policy to the last
checkpoint window), converting any lying cursor into a true one — heals
every wedged replica with no server change and no data-dir surgery, and
keeps healing any future parked-history divergence from any cause.

iOS grows a clampCursor delegate (persists doc+cursor in the same write,
C2 rule); Rust clamps shared.cursor at the state site both transports
share. Amnesty logs at info so a clamp names itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* landing: point downloads at v0.2.5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pi-adopt): repair tests + k3 findings (ID01-426) (#3)

Tests: non-ephemeral cwds (the filter itself broke tempdir-based tests);
/tmp adoption asserted as filtered. Findings: /private/var/tmp coverage,
shared TITLE_PROMPT_PREFIX const with titles.rs, info-level utility skip,
extra ephemeral tests, module doc on operator-side purge.

Implementer: glm-5.3 (timed out at 40m but left complete work).
Local gate: targeted tests (unit+integration pi_adopt) + workspace check;
full engine suite runs in CI only (hangs locally against the live engine).

Co-authored-by: David <davidhsutton@gmail.com>

* subagent viz: three live-rig findings fixed

The PR's media rig session (real claude + codex through the built app)
surfaced three gaps the fixture suites missed; all three are fixed and
pinned by regression tests:

- update_part_fields dropped the chip fields: a spawn chip mutating
  while its segment still streamed (the codex fan-out shape) lost
  subagentRef/subagentStatus/subagentTail at SegmentWriter::sync — only
  the parked in-place path persisted them. Live-segment chips now carry
  the fields.
- claude 2.1.228 emits NO tagged terminal frame for a finished
  background subagent: completion arrives as an untagged system
  task_notification carrying the spawning tool's id (shape pinned from
  the live capture). The normalizer now surfaces it as the subagent's
  tagged Done, so chips flip done/failed and transcripts freeze instead
  of reading running forever.
- claude streams no tagged partial deltas: a subagent's text arrives
  only as full text blocks on its tagged assistant frames, which were
  dropped — subagent transcripts were tool-chips-only. Tagged text
  blocks now emit as attributed text (message granularity; the wire
  offers nothing finer).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* subagent tabs: contextual titles + top-aligned streaming runway

User feedback on the PR build:

- Tab names are contextual. The root cause was the harness: Claude's
  spawn decode named the chip a bare "Agent", so the tab inherited it.
  The Agent/Task decode now names the chip after the task's description
  ("Agent: scan repo") — codex (agent-path leaf) and cursor already did.
  The tab-title fallback also digs description/prompt out of the stored
  tool input for older docs whose chips predate the decode, and every
  derived title caps at 40 chars for the fixed-width strip.
- Subagent transcripts start from the TOP and stream downward. Override
  instances build their list Top-aligned — the notes-page shape
  structurally, with the empty space below as the runway — instead of
  Bottom-aligned, which pinned short/starting transcripts to the foot
  of the pane. The whole glue-trap surface (end-sentinel re-glue,
  distance-0 restick, pad/hold machinery) is a Bottom-alignment
  property, so none of it is needed or armed here: the pin starts off,
  the wheel restick is gated off for overrides, and row 0 keeps only
  the turn gap (the pane already pads for the titlebar — the full
  window-chrome pad double-counted and started content ~66px down).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): round project picker section corners

* feat(pi): step-boundary steering — descriptor truth-up for the patched pi-acp adapter

The local pi-acp adapter now implements the _session/steering ACP
extension, bridging to pi native RPC steer (delivered before the next
LLM call). Runtime detection stays dynamic via
initialize._meta.steering.supported, so an unpatched adapter degrades
to boundary delivery instead of breaking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScYmkStp1WkbMwchzaXjcb

* subagent UX: real terminal statuses, bare tab titles, designed chip

User feedback round — statuses were wrong and the chip read as bolted-on;
all fixed now, none deferred:

- Codex and Cursor subagents get REAL terminal signals. Codex children
  never see thread/closed in real fan-outs (it needs the collab
  close_agent tool), so chips stayed "running" after the child settled —
  a child's turn/completed / turn/failed / turn/aborted now routes as
  the subagent's tagged Done (completed/errored/interrupted). Cursor's
  SDK has no separate terminal frame for the nested transcript, so the
  task tool's end doubles as the tagged Done. Claude already settles via
  task_notification. The engine skips minting a subagent doc for a
  Done-only event (a terminal that beats registration is chip-only, not
  an empty frozen doc).
- Tab titles are the BARE task description — the "Agent: " prefix ate
  most of a fixed-width tab. Genus stripping is word-boundary-safe
  ("Taskmaster" survives), first-line-only, 40-char capped; "Subagent"
  is the last resort.
- The chip is designed, not annotated: state is visual — the app's
  mini gradient spinner (the sessions-sidebar dot loader) pulses in the
  trailing slot while running, done is the ordinary quiet chip, failed
  takes the danger tint; the "· running/· done" text suffixes are gone.
  The affordance row is "Show subagent transcript", rendered from the
  same base as "Show full output". The Subagent tab's strip icon swaps
  to the mini spinner while its doc streams (the history fetch-all
  pattern), settling back on completion.
- Clipped running-tail bug root-caused: the tail bypassed the
  single_line whitespace fold every other chip detail passes through,
  so raw text wrapped inside the fixed 30px header and clipped. It now
  folds like every detail line; height accounting is unchanged by
  construction (no new visible rows).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* subagent polish: Agent chip label + codex message paragraphing

Two cosmetic findings from the live verification pass:

- Spawn chips read "Tool · Agent: scan repo" — two labels fighting. The
  Unknown decode convention every native driver uses ("Agent[: <desc>]")
  now labels the chip "Agent" with the description as the detail.
- Codex emits several assistant messages per turn (commentary, final
  answer) whose deltas carry no separator, so consecutive messages
  rendered concatenated ("…waiting.Beta's 90-second…"). Each completed
  agentMessage now closes a paragraph, in the parent transcript and in
  child subagent docs alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* subagent tab: scroll follow + jump pill; chip quiets down; top fade

Feedback batch on the subagent surfaces:

- The tab now has the main transcript's follow UX. Top alignment stays
  (the start-from-top runway is untouched) — verified against the pinned
  gpui source that the list glue trap is Bottom-alignment-only: under
  Top, past-end offsets always materialize concrete, so a parked spring
  cannot re-glue and hard-track growth. The pin/spring/restick machinery
  is distance-based and runs unchanged on top: streaming overflow
  follows the end with the same glide, wheel-up releases to a held
  offset, and the jump pill — extracted into a shared element, not
  copied — renders pane-locally for the active tab and re-engages
  follow on click. Frozen transcripts open unpinned at the top.
- The chip's live tail is REMOVED end to end. Rewriting the header per
  delta batch grew the parent doc's oplog for the whole subagent run and
  rendered as mid-stream markdown fragments. The fold and the engine's
  in-place stamps are lifecycle-only now; the running chip is the static
  task description plus the working spinner. subagent_tail stays in the
  schema (and the row-cache fingerprints) for docs that carry it.
- The affordance row reads "Open subagent".
- The pane transcript gets the top overflow fade (edge_faded, top edge
  only — nothing sits at the pane's bottom), gated on real overflow so a
  short top-anchored transcript shows no fade band.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* shell: jump pills go glass

Both scroll-to-bottom pills (one shared element since the subagent-pane
round) rendered as an opaque raised surface — one of the few non-glass
floating elements left. On glass appearances the pill now frosts at the
composer tier (radius-clipped backdrop blur 16) under the standard
floating-card tint, hover fading the on-glass wash in OVER the tint —
compositing the wash rather than mixing toward it, which would have
thinned the pill see-through on hover. Opaque appearances keep the
raised-surface blend. Frost opens its own scene layer at the pill's
paint position, so blur + pill composite over the scrolling transcript
in both hosts; size, label, motion, and click behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* spaces: reach other drives from the add-project palette

The folder browser only ever started at home, so a project on an
external SSD or second disk meant walking up to / and hunting for the
mount by hand (if you even knew where your platform puts them). The
palette's rail now has a Locations section under Devices: Home plus the
device's mounted drives, served by a new relay-forwardable ListDrives
RPC (macOS /Volumes with the boot-volume symlink resolved; Linux
/proc/mounts under /media, /run/media, /mnt plus a System row for /;
Windows drive letters). Clicking one rebrowses the palette at that
root, the row owning the browsed path carries the selection wash, and
the drive's mount folds into a breadcrumb named after the drive the
same way home folds into the device crumb. Hard-drive + home glyphs
hand-drawn in the Solar Linear style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* subagent tab: every open lands at the end

Opening a tab now lands on the latest content universally, not only for
live tabs. Frozen transcripts arm a one-shot end-landing that fires in
sync on the FIRST non-empty row set — whichever feed delivers it (blob
snapshot or watch replay) and whenever it resolves — via the item-
anchored scroll_to_end (a past-the-end offset the next layout
materializes by walking back from the last item; pixel offsets would
land short on freshly-spliced unmeasured rows). The flag clears on
fire, so later growth never re-asserts and the viewport belongs to the
user immediately. `pinned` stays untouched: a frozen doc that grows
after the landing appends below with no auto-follow — landing at the
end and following it remain distinct states. Short content still rests
top-anchored (the past-end offset clamps to the top under Top
alignment), so the runway behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* subagent iconography: bot glyph for tabs and spawn chips

Hand-drawn bot head (antenna + eyes + ears) in the Solar Linear style —
the embedded set had no robot glyph. The Subagent tab drops the chat
bubble for it, and the spawn chip's icon tile matches (the "Agent"
Unknown convention drew the generic widget glyph, inconsistent with its
own tab). Chat-bubble usages that ARE chat (question chip, comments,
composer) stay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ui: real frosted floats on Linux (wgpu backdrop blur)

Theme::is_frost() separates scene-level frost (floating surfaces
blurring in-app content) from is_glass() window vibrancy: the wgpu
renderer now rasterizes BackdropBlur like Metal does, so Linux gets
frosted popovers/modals and the composer pill without compositor
vibrancy. The window chrome itself stays opaque off macOS (no
blur-behind guarantee outside KWin).

- composer pill + question panel: paint their backdrop blur and
  translucent tint on Linux; drop the opaque shadow slab
- popovers/modals/settings cards: frosted tint over the blur
- macOS unchanged (is_frost == is_glass there)

Requires the gpui rev bump to wingleeio/zed comet/edge-fade-horizontal
with the wgpu BackdropBlur port (PR to that fork); rev points at the
port branch until it merges.

* spaces: enumerate custom mounts and accept typed paths (PR #144 feedback)

Linux drive partitions live wherever the user mounted them — /disk2 is
as much a drive as /media/user/T7. The Linux walk now also takes any
block-device mount at a depth-one non-FHS point (squashfs/erofs images
excluded, so snaps stay out; the system's own split /boot//home//var
partitions are plumbing, not drives). And the browse root was never
meant to be a cage: a query starting with / or ~ now navigates —
typing / jumps to the root and each segment/ descends shell-style,
while a pasted path (or ⏎ on one) jumps in one hop, ~ expanding to the
device's home. A typed path that doesn't exist shows the engine's real
folder error instead of blaming the device's connectivity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* landing: point downloads at v0.2.6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chat2: claim rooms on HTTP first-contact, not just WS join

The /rows GET/POST twins predate the pull-first HTTPS transport and sat
behind an owner gate that only /ws and checkpoint POST could satisfy. A
brand-new chat on a WS-hostile network deadlocked: the sender's HTTP
push 404s (room unclaimed), the host's HTTP pull 404s, and the only
claimants — WS join and the checkpoint heal (gated on a server state
that the 404'd pull can never learn) — never run. Every remote new-chat
send hung on "Sending…" until some WS dial happened to succeed.

First authed toucher now owns the room on any HTTP contact, the same
client-minted-id discipline /ws already applies; foreign users still
get 403.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* grok: subagent visualization over ACP — lifecycle wire events + disk-tailed transcripts

Grok subagents rendered as plain tool calls because the ACP path never
emitted AgentEvent::Subagent: grok's frames carry no parent_tool_use_id
(tagging documented but unimplemented as of 1.0.4), the subagent's interior
transcript never rides the parent's wire, and the lifecycle updates
(subagent_spawned/progress/finished) arrive on grok's extension channel
_x.ai/session_notification, which the harness dropped entirely.

The new SubagentTracker correlates the spawn_subagent tool call (via
_meta["x.ai/tool"]) with its subagent_spawned update — binding through the
subagent_id echoed in the spawn's completion output, falling back to
description-FIFO for sync spawns — then tails the child session's
chat_history.jsonl (located by session id under ~/.grok/sessions, one glob
level to dodge the cwd encoding) into tagged transcript events: reasoning
summaries, assistant text, typed tool calls, tool results.
subagent_finished settles the chip with a tagged Done after a bounded
drain; a torn-down session settles running tails as Interrupted. Every
disk parse fails soft to chip + final-output-only — the format is
vendor-private. Spawn chips are named "Agent: {description}", claude-driver
parity.

Live-validated against grok 1.0.4 (examples/grok_subagent_probe.rs): full
tagged stream — reasoning, text, Exec("echo viz-probe-ok && sleep 3"),
result, Done — attributed to the spawn chip on a real background subagent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(engine): per-harness model allowlist for ListModels (#5)

Trim the picker to ~/.zeron/model-allowlist.json at the RPC seam.
Missing/corrupt/empty config or zero matches pass through so the
catalog never goes empty. Deepseek ids with an extra slash match
deepseek-payg/*.

Co-authored-by: David <davidhsutton@gmail.com>

* feat(ios): create a space from the new-session menu (#6)

The folder browser already existed. This wires New space into the +
menu, adds a typed path (engine-validated via ListFolders), and
clears a stale gitDetected when jumping paths.

Co-authored-by: David <davidhsutton@gmail.com>

* fix(engine): deaf-socket tripwire retries with backoff + sweeps chat rooms (#7)

* fix(engine): deaf-socket tripwire retries with backoff + sweeps chat rooms

The presence tripwire fired one registry redial and disarmed ("one probe +
one redial, ever"), so a redial that landed on another dead socket left the
engine dark forever. Chat2 rooms were never touched at all, so a completed
turn's final message sat undelivered on the phone (2026-08-18 incident,
11:37-11:51 UTC: redial requested, never recovered, summary never arrived).

- keep the tripwire armed while presence stays dark: repeat redials with a
  doubling interval (60s base, 15 min cap), log each attempt and recovery
- new deaf-escalation hook wired to DocHost::probe_open_chats — a probe is
  free on a healthy room; a deaf room misses its probe deadline and redials
  itself (chat rooms ride the same relay as the registry room)

Gate: cargo check --workspace --all-targets OK; zeron-engine --lib 76/76.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtyrsrkwhZsVP7QvPKUGLr

* fix(engine): invoke presence hooks outside reg/room guards + test the ladder

Review finding (K3, CRITICAL): the deaf-escalation hook ran under the
registry `reg` guard (match-scrutinee temporary in publish()) plus the
room/presence_seen guards, while DocHost's open/eviction paths take
`handles` before `reg` — an AB-BA deadlock armed exactly during the long
dark-presence spells the watchdog exists for.

- publish() binds read_all() outside the match so `reg` drops before
  overlay_presence (also moves the pre-existing peer-alive hook out from
  under `reg`)
- check_presence_deafness returns the decision; overlay_presence invokes
  the hook after releasing room/presence_seen, mirroring peer_alive
- the tripwire state machine is now PresenceWatch::tick (pure) with unit
  tests for the probe->redial ladder, doubling backoff + cap, and re-arm

Gate: cargo check --workspace --all-targets OK; zeron-engine --lib 79/79.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtyrsrkwhZsVP7QvPKUGLr

---------

Co-authored-by: David <davidhsutton@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(engine): keep task timer across park/self-continued (ID01-423) (#8)

* fix(engine): keep task timer across park/self-continued resume (ID01-423)

started_at now tracks the user task, not each internal turn. A parked
persistent session keeps the base; self-continued output does not
restamp; user dispatch/steer does. Only a true settle (run handle gone)
clears it.

* fix(engine): don't double-restamp timer on Steered; skip settle if a new run owns the chat

* fix(engine): hold runs lock across settle_status so a new dispatch cannot be clobbered to Idle

---------

Co-authored-by: David <davidhsutton@gmail.com>

* feat(pi): foreground subagents tool (ID01-432) (#9)

* feat(pi): foreground subagents tool (ID01-432)

Versioned copy of ~/.pi/agent/extensions/subagents.ts plus unit tests.
Foreground only; background is ID01-433.

* fix(pi): keep SIGKILL timer referenced so timeout tests settle

* fix(pi): clear SIGKILL timers on settle; cap stdout incrementally

---------

Co-authored-by: David <davidhsutton@gmail.com>

* feat(pi): background subagents + status (ID01-433) (#11)

* feat(pi): background subagents + status (ID01-433)

* fix(pi): per-entry kill + batch token so background reuse stays clean (ID01-433)

killAll no longer sticks a runtime-wide flag that would mark later batches
killed. Sweep only touches entries owned by the same dispatch token.

* fix(pi): close k3 findings on background subagents (ID01-433)

Foreground workers stop spawning after killAll via killEpoch. Pre-aborted
signals reject instead of returning a silent dispatched list. Status
distinguishes queued vs running. Duplicate batch ids fail in foreground.
installSubagents covers steer/nextTurn/shutdown delivery.

* fix(pi): drop dead abort/dup branches after resolveTasks (ID01-433)

---------

Co-authored-by: David <davidhsutton@gmail.com>

* cursor: in-app Connect via the SDK's browser login + accounts integration

The @cursor/sdk conversion (#136) left cursor unusable without a manually
minted CURSOR_API_KEY — the SDK's credentials are separate from
`cursor-agent login`, and the shim's only guidance was an error pointing at
cursor.com/settings. But the SDK ships its own PKCE browser login
(Cursor.auth.login, verified live on 1.0.28): it mints a named, revocable,
90-day user API key and persists it to ~/.cursor/sdk/auth.json.

This drives that flow from the Accounts settings page:

- shim login mode (`node <shim> login <store-path>`): runs Cursor.auth.login
  with openBrowser:false, streaming {"ev":"auth-url"} then "logged-in" /
  "fatal" JSONL; the minted key lands in an engine-chosen store file, never
  the live login. Keys are dashboard-named "zeron — <hostname>".
- agent_accounts grows a Cursor provider: detection reads the SDK's
  StoredSdkCredentials (email + key expiry; expired keys warn and show
  "Key expired" instead of failing runs mysteriously), slots snapshot the
  store file, switching rewrites it — the codex slot machinery, generalized
  (LoginFlow::Codex → LoginFlow::Spawned, shared child wiring). First
  connect on a device with no usable live login auto-activates the minted
  key, so runs work immediately after the browser round-trip.
- Accounts page renders the Cursor card (Connect Cursor, browser-poll
  dialog with cursor-specific copy); run-time auth errors now point at
  Settings → Accounts instead of only the env var. CURSOR_API_KEY still
  wins as the headless override.

Live-validated: the real shim in login mode emits the loginDeepControl URL
immediately and polls Cursor's backend (browser completion covered by the
fake-shim integration test; full round-trip needs a real account login).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* landing: point downloads at v0.2.7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* cursor: live model discovery + typed model options actually applied

The picker showed only Auto + Composer 2.5 — a static catalog from #136,
built on the assumption that Cursor.models.list() required auth. It
doesn't (verified live, logged out, 1.0.28): the endpoint returns the full
catalog — 37 models with typed parameter definitions (thinking, context,
effort, reasoning, fast, Auto's optimize_for), display names, aliases, and
default variants.

- shim models mode (`node <shim> models`): prints the live catalog as one
  JSONL frame.
- CursorHarness::models(): spawns it (15s bound), maps ModelListItem →
  picker Model — parameters become ModelOption choice sets, isDefault
  variants set the default choice, the bare `default` alias twin of the
  parameterized Auto entry is skipped. Successful non-empty catalogs cache
  (OnceCell); failures fall back to a minimal static pair, uncached so the
  next picker open retries.
- Model options now REACH the run: the run frame carries modelOptions and
  the shim folds them into the SDK's ModelSelection params — the old
  static "optimizeFor" choice was picker decoration that never left the
  app. Legacy stored optimizeFor/speed picks translate to
  optimize_for/cost instead of sending ids the backend never knew.

Live-validated end-to-end through the real harness path: 36 models with
options and catalog defaults (examples/cursor_models_probe.rs, kept for
pin-bump revalidation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* landing: point downloads at v0.2.8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* cursor: isolate each SDK run's agent store — concurrent runs no longer lock

"cursor agent failed to start: database is locked" (user report): the SDK's
default local store is SQLite keyed by WORKSPACE (getDefaultSdkStateRoot(cwd)),
designed for one process per workspace. Zeron runs concurrent shims on the
same cwd as a matter of course — a chat turn and its title generation start
together, and two chats can share a worktree — so the second Agent.create
finds index.db locked and dies. Reproduced live: 3 concurrent shims, one
ready, two fatal with exactly the reported error.

A SHARED JSONL store is not the fix — JsonlLocalAgentStore serializes writes
only process-wide and rewrites whole files on update. Instead every run gets
its own JsonlLocalAgentStore directory under ~/.zeron/cursor-state/agents/
(one agent per store, so the backend's small-catalog caveat never applies,
and the native sqlite3 dependency drops out), and a one-writer marker file
(by-agent/<agentId> → dir, atomic tmp+rename) lets Agent.resume from a later
process find the agent's store. Pre-isolation agents have no marker and
resume from the SDK default store exactly as before. ZERON_CURSOR_STATE_DIR
overrides the base for tests.

Live-validated (real SDK, 1.0.28): 4 concurrent creates on one cwd all reach
ready; resume round-trips through the marker in a fresh process; markerless
resume routes to the default store.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.9

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* landing: point downloads at v0.2.9

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(pi): subagents usage/cost + budget (ID01-434) (#12)

* feat(pi): subagents usage/cost + budget (ID01-434)

* feat(pi): subagents usage/cost + budget (ID01-434)

Cada hijo reporta usage (session-dir descartable) y el batch una línea
summary (wall vs cpu, costo o n/d, PAYG, skipped). Tope opcional
PI_SUBAGENTS_MAX_COST_USD. Review k3: path traversal en task.id,
leak de dirs, pid en batchKey, budget re-leído por batch.

---------

Co-authored-by: David <davidhsutton@gmail.com>

* fix(pi): subagents heartbeat + role timeout floors + honest timeout message (#13)

Los hijos corren pi -p (output solo al final): con timeouts bajos elegidos por
el orquestador LLM morian a los 5 min con output vacio, y el silencio >30s
hacia que el engine parkee el turno (chat mudo en la app). Heartbeat cada 20s
via onUpdate (tasks vivas + elapsed), pisos de timeout por rol que clampean el
timeoutMs pedido (implement/hard 900s, review 600s, research/grunt 300s;
deps.timeoutFloorsMs={} para tests) y mensaje de timeout que explica que la
task probablemente seguia trabajando. Hallazgo k3 aplicado: emitUpdate con
try/catch (teardown del host no tumba el batch).

Diagnostico y spec: Linear ID01-434 (comentario 2026-08-18). Review: k3 APROBADO.


Claude-Session: https://claude.ai/code/session_01LWydxxHbUSos6jJr2XfqjL

Co-authored-by: David <davidhsutton@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* opencode: ACP harness with subagent viz off the sidecar event bus

Adds OpenCode (SST's opencode, `opencode acp`) as an ACP harness — the
new-agent recipe end to end: HarnessId variant, AcpAgentSpec (no npm shim:
the opencode-ai package ships a native binary, so the CLI itself is the
ACP server), registry_lazy slot, harness_slug, pixel-"o" brand mark, and
the settings/picker arms. Model discovery rides the existing config-option
probe (verified: the model select lists every configured provider with no
auth); no thought_level option exists, so the effort ladder is empty
(hermes parity). Steering is turn-boundary; loadSession resume works.

Subagent virtualization: opencode's `task` tool call rides the parent
session/update stream, but the child transcript never touches the ACP
wire, and 1.18 moved session storage to SQLite — so no grok-style JSONL
tail. Instead the harness exploits the fact that `opencode acp` doubles as
opencode's HTTP server: runs pass `--port <free>` (the shared default 4096
loses silently under concurrency) and OpencodeTracker tails the `/event`
SSE bus. session.created (parentID + "{description} (@agent subagent)"
title) binds children to task chips ahead of the completion's
authoritative rawOutput.metadata.sessionId; message.part.updated/delta map
to tagged AgentEvent::Subagent traffic (token-level text, typed tool
chips with results); the ACP task completion settles the chip with a
tagged Done after a short drain. Everything fails soft to chip + final
<task_result> output. A normalize arm keeps the "Agent: {description}"
chip naming across the completion frame, which drops rawInput but keeps
the spawn signature in rawOutput.metadata.

Live-validated against opencode 1.18.18 through the real harness path
(examples/opencode_subagent_probe.rs, kept for bump revalidation) with a
mock OpenAI-compatible provider forcing deterministic spawns: single task
and two concurrent tasks both stream fully tagged interior transcripts
attributed to the right chips. Engine needed zero changes (generic
Subagent routing from the native-drivers work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(pi): subagents streaming real via --mode rpc (ID01-461) (#14)

Los hijos pasan de 'pi -p' (output opaco hasta el final) a 'pi --mode rpc':
prompt por stdin JSONL y parseo de los eventos del stdout (text_delta,
toolcall_end, tool_execution_start, message_end, agent_settled). El heartbeat
del padre ahora muestra QUE esta haciendo el hijo, no solo cuanto lleva vivo.
Modo print queda como fallback (deps.childMode / PI_SUBAGENTS_CHILD_MODE=print).

Correcciones de 3 rondas de review adversarial (modelo distinto al implementer):
- stdin sin handler 'error': un EPIPE async tumbaba el pi PADRE.
- cierre: stdin.end() primero + reaper TERM/KILL y resolucion en 'exit' (no mas
  hijos huerfanos fuera de 'live'; usage leido con el jsonl flusheado).
- gracia post-settle solo si el cwd tiene .pi-verify.json (verify-gate reinyecta
  turnos de reparacion): cancela con cualquier evento, 15s, cero costo si no aplica.
- timeout manda sobre el cierre ordenado salvo que el cierre haya empezado antes
  (no reportar exit 0 un task que reventó el tiempo, ni 124 uno que ya termino).
- cap de linea JSONL 1M chars con descarte reportado; buffers de salida acotados
  de forma continua; rpcError transitorio limpiado solo en retry del assistant;
  timers de señales en un Set por task (no mas aliasing en pendingKills).
- si el pi instalado no soporta --mode rpc: hint accionable + stdout crudo.

67/67 tests (incluye reaper sin huerfano, EPIPE del runtime, settle post-abort,
linea gigante, error transitorio, multi-mensaje). E2E real en la Beelink.
Linear: ID01-461. Review: APROBADO.


Claude-Session: https://claude.ai/code/session_01LWydxxHbUSos6jJr2XfqjL

Co-authored-by: David <davidhsutton@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* send: worktree creation rides the durable command plane; relay calls get deadlines

New-worktree sends wedged on "Sending…" forever while the session ran
remotely anyway: the composer's blocking CreateWorktree relay RPC had no
timeout, so a silently lost relay reply (the DO's auto-pong keeps a dead
host socket looking healthy) hung the send task after the remote had
already created the worktree and — via a retry — run the session.

Two fixes, defense in depth:

- RunRequest grows an additive `worktree: Option<WorktreeSpec>`: the HOST
  materializes the worktree at command-drain time, repoints the chat row's
  cwd, and stamps the actual zeron/<name> branch. The composer no longer
  makes ANY relay call on the send path — createChat and QueueCommand are
  local, so a new-worktree send is exactly as durable as a plain send. The
  resolved request (spec taken, cwd = worktree path) is what gets journaled,
  so resumes and steer→new-turn fallbacks reuse the checkout; a duplicate
  spec-carrying Run reuses the chat's existing worktree instead of minting
  another. Old hosts ignore the unknown field and degrade to the main
  checkout instead of failing.

- Relay-forwarded unary calls get a reply deadline (30s interactive, 120s
  CreateWorktree, 15m network git/update). On expiry the peer link is
  invalidated — the zombie socket re-dials — and the caller sees a
  retryable error instead of hanging forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* harness: native drivers rediscover slash commands

The ACP→native cutover (#136) silently dropped slash-command discovery
for claude/codex/cursor: the adapters used to advertise
availableCommands, and the native drivers inherited the trait's empty
default — only ACP-path agents (grok/hermes/pi, now opencode) kept a
populated composer popover.

- claude: short-lived probe sends the `initialize` control request on
  the stream-json channel (the same handshake the Agent SDK's query()
  opens) and reads commands + argument hints out of the
  control_response. No user message is written, so no model turn and no
  API cost (verified live on 2.1.228: 48 commands in ~1s). The probe
  needs `--verbose` — mandatory with --print + stream-json output.
- codex: short-lived `codex app-server` handshake + `skills/list`, the
  only invocable-listing method on the 0.146.x wire (custom
  ~/.codex/prompts are not exposed; verified against the full method
  list). Skills are deduped across cwd groups; the interface's
  shortDescription wins over the model-facing paragraph.
- cursor: documented no-op — @cursor/sdk 1.0.28 has no slash-command
  surface, so the empty default is the honest answer.

Both discoveries cache on success only (ACP parity), time-bound at 10s,
and tear the child down immediately. Fake-CLI tests cover parsing,
dedupe, hint mapping, and caching; live `#[ignore]`d smokes cover the
real binaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.2.10

* landing: point downloads at v0.2.10

* accounts: keep the list order stable — always

Two ways the accounts page reshuffled, both gone:

- Same-millisecond logins (issue #161): the sort key was created_at
  alone, ms-resolution; a tie fell back to read_dir enumeration order,
  which is filesystem-arbitrary for UUID-named slot files — the flaky
  cursor_slot_swap_round_trip flip. New slots now get stamped strictly
  after every sibling (max+1 bump in write_slot), and the sort carries
  the slot id as a deterministic tie-break for pre-existing collided
  files.

- Switching accounts moved the switched-to card to the top: the engine
  deliberately lists in creation order ("never active-first"), but the
  UI's provider_accounts re-sorted active-first, defeating it. The
  re-sort is removed; the Active badge already marks the live account,
  and a list that reshuffles under the click reads as broken.

Fixes #161.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* harness: opencode probe example builds again post-worktree field

PR #159 added RunRequest.worktree and updated every initializer except
the opencode probe example, which landed in parallel via #157 — the
cross-PR race broke `cargo test --workspace` (examples compile there;
no CI job builds them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* transcript: spawn chips link to the subagent tab, not an accordion

The spawn chip's expandable body only repeated the subagent's own
transcript (and its footer 'Open subagent' row was easy to miss). The
chip is now a plain link: clicking anywhere on it opens the subagent
tab, and the trailing tile shows a top-right open-arrow (new
arrow-up-right icon, arrow-up rotated 45°) instead of the accordion
chevron. ChipAffordance collapses to the blob-fetch struct now that
the subagent slot is gone.

ZERON_MOCK_SUBAGENT=1 makes the mock harness fan out two scripted
subagents (tagged AgentEvent::Subagent traffic) — the data-side way to
put spawn chips and their openable docs on screen in the demo rig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* attachments: 10x chunks, parallel window, overall deadline, live progress

An image send could crawl indefinitely on a flapping link while reading
only "Sending…": per-chunk timeouts retry 3x, so chunks that succeed on
attempt 2-of-3 never trip the consecutive-failure abort, and a ~3MB
screenshot was ~70 sequential 45KB relay round trips — hours of lawful
crawling with no feedback and no overall bound (2026-08-18 user report;
text-only sends are all-local and instant, which is why only image sends
looked hung).

- UPLOAD_CHUNK_B64_CHARS 60_000 → 680_000 (~510KB binary per chunk,
  ~35% headroom under Cloudflare's 1MB WebSocket message cap). Chunk
  slot files engine-side are size-agnostic; no engine change.
- Three chunks in flight (seq slots are…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants