aiui v0.5.0 stabilization — Steps 1–4 (multi-window) + /render hotfix#138
Open
iret77 wants to merge 18 commits into
Open
aiui v0.5.0 stabilization — Steps 1–4 (multi-window) + /render hotfix#138iret77 wants to merge 18 commits into
iret77 wants to merge 18 commits into
Conversation
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>
…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>
…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>
This was referenced Jun 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the stabilization plan end-to-end. Per-step implementation records are inline in the plan doc.
What's in
host_should_exit = uninstall/update || !claude_desktop_running. Window-X = hide (I2);ExitRequesteddefault-deny; child counter → telemetry only./rendercancellation-safeRenderGuard(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.kill_remote_mcp_stdio(pkill -f aiui-mcp, blast radius) + all 3 callers. Pin takes effect at next natural spawn. CooperativeWIRE_VERSIONfloor on/version+/probe./render+ parityx-aiui-async(additive, sync path untouched):POST→202 {id},GET /render/{id}long-poll. Both bridges poll + fall back to sync. Python gains cold-start poll, progress notifications,ReadErrorclassification.session · session_origin); Python auto-injects hostname.-R, no~/.ssh/configRemoteForward); aiui already runs the correct dedicatedssh -NTRper remote, adequately hardened (ExitOnForwardFailure+ServerAlive+ shared-forward detection). Piggyback impossible; no refactor needed.Verification
cargo test102 ·clippy -D warningsclean ·pytest26 ·svelte-check0 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