Skip to content

aiui v0.5.0 stabilization — Steps 1–4 (multi-window) + /render hotfix#138

Open
iret77 wants to merge 18 commits into
mainfrom
claude/peaceful-germain-0b232e
Open

aiui v0.5.0 stabilization — Steps 1–4 (multi-window) + /render hotfix#138
iret77 wants to merge 18 commits into
mainfrom
claude/peaceful-germain-0b232e

Conversation

@iret77
Copy link
Copy Markdown
Contributor

@iret77 iret77 commented May 30, 2026

Implements the stabilization plan end-to-end. Per-step implementation records are inline in the plan doc.

What's in

Step Summary Status
1 — Host lifetime Single exit authority host_should_exit = uninstall/update || !claude_desktop_running. Window-X = hide (I2); ExitRequested default-deny; child counter → telemetry only.
1.5 — /render cancellation-safe RenderGuard (RAII) frees the registry slot + destroys the window when the handler future is dropped → kills the 409-storm + stranded empty window from the field report.
2 — Never kill remote bridge Deleted kill_remote_mcp_stdio (pkill -f aiui-mcp, blast radius) + all 3 callers. Pin takes effect at next natural spawn. Cooperative WIRE_VERSION floor on /version+/probe.
3 — async /render + parity Opt-in x-aiui-async (additive, sync path untouched): POST202 {id}, GET /render/{id} long-poll. Both bridges poll + fall back to sync. Python gains cold-start poll, progress notifications, ReadError classification.
4 — multi-window Single-occupancy 409 gone; one window per render (label = dialog id) via a pull model (window fetches its own spec on mount → no emit/ack/ready-race). Session chip (session · session_origin); Python auto-injects hostname.
4 — tunnel Settled empirically (Mac-side probe of a live Code-tab session): Claude Desktop provides no reverse forward (no -R, no ~/.ssh/config RemoteForward); aiui already runs the correct dedicated ssh -NTR per remote, adequately hardened (ExitOnForwardFailure + ServerAlive + shared-forward detection). Piggyback impossible; no refactor needed.

Verification

cargo test 102 · clippy -D warnings clean · pytest 26 · svelte-check 0 errors.

Honest limits: Steps 1–3 are logic-heavy and unit-tested (async is additive). Step 4 multi-window is a structural window-system change verified at compile/type/unit level — GUI behaviour needs validation on the Mac (the headless remote can't launch the GUI). The remote-path integration harness remains the open cross-cutting item; it's the right home for validating multi-window. One earlier tunnel inference in the plan was wrong (it read the remote's config, not the Mac owner's) and was corrected with an on-device measurement before any code was touched — no TunnelManager was deleted.

Refs #137

🤖 Generated with Claude Code

iret77 and others added 5 commits May 30, 2026 15:47
Step 1 of the stabilization plan (docs/architecture/stabilization-plan.md):
collapse the host's lifetime onto one predicate and stop deriving "does
anyone still need aiui?" from proxies (child count, window visibility) that
0.4.43–0.4.45 kept getting wrong.

Single exit authority (Invariant I1):
  host_should_exit(explicit, cd_running) = explicit || !cd_running

- lifetime.rs: pure host_should_exit + grace_outcome + ExitAuthority latch,
  all unit-tested (the Step-2 verification mini-harness: host survives a child
  flap while Claude Desktop runs; host exits on Claude-Desktop-quit). The
  shutdown watcher keeps the last-child-disconnect edge as a *trigger only* —
  it arms a short 5s grace (was 60s) and exits solely when Claude Desktop is
  gone and no child returned. One pgrep per edge, no continuous poll.
- lib.rs: setup-window close = prevent_close + hide + Accessory demote (I2),
  never app.exit. RunEvent::ExitRequested = default-deny via host_should_exit;
  the child-count / pending-dialog veto is removed. ExitAuthority managed +
  latched by quit_app.
- http.rs + updater.ts: both update-restart paths latch ExitAuthority before
  restart()/relaunch() so the default-deny gate honours case (c).
- LifetimeStats child counter demoted to /health telemetry + start-trigger;
  it no longer gates exit.

Prove-then-delete mapping for each retired exit path (grace-expired,
setup-close-no-children, exit-requested-no-attached) is recorded in the plan
doc. One deliberate deviation from the spec's prose (arm grace on every edge
vs. "don't arm if CD alive") is documented there — it closes a CD-mid-quit
race while preserving I1 exactly.

Baseline + post-change green: cargo test (104 passed), clippy -D warnings,
svelte-check (0 errors).

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Acute bug from the 2026-05-30 test: after a dialog the next aiui call gets
"409 Conflict — companion busy" repeatedly, and an empty dialog window is
left on screen. Root cause is a cancellation-safety hole in the synchronous
/render handler, independent of the Step-1 host-lifetime work:

  /render registers a dialog, surfaces a window, then parks on
  timeout(DIALOG_TTL=2h, result_rx). The MCP client gives up far sooner — the
  local Rust bridge's reqwest client times out at 300s — and on any
  client-side give-up (timeout, ReadError, tunnel blip, slow dialog) Axum
  drops the handler future. None of the explicit teardown then runs, so the
  registry entry sits pending for the full 2h TTL (every subsequent /render
  → 409) and the already-surfaced window strands empty.

Fix: a RenderGuard (RAII) armed right after try_register and run on *any*
drop, including the future-cancelled path the explicit teardown can't reach.
On drop it cancels the registry entry (frees the slot immediately) and
destroys the dialog window. Disarmed after the normal terminal teardown so
precise behaviour is unchanged; cancel is a no-op once the entry is gone and
destroy_dialog_window is idempotent, so an over-fire is harmless. The
ui_unreachable 503 path now also gets its surfaced window torn down (it
previously left it stranded).

This is a targeted robustness fix, NOT the spec's Step 3 async /render (which
removes the held connection entirely and properly supports long human fills);
noted as such in the plan doc. DIALOG_TTL is deliberately left at 2h — the
leak was the missing drop-cleanup, not the TTL (v0.4.41 raised it for slow
forms on purpose).

Tests: RenderGuard armed-drop frees the slot + delivers a cancelled result;
disarmed-drop leaves the terminal path untouched. cargo test 106 passed,
clippy -D warnings clean.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes the version-forcing remote kills and replaces external enforcement
with a cooperative wire-version floor (stabilization-plan Step 2).

`setup::kill_remote_mcp_stdio` was `ssh … pkill -f 'aiui-mcp'`: a blunt sweep
that crashed live remote sessions mid-call (Claude Code does NOT respawn a
disconnected MCP) and matched *every* aiui-mcp on the host — the remote twin
of the 0.4.42 Cowork-kill, and outright unsafe now that parallel sessions per
remote are a requirement. Deleted, along with all three callers:
  - GUI-startup remote-pin loop (lib.rs)
  - add_remote re-add sweep
  - resync_remote (now re-pin only)
The version pin in ~/.claude.json takes effect at the next natural spawn; a
live session keeps its version until it ends on its own. Deregistration
(remove_remote / uninstall_all) already used config-removal + tunnel-stop, no
kill — unchanged.

Cooperative floor: WIRE_VERSION=1 in http.rs, surfaced on /version + /probe.
The Python bridge's _check_wire_compat reads it once per process and raises a
structured "restart this session" tool error only on a hard wire mismatch;
an absent field is treated as v1 and transient /version read errors are
tolerated (ordinary app-version skew never blocks). The native Rust bridge is
the same binary as the companion, so it needs no floor check.

Tests: cargo test 105 (−1 = deleted kill test), clippy -D warnings clean,
python 21 (4 new wire-compat).

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atible)

Closes the remote ReadError class without disturbing the proven synchronous
path. Async render is opt-in via the `x-aiui-async` header — not a
replacement — so old bridges keep working and WIRE_VERSION stays 1.

Companion (http.rs):
  - POST /render + header → registers, surfaces the dialog, hands resolution
    to a detached task filling an AsyncSlot, returns 202 {id, ttl_secs}.
  - New GET /render/{id}: poll-loop (200ms ticks, bounded by ASYNC_POLL_WINDOW
    = 25s) → terminal result (drained once) / {pending:true} / 404.
  - Without the header the legacy synchronous long-poll runs unchanged.
    resolve_dialog shares resolution + window teardown across both paths;
    resolved-but-uncollected slots swept at DIALOG_TTL.

Both bridges (mcp.rs, server.py): POST with header, then loop GET until
terminal; each GET bounded (40s > server window) so a tunnel/GUI blip costs
one poll, never a multi-minute held connection. Both fall back to the sync
result if the companion answers 200 not 202 (new bridge ↔ old companion).

Python parity (I6): _wait_for_aiui (/ping cold-start poll ~30s, mirrors the
Rust bridge), MCP progress notifications per pending poll (FastMCP
Context.report_progress, best-effort), the async polling loop, and an explicit
httpx.ReadError branch ("tunnel up, Mac not serving") distinct from
ConnectError. The Rust bridge already had cold-start + progress.

Tests: cargo 106 (+1 slot lifecycle), clippy -D warnings clean, python 26
(+5). Not yet exercised end-to-end against a live remote — integration harness
remains the open cross-cutting item.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drops single-occupancy and gives every render its own window, labelled by the
dialog id, so N dialogs (parallel sessions) coexist instead of fighting over
one reused window and 409-ing each other (Invariant I8). The tunnel half of
Step 4 (aiui-dedicated cleanup, Mac-side) is deferred — see the plan doc.

Rather than multiply the fragile emit/ack/ready-handshake per window, this
inverts to a PULL model: the window carries the id as its label and fetches
its own spec on mount, so there's no event-before-listener race to guard and
a large amount of single-window workaround code is removed.

- dialog.rs: try_register (409) → register_dialog (N concurrent, evict-oldest
  only at DIALOG_HARD_CAP). Stores the request (spec + ttl + session +
  session_origin) for pull via get_request. cancel_all removed — per-id cancel
  only (a blunt drain would kill other sessions' live dialogs).
- http.rs: POST /render builds a fresh per-id window (build_dialog_window);
  the emit / dialog_window_ready / ack-timeout / reload-retry / idle-restart
  machinery is gone. Teardown, RenderGuard and resolve_dialog are per-id.
- lib.rs: get_dialog_spec command; per-id destroy_dialog_window; X-close
  cancels only the closed window's dialog; orphan-sweep + Accessory demote are
  multi-window aware. Removed dialog_received / dialog_window_ready commands and
  the ready watch channel.
- Frontend DialogShell.svelte: reads its window label (= id), pulls the spec,
  and shows a fixed top-right session chip (session · session_origin), hidden
  when neither is set. No more dialog:show listener / ack round-trip.
- Bridges: `session` tool param on both (mcp.rs, server.py); the Python bridge
  auto-injects socket.gethostname() as session_origin (I8 fallback for remotes
  sharing :7777).

Tests: cargo 102, clippy -D warnings clean, python 26, svelte-check 0 errors.
GUI behaviour is NOT verifiable from the headless remote — needs validation on
the Mac (the integration harness is the right home for it).

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@iret77 iret77 changed the title fix: host-lifetime invariant — single exit authority (Step 1, v0.5.0) aiui v0.5.0 stabilization — Steps 1–4 (multi-window) + /render hotfix May 30, 2026
iret77 and others added 13 commits May 30, 2026 20:17
…ack impossible

Fixes a wrong inference in the Step-4 planning note. A read-only probe of a
live Claude-Desktop Code-tab session, run on the Mac (client side), settles
the tunnel question with measurement instead of assumption:

- Claude Desktop spawns /usr/bin/ssh WITHOUT -R on the CLI, WITHOUT a custom
  -F config, and `ssh -G <host>` resolves no `remoteforward` — there is no
  RemoteForward in ~/.ssh/config. CD provides NO reverse forward.
- aiui already runs the dedicated path: live `ssh -N -T -R 7777:localhost:7777
  … <host>` per registered remote, parented by aiui.app, working.

Therefore: piggyback is impossible (nothing to ride), and aiui-dedicated is
both correct and already implemented — no refactor needed. The earlier note
claimed CD provided the forward and floated deleting the TunnelManager; that
read the *remote's* aiui config (irrelevant — the Mac owns the tunnels) and
would have broken all remote dialogs. The existing tunnel is adequately
hardened (ExitOnForwardFailure + ServerAlive 30/3 + shared-forward detection +
orphan-sweep); the original ReadError driver (Mac HTTP down) is closed by
Step 1. The verified-/probe health polish was assessed and declined as
marginal overhead. Step 4 is complete.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Auto-discovering Claude Desktop's Code-tab SSH connections was my speculation,
not grounded in any known capability — Claude Desktop doesn't expose those
connections to a third-party app, and the only route (scraping running ssh
processes) is a fragile hack. Removed as a phantom TODO. Manual remote
registration in aiui's settings stays documented as current, intended
behaviour. The remaining non-code residual is the remote-path integration test
harness (optional, highest-value follow-up).

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…) + Stufe 2 design

Establishes that the harness driver runs from the remote against the REAL
companion over the existing reverse tunnel (localhost:7777 + pushed token) —
not a simulated one. This is the layer where every whack-a-mole bug lived and
which had zero automated coverage.

Stufe 1 (done): python/tests/test_integration_live.py — read-only smoke
(/ping, /health, /version, /probe, 401-on-bad-token, unknown render-id). No
dialogs pop. Opt-in via AIUI_LIVE=1, so the normal pytest run and CI skip it
(26 passed, 6 skipped). Version-tolerant. Verified live: 6 passed against the
installed companion (v0.4.45) over the tunnel.

Stufe 2 (design): docs/architecture/integration-harness.md — render-path +
window-lifecycle via a strictly test-gated companion hook (POST /test/answer,
hidden-window test mode, window-label reporting) so the full render→answer→
teardown cycle, no-409 concurrency, and cancellation-safety can be driven from
the remote with no human and no screen-spam. Preconditions stated honestly:
the v0.5.0 build must run on the Mac to validate this PR's behaviour, and the
test hook must be reviewed to confirm it can't activate in production.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The free-text input for the "Andere Antwort" option was nested inside the
option's <button>. In WebKit the Space key activates the nearest ancestor
button, so every space typed fired the toggle (otherActive → false), removed
the {#if otherActive} input, and stole focus — the user had to re-click the
field after each space.

Fix: the text field is now a SIBLING of a plain toggle button, never nested
inside it (valid HTML — interactive content can't live in a <button>). No
keydown stopPropagation (that would have swallowed Escape-to-cancel from the
field); the structural change alone removes the root cause. Card look
preserved via the surrounding .option.

svelte-check: 0 errors.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Aligns the Python bridge package version with the companion (Cargo.toml /
tauri.conf.json are already 0.5.0) and with the wire contract. Required for a
0.5.0 release: scripts/release.sh asserts the built wheel matches the release
version. No publish here — just the version string.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lags

Enables a validate-first delivery: build + high5-sign + notarize + a GitHub
*pre-release* (which `/releases/latest/` skips, so no client auto-updates)
WITHOUT the permanent PyPI publish. Promote later via
`gh release edit <tag> --prerelease=false` + `uv publish`. Both flags default
off — normal `release.sh <version>` behaviour is unchanged.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…never matched

The companion is on axum 0.7.9, where path params are `:id`, not `{id}` (that's
axum 0.8). `.route("/render/{id}", …)` was therefore a *literal* segment that
never matched a real id → axum's default empty-body 404 → the async render's
result-retrieval was broken end-to-end (POST returned 202, every GET 404'd),
breaking both bridges' async path. The dialog window itself was unaffected (it
pulls its spec via the `get_dialog_spec` Tauri command, not this HTTP route).

Fix: `/render/:id`. Also tightened the live smoke test to assert the 404 body
is `unknown_render_id` (i.e. the route matched our handler) rather than
accepting any 404 — a status-only check is exactly what let this ship in the
first 0.5.0 pre-release. Caught by firing a real render against the pre-release
(the integration harness doing its job).

cargo test 102, clippy -D warnings clean.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Carries the async GET /render route fix (0622033) into a fresh build. 0.5.0
was a pre-release only (never promoted to latest / PyPI); 0.5.1 supersedes it.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…after dialog close (v0.5.2)

Two issues from on-device validation (2026-05-31):

1. Multiple dialogs opened exactly on top of each other (all `.center()`ed,
   same size) — dangerous, you can't tell them apart. build_dialog_window now
   offsets each additional window by 28pt × the count of OTHER dialog windows
   currently open, wrapped at 8 steps. Keyed on the live open-count (not a
   monotonic counter), so closing a window frees its slot, the first/only
   dialog always opens centered, and they never march off-screen over a
   session.

2. Closing a dialog made the setup window pop up. macOS fires RunEvent::Reopen
   as a side-effect of a dialog window closing, and the handler unconditionally
   called show_settings_window. Now it records each dialog teardown
   (mark_dialog_teardown) and the Reopen handler only surfaces settings when no
   dialog was torn down in the last 1.5s — i.e. a genuine user reactivation,
   not a close side-effect. The orphan-sweep still runs unconditionally.

cargo test 102, clippy -D warnings clean. GUI behaviour needs on-device
validation (headless remote can't see it).

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g UI)

The session identifier chip was position:fixed top-right, so it floated over
the dialog's own header/content and overlapped UI elements (2026-05-31
report). It's now an in-flow strip at the top of a flex-column root
(.dialog-root → .session-bar + .dialog-body); the widget fills the remaining
height (its .window-shell is height:100%, and #app/body are height:100%, so
the chain resolves). Hidden when no session/origin is set, so a normal local
dialog is unchanged.

svelte-check: 0 errors. GUI layout needs on-device validation.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hip (v0.5.3)

Per on-device feedback (2026-05-31): the session info belongs in the window
title bar, not floating in the dialog's work area where it crowded the
content. DialogShell now sets the native window title via
`getCurrentWindow().setTitle("aiui — <session> · <origin>")` on mount and
renders the widget directly again — the in-flow chip + its flex-column
restructure are removed entirely. Cleaner, OS-native, and structurally
incapable of overlapping content. Falls back to "aiui" when no session is set.

svelte-check 0 errors. Title-bar text needs on-device confirmation.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sion-gated) (v0.5.4)

0.5.3 moved session identity to the native title bar via the frontend
`getCurrentWindow().setTitle()`, but Tauri v2 gates that behind a
`core:window:set-title` capability we don't grant — so the call was silently
denied and the bar still read "aiui". Now the title is set in Rust, where no
capability is needed: build_dialog_window takes a `title` and the /render
handler computes "aiui — <session> · <origin>" before the fields move into the
registry. Frontend setTitle removed.

clippy -D warnings clean, svelte-check 0 errors. Title text needs on-device
confirmation.

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Have to click twice" report (2026-05-31): macOS consumes the first click on a
non-key window just to focus it. The old single-reused-window path called
set_focus(); the per-id multi-window rewrite dropped it, so every fresh dialog
needed a focusing click first. build_dialog_window now builds with
`.focused(true)` and calls `win.set_focus()` after positioning. If a stolen-
focus edge persists, the bulletproof fix is acceptsFirstMouse via objc2 — to be
escalated only if needed.

clippy -D warnings clean. Needs on-device confirmation (can't see GUI from the
remote).

Refs #137

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant