Skip to content

Workspaces 3/10: multiple windows and the transfer protocol - #616

Draft
nedtwigg wants to merge 39 commits into
workspaces-persistfrom
workspaces-multiwindow
Draft

Workspaces 3/10: multiple windows and the transfer protocol#616
nedtwigg wants to merge 39 commits into
workspaces-persistfrom
workspaces-multiwindow

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Several windows over one sidecar: Rust owns PTY→window routing, per-window close, quit voting, macOS terminate interception, the two-phase transfer with arrival queue, and the cross-window drag.

Two fixes folded in: a repeat Cmd+Q during voting no longer clears votes already cast (which wedged the quit), and an arrival the target never adopts is handed back after ARRIVAL_MAX. Also restores lib/src/lib/throttle.ts, which main deleted with its only user; the drag probe is the second.

Based on workspaces-persist.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u

nedtwigg and others added 29 commits September 9, 2026 23:41
The sidecar serves one process, so a second window would have seen every
other window's terminal output. Rust now keeps the PTY-to-window map,
minted only in `pty_spawn`, and every stdout line passes through a pure
`route()` before it is emitted: terminal traffic to its owner, a `pty:list`
to the window that asked, a `dor` request naming an unowned Surface to an
error rather than a sibling, everything else broadcast.

The same state carries the transfer suppression (with a fail-open sweep),
the focus order the drag hit test uses as a z-order stand-in, and a torn-out
window's pulled boot payload. The quit machine becomes vote-then-walk over
per-window state, and a close is now this window's alone unless it is the
last one. `main` is a real label in `tauri.conf.json`, `ws-*` windows share
the default capability, and `main-only.json` keeps the updater grants where
the walk tears down last.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`list` and `gracefulKill` take an optional id set, following the same
"omitted is not empty" rule `interrupt` already carries, and `list` echoes
the window that asked so the host can route the list and every replay
behind it back to it alone. A window tearing down can now kill exactly its
own PTYs.

The Burrow's asks collect until every window has answered (or the budget
runs out) instead of settling on the first: each window sees only its own
Workspaces, so a directory built from one answer would silently omit the
rest. `burrow:windows` pushes the count, and lowering it settles asks a
closed window can no longer answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Closing a window with siblings alive now ends that window alone: it acks,
asks about its own running work, archives its own notes, removes its
snapshot so the next launch does not reopen it, and kills only the PTYs it
owns. Only the last window's close is still a quit.

The quit itself is vote-then-walk. Every window is asked, and only once all
of them agree does Rust tear them down one at a time, `main` last — so a
cancel in the last window can no longer leave earlier ones destroyed. The
non-last windows hand the walk on instead of exiting, and the install stays
in `main`, the one window holding `updater:*`.

The confirmation dialog is shared between the two endings and names the
window by its visible Workspace while more than one is open. The notepad
gate moves to its own module, since both endings run it and a transfer
runs neither.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`disposeSession` splits into one teardown with a `kill` flag, and the new
`releaseSession` takes this webview's half of a Session down while the
process keeps running — the whole basis of a transfer. It is an explicit
handle verb and never an unmount effect: a Wall unmounts on a reload, a
StrictMode double-mount and a Workspace switch, and releasing there would
strand every PTY.

`releaseWorkspaceForTransfer` builds the payload the target restores from,
in the one order that works: serialize with a live cwd probe while the
Sessions still exist, take the notes, then detach. Nothing is archived and
nothing is killed, because a move is not a closure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The source releases the Workspace and hands Rust its record, its notes and
its PTY ids; Rust reassigns ownership synchronously and suppresses those
PTYs until each one's replay has reached the target. The target arms its
collector first and only then calls `adopt_ready`, which is what removes
the whole "arrived before armed" bug class — nothing is listed or replayed
until something is listening for it.

`collectLivePtys` gained a trigger and an id filter so an arrival asks for
exactly the PTYs that just moved instead of the whole Window. A Workspace
created after first render reads its boot plan from a parking store, since
creating it mounts the Wall that needs it. A window whose last Workspace
leaves closes itself: nothing ended, so nothing is confirmed, archived or
killed. A torn-out window pulls its boot payload, because an emit to a
window that does not exist yet is lost.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The strip's tear-out seams are wired up: past the strip edge the host
throttles a cursor probe and lights a caret in whichever window is under
the pointer, and the release either transfers there or tears out into a new
window positioned so the tab lands under the cursor.

A spike settled the one unverified platform assumption — a pointer captured
on a tab keeps delivering `pointermove` and `pointerup` far outside the
window in WKWebView, with client coordinates that run past the edges rather
than clamping — so the gesture stays the webview's and Rust is only asked
where the cursor is. Tauri exposes no z-order, so among stacked windows the
most recently focused wins, and the caret is what makes a wrong guess
visible before the release.

The browser-dev harness gets no window ops: it has no windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Standalone is a multi-webview host now, so a per-window `localStorage`
mirror can no longer be canonical: the WATCHING rule set and the alarm
settings are one per machine. The sidecar runs the very classes the VS Code
extension host runs — first seed wins, edits are deltas, and a renderer's
settings blob is revalidated before it is installed — and broadcasts the
canonical snapshot to every window.

The settings shape, defaults and validation move to a platform-free
`alert-settings-model.ts` so a host can run them without dragging a
renderer's platform in behind them; `alert-settings.ts` re-exports it and
stays the renderer's own mirror.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Two bugs the real app found, both silent.

Tauri delivers an `emit_to` event to any listener registered with the
default `Any` target, which is what the JS `listen()` uses — so every
window received every other window's terminal output, `pty:list`,
Workspace arrivals and teardown order, and the whole routing table was
decoration. A boot with two windows had each one adopting the other's PTYs.
Every listener now names its own window, and a source scan keeps the bare
API to the one wrapper that does it.

`WorkspaceWindow` latched a boot plan per Workspace id forever. A Workspace
dragged out and back mounted from the plan it first booted with, so a fresh
default pane landed over the Sessions that had just arrived and the running
work was lost. A departed Workspace now takes its latched plan with it.

The window lifecycle also logs its transfers, tear-outs and closes, which is
how both of these were found.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
standalone.md gains a Windows section: the label scheme, the routing table
and the rule that every webview listener names its own window, boot with N
windows and the geometry sibling, the per-window close, the transfer and
its suppression window, the tear-out, and the drag between windows. Quit is
rewritten as vote-then-walk with `main` last.

transport.md's governing rule gains the per-window close and the transfer;
its PTY lifecycle gains "Transferring a Workspace" — release-never-dispose,
suppress-until-replay, and omitted-is-not-empty. The glossary gains the
`release` system verb and the two Workspace-moving user verbs, and says the
Tauri label is a Window's persistence identity. notepad.md says a transfer
archives nothing and N windows contend through the archive's own lock;
alert.md says both app-global stores are host-authoritative wherever one
host serves several webviews; auto-update.md says the install runs in the
last window torn down; security-local.md's atomic-write rule covers the
geometry sibling. layout.md's rollout ledger drops multi-window.

The rationale files record the spike that settled pointer capture outside
the window, why `adopt_ready` is a hop, why there is no window-state
plugin, and the Tauri event-target behaviour that made the routing a no-op.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The standalone frontend's tsconfig has no Node types, so the source scan
belongs beside the other config assertions in `scripts/`, not in the vitest
suite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
It is built by `stage:sidecar-proxy` like every other sidecar `.cjs`, and
the ignore list is where those belong.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Every sidecar stdout line passed through three mutexes, a `String` alloc for an
`id` most events do not use, an `Instant::now()` and a suppression sweep whether
or not anything was transferring, and an `append_log` under the routing locks.
Fold the three maps a line is routed against into one `RoutingState` behind one
lock, borrow every label the table hands back rather than cloning it, gate the
sweep on an atomic count of suppressed ids, and take the focus order only for
the `dor` request that has no Surface (`Route::Focused`).

- A `dor` cancel now follows its own request to the window handling it, rather
  than waking every window: Rust remembers which window took each `requestId`
  and forgets it on the response, the cancel, or the window going away.
- Geometry rides a cache fed by the `Moved` / `Resized` payloads themselves, so
  neither the debounced write nor the drag hit test asks the platform for four
  numbers per event; `window_at_cursor` reads that cache instead of cloning
  every webview handle per probe.
- `pty_graceful_kill` and `capture_agent_recovery` drop an `ids` parameter no
  caller passed, and the sidecar's dead `gracefulKillAll` wrapper goes with it.
- `alert_set_watched` + `alert_publish_settings` become one `alert_command`
  (the op is in the payload), and `window_close_proceed` + `close_window_self`
  one `close_window` (Rust's half is identical; the intent is at the JS call
  sites).
- `WindowQuit.tearing_down` was set and never read; `restorable_labels` and
  `quit_order` share `partition_main`; the `closing` refusal is dropped when its
  webview is destroyed and can no longer save.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The quit and the per-window close had grown two copies of the same shape — ack,
ask about running work, archive, act — with their own phase enums, their own
injected `windowName`, and one hand-rolled `Promise.race` that leaked its timer.
They now share `createTeardownFlow`; what is left in each is the step past the
gates, a vote or a teardown.

- The Burrow's N-answer collector is keyed by **which** window answered, not by
  how many have: Rust stamps the sending window's label on every forwarded
  command, `burrow:windows` carries labels, and a window answering twice can no
  longer settle an ask the others have not spoken to.
- One boot-plan store, seeded at first render, replaces the latched copy
  `WorkspaceWindow` kept in a ref and mutated during render — which is what made
  a returning Workspace's plan need evicting by hand.
- `dor list` reports the Window that answered (`window:<label>`), so a caller
  with several windows open gets a ref it can hand back; a bare Wall still says
  `window:1`.
- The strip drag hands the host what it already knows (the release was inside
  its own strip) instead of the host re-querying the DOM for it, tells the host
  when a drag is abandoned so no caret is stranded in another window, and the
  three places that measured the tab strip share one scan.
- The hover probe skips an unmoved pointer, and its dedupe keys on where in the
  target the cursor is, so the target's caret follows the pointer across its
  tabs instead of lighting once.
- `alert-store-host.ts` drops two `as unknown as AlertManager` casts for the
  `Pick<>` targets the host classes actually take, and the unscoped-listener
  guard walks `standalone/src` recursively.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Track the code the two commits before this one changed: the `dor:controlCancel`
routing row and the cancel-follows-its-request rule, the geometry cache behind
both the debounced write and the drag hit test, the ask collector keyed by
answerer rather than by count, the shared teardown flow and the one
`close_window` both endings call, the id-less scoping of `pty_graceful_kill` and
`capture_agent_recovery`, and `window:<label>` as the Window ref `dor list`
reports. Budgets re-ratcheted.

Also lifts the boot-plan store's empty-plan constant above its one reader,
rather than leaving it declared below the function that returns it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The Rust half of the Stage C review fixes.

`route` drops a PTY event no window owns instead of broadcasting it: every PTY
is minted with an owner, so an unowned id is one whose window went away, and a
broadcast rang every sibling's AlertManager for a pane none of them shows.
`Destroyed` reaps whatever the window still owned, which is what the close
ack-timeout path never killed.

`Destroyed` is also the first moment Tauri has taken a label out of
`webview_windows()`, so the quit machine's `forget_window` and the sidecar's
window list both move there — the old post-`destroy()` send told the Burrow a
dead window would answer, and every ask waited its full budget. A user close
arriving mid-walk is prevented rather than ignored, and a quit that runs out of
windows exits instead of going headless.

Arrivals are queued per window rather than emitted: a window still booting, or
torn out moments ago, is a legal drop target and an `emit_to` it is lost. The
source is told of the departure only once the target has asked for it.

Also: the geometry flush releases its slot in the same step as the drain (a
`Moved` landing between the two was dropped) and reads the platform's scale
before taking the `rects` lock, which off the main thread parks on the event
loop the main thread may be driving while it waits for that lock; the routing
lock is released before anything is serialized or emitted; `mint` clears a
stale transfer suppression; `finish_window_close` refuses later saves on both
close paths; the walk ends with `main` if it is open and otherwise with the
most recently focused window, so every window now holds `updater:*`; and a
Burrow ask naming a Surface goes to its owner alone, with Rust telling the
collector so it does not wait out its budget on windows it never reached.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
muda wires `PredefinedMenuItem::quit` straight to AppKit's `terminate:`, and
tao's app delegate implements only `applicationWillTerminate:` — by which point
AppKit has decided and nothing may refuse. So the app menu, its Cmd+Q, the
Dock's Quit item, an `osascript` quit and a logout all ended the process
without the quit flow: no confirmation, no agent-recovery capture, no final
save. Verified in tao-0.35.2's `app_delegate.rs` and muda-0.19.1's
`platform_impl/macos/mod.rs`.

The menu item becomes a custom one whose event calls `request_quit`, and
`applicationShouldTerminate:` is spliced onto the live delegate's class at
`Ready` — cancelling the terminate and starting the flow, then answering "now"
when the flow's own `app.exit(0)` comes back through it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The webview half of the Stage C review fixes.

`prepareWorkspaceTransfer` replaces `releaseWorkspaceForTransfer`: the record
and the notes are built without touching anything, and the Sessions are
detached only once Rust has accepted the move. The target window can close
between the drag's last probe and the drop, and releasing first left a
Workspace with no Sessions and no window that owned them. A pane's helper
Session rides with it now — it is not a member Surface, so nothing else in the
payload named it, and one left behind was a leaked shell plus a stray pane on
the source's next reload.

`collectLivePtys` finishes only on its own answer. Every listener sees every
`pty:list`, so one webview with two collections outstanding — a boot and an
arriving Workspace, or two arrivals — had each finishing on the other's list
and concluding the host held nothing. The token rides the `requestInit` through
to the sidecar's `list` and comes back on the list and each replay. An arrival
that times out now refuses rather than cold-restoring: those shells are still
running, and the restore would start a second set over them.

The quit flow and the per-window close arbitrate over the one dialog. A second
one used to be refused in silence, its context never settled and its host
waiting out a decision that could not come: a quit now takes a confirming close
over, a close met by a quit is refused with `window_close_cancel`, a quit
cancelled elsewhere drops only a quit's dialog, and the store cancels any
context it cannot open. Closing a window holding an approved download says so.

The drag ignores a probe that lands after the release, asks again where the
pointer came to rest, and clears its caret when the pointer comes back over its
own strip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
standalone.md: the arrival queue as its own section; ownership routing drops
and reaps an unowned id; what a window's `Destroyed` settles, and why that is
the first moment it can; the two geometry-flush rules; the transfer's two-phase
release, its helper Session and its refusal on a timed-out arrival; the
teardown arbitration table; the drag's trailing probe, stale answer and
back-inside clear; `Trigger interception` becomes a heading and gains the two
macOS rows; the walk ends with `main` if it is open, else the focused window;
the Burrow's addressed ask.

transport.md: a collection finishes only on its own answer, and a timeout is
not an empty answer; the standalone `alert_command` envelope; release only once
the host has accepted.

auto-update.md: the install runs in whichever window is torn down last, so
every window holds `updater:*`; only `main` checks; closing `main` discards an
approved download. glossary.md and the persistence note stop saying the app has
one window; dor-cli.md's reserved ref is `window:<label>`.

Rationale keyed to each: the broadcast that rang every sibling, the
`webview_windows()` ordering, the two geometry races, the lost arrival, the
tao/muda evidence for the terminate hole, the unsettled confirm context, and
the capability split that enforced a rule the window lifecycle does not
guarantee. Budgets re-ratcheted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Closing a window discards the approved download it is holding, and that is the
one ending that does: a quit installs it. The close asks about it even with
nothing running, and the dialog says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
An arrival is now a record keyed by `workspaceId` — `{ from, to, workspace,
notepad, terminalIds }` — held from the source's invoke until the target adopts
it or dies. Everything the transfer needs reads that one record instead of
inferring itself from the suppression map, which is what let the pieces disagree.

`adopt_ready` names its Workspace and answers with exactly that arrival's ids,
never "everything suppressed for this window": two Workspaces landing in one
window at once — a tear-out with a second tab dropped on it moments later — each
finished on the other's `pty:list` and resumed over its neighbour's shells.
`pty_request_init` excludes every id an arrival claims, so a window booting with
a Workspace already queued for it no longer lists those shells as top-level panes
beside the Workspace about to mount them.

The sweep's fail-open now applies only to suppressions no arrival claims. A cold
boot slower than `AWAITING_REPLAY_MAX` used to have a real arrival's shells
unsilenced into a window that had not resumed them yet.

`adopt_done` and `adopt_failed` are the two ways an arrival ends. `adopt_done`
retires the record, clears what is left of its suppression, and emits
`workspace-departed` to that Workspace's source alone — where the old
`announce_departures` told a source about every Workspace it had sent to that
window. `adopt_failed`, and a target `Destroyed` with an arrival still queued,
hand the shells back to the source unsuppressed and emit
`workspace-arrival-failed`; with both ends gone they are reaped rather than left
owned by a dead label.

`take_arrivals` no longer consumes, so a webview that drains at boot and again
when its listener is installed cannot lose a Workspace to a drain that happened
too early. The `departures` Vec is gone into the record, and the tear-out's
`payload.clone()` with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Granting `updater:*` to every window bought nothing. Only `main` runs the
periodic check, so only `main` can hold a downloaded update — a session whose
`main` was closed has none to install, whichever window the walk ends on. The
grant traded a structural guarantee for a case that cannot arise.

So `capabilities/main-only.json` is back, scoping `updater:default` and
`core:app:allow-version` to `main`; the walk is `main` last again, with the
focused-last variant dropped along with the focus argument `QuitMachine` carried
for it; and `tauri-conf.test.mjs` pins the split rather than its absence. The
close confirmation's "this throws away the downloaded update" warning stays —
that is what a user closing `main` actually needs told.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The source used to release the Workspace the moment Rust accepted the invoke —
Sessions detached, notes dropped, tab gone — leaving nothing to put back if the
target then refused the arrival or closed before taking it. Now the invoke only
marks the Workspace *transferring*: it stays mounted with its Sessions attached,
and the two ends of the arrival record decide what happens next.

`workspace-departed` is the commit, and it names one Workspace: the prepared
transfer is looked up by id and only that one is released, so a source with two
Workspaces in flight into the same window no longer loses both when one lands.
`workspace-arrival-failed` clears the mark and nothing else — the Workspace is
simply still here, its xterms painting again as soon as Rust unsuppresses them.

A transferring Workspace is in no snapshot this Window writes: its shells already
belong to the target, so a quit in the gap would otherwise persist the same
Workspace in two Windows and restore it twice.

On the target, `planArrival` no longer throws into `bootstrap()`. Every failure
becomes an `adopt_failed`, and a torn-out window whose sole arrival could not be
resumed returns null so the caller boots it fresh — a window the user can use
rather than a blank one. `bootFromTearOut` also leaves a window that has a
snapshot to restore itself, instead of treating a drop that landed mid-boot as a
tear-out and installing over its own Window. Arrivals are deduped by id, because
`take_arrivals` no longer consumes and the boot drain and the nudge overlap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
A quit arriving while a close had committed only acked, and never voted. The
quit machine then sat in `Voting` waiting on a window that had decided nothing —
with no dialog anywhere for the user to answer, because the close's own question
was long since past. It votes now: the window is ending either way.

A committed flow can still retreat, though. `archive-failed` puts the notes it
could not store to the user, and declining there cancels the close and leaves
the window standing — with a quit already voted for it and its own question
never asked. The deferred intent is kept and re-driven from that cancel, so the
question is put after all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`timedOut` is the whole difference between "the host holds nothing" and "the
host never answered", and `resumeOrRestoreFrom` reads the second as the first:
it cold-restores, starting a second set of shells over the ones still running.
The arrival path refuses rather than restore; the ordinary boot path had no such
guard, and a launch slower than 500 ms — a cold sidecar behind an antivirus scan
— is exactly when it bites.

`collectLivePtys` now takes `retryTimeoutMs` and asks once more on that budget
when the first wait times out. The Window restore passes it when the saved
Window has terminal panes to lose, `resumeOrRestore` unconditionally: an empty
list still resolves as soon as it arrives, so the retry costs nothing when there
is nothing to say.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`throttleTrailing` is exactly the leading-plus-trailing throttle this had
hand-rolled out of a timestamp, a timer and a re-arming callback; all that is
genuinely local is the in-flight flag, since a probe is an IPC round trip and a
throttle window can close on top of one. The three verbatim gesture-end blocks —
back inside the strip, released, cancelled — collapse into `endGesture`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`standalone.md` → "Arrival queue" now carries the protocol as five numbered steps
over one keyed record, plus the rules that hang off it: nothing released before
adoption, a transferring Workspace in no snapshot and killed by neither end, the
hand-back on refusal, the never-throw boot path, the non-consuming drain, the
sweep's narrowed fail-open, and the boot list's exclusion. "Transfer" keeps what
a move *is* and defers the ordering to it; `Destroyed`, "Per-window close", the
routing table and the quit flow gain their one line each.

`auto-update.md` goes back to `main-only.json` and a walk that ends on `main`,
keeping the close confirmation's discard warning as the thing that session
actually needs. `transport.md` picks up `retryTimeoutMs` and the corrected
"released only once the target has adopted it".

Rationale takes the five-symptoms-one-gap history, the two-phase argument, why
the drain stopped consuming, why the capability grant was reverted, and why a
timed-out boot list is asked again. `standalone.md` and `transport.md` budgets
ratcheted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Carries origin/main (#611, #612) up the stack. releaseSession keeps its
kill:false branch and, like disposeSession on main, releases the renderer,
so a Session handed to another Window leaves no GL context behind.

Restores lib/src/lib/throttle.ts, which main deleted with its only user:
the cross-window drag probe is its second one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u
A second Cmd+Q while voting cleared every window's vote, but a window that
had voted is committed and answers the repeat with an ack alone. With one
sibling still on its dialog, its yes was then never the last vote: the
machine sat in Voting with no dialog left, until someone cancelled. Votes
are now cleared only from Idle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u
A claimed arrival had no deadline in Rust: the replay sweep fails open only
for ids no arrival claims, and Destroyed hands back only when the target
dies. A target alive but wedged left the source marked transferring and its
shells silent for good. begin_arrival now arms a watchdog that, past
ARRIVAL_MAX, retires exactly the record it was armed for and hands it back
like any refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 10, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: b3c12af
Status: ✅  Deploy successful!
Preview URL: https://da1dd212.mouseterm.pages.dev
Branch Preview URL: https://workspaces-multiwindow.mouseterm.pages.dev

View logs

@nedtwigg
nedtwigg added this pull request to stack #624 September 10, 2026 21:44

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback on work in progress, not a merge verdict — mark it ready when you want the full pass.

The arrival record as one keyed transaction, the two-phase release, and vote-then-walk all hold up under tracing, and the test coverage on them is unusually good. What follows is what survived verification. The inline comments carry the six findings with a line to point at; four more are here because they span files or are documentation.

The spec states the invariant that the pty_graceful_kill finding breaks. docs/specs/standalone.md → Quit flow: "A Workspace transferring out is in no window's snapshot and is killed by neither end (§Arrival queue), so a quit mid-transfer restores it exactly once." The §Arrival queue bullet reasons only about the source — "its source kills nothing of it — its shells already belong to the target (pty_graceful_kill is scoped by ownership)" — and that same ownership scoping is what puts them in the target's kill set. Either way that sentence needs to change; "restores it exactly once" is also only true after the target's publishWorkspaceSession, not across the whole gap.

Two more comments assert the updater:* widening that auto-update.rationale.md → "Why updater:* stayed scoped to main" records as reverted. main.tsx: "Installing is every window's, because the quit walk's last window is not always main". window-label.ts: "Installing is not gated on it: the walk ends with the most recently focused window when main has been closed." Both contradict capabilities/main-only.json, and the "most recently focused" half is independently wrong — start_walk calls quit_order(self.windows.keys()) over a HashMap, so the non-main order is arbitrary, and focus_order never reaches the walk at all. WindowState::focused's own doc comment ("most recently focused first, for the quit walk's order") repeats it. The risk is that an editor following these comments re-adds updater:* to default.json and removes the structural guarantee tauri-conf.test.mjs exists to pin.

window_at_cursor calls platform queries while holding GeometryState::rects. The guard(&geometry.rects) temporary lives to the end of the let rects = ...collect(); statement, so window.is_visible() and window.is_minimized() run inside it, ~16×/s during a drag. GeometryState's own doc says "Never call a platform query while holding rectsrefresh_rect … takes no window at all so the rule cannot be broken by accident", and refreshing_a_cached_rect_takes_the_scale_rather_than_the_window pins that. It is safe today only because sync Tauri commands run on the main thread — an unwritten premise, and making this command (async) for the per-probe cost would deadlock it against the geometry flush thread. seed_geometry reads scale_factor() inside the same lock. Hoisting both reads above the guard costs nothing; CachedRect is Copy.

resumeOrRestore now waits 3.5 s before first paint where nothing answers. It passes retryTimeoutMs: LIST_RETRY_MS unconditionally, and FakePtyAdapter.requestInit() is a no-op, so the lib dev server pays 500 ms + 3 s on every boot with lib/src/main.tsx rendering behind the promise. restoreWindow gates the same retry on hasTerminalPanes — a collection with nothing to lose does not need it. Two comments went stale with this: lib/src/main.tsx ("this resolves immediately with no IDs") and vscode-ext/test/webview-boot.smoketest.ts ("self-caps at 500ms when no host answers").

Closing main leaves a fresh empty main in front on the next launch. finish_window_close removes main.json and its geometry, but app.windows[0] in tauri.conf.json recreates the window unconditionally at boot, and restore_windows focuses it last so it comes up in front of the restored ws-* ones. §Boot and geometry says "Boot reopens every window sessions/ names, main first (already up from the config)" without covering the case where sessions/ names no main — and auto-update.rationale.md treats a main-less session as a supported state. Worth either handling or writing down.

Comment thread standalone/src-tauri/src/quit_state.rs
Comment thread standalone/src-tauri/src/quit_state.rs
Comment thread standalone/src-tauri/src/lib.rs
Comment thread standalone/src-tauri/src/lib.rs
Comment thread standalone/src/workspace-move.ts Outdated
Comment thread standalone/src/teardown-flow.ts
Comment thread standalone/src/teardown-flow.ts Outdated
Comment thread standalone/src/quit.ts Outdated
nedtwigg and others added 8 commits September 10, 2026 17:37
A quit trigger over an empty label list parked the machine in Voting with
nothing able to vote, cancel or be forgotten, so every later ExitRequested
was prevented and the app was unquittable short of a force-kill. Reachable
by closing two windows while each other's teardown runs, or when the last
Workspace is dragged out of a window. request now exits at once, as
start_walk and forget_window already did one phase on.

CloseMachine::clear removed the entry, so the next request for that label
started from seq 1 again, the token a watchdog still sleeping on the
cancelled close was holding; on waking it would destroy the window under
the user's second dialog. clear now bumps the seq in place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
… rect lock

begin_arrival moves ownership to the target at the source's invoke, while
the source is still showing the Workspace. A target tearing down before it
adopted therefore SIGTERMed those shells in pty_graceful_kill and Ctrl-C'd
their agents in capture_agent_recovery, and the hand-back at Destroyed
then returned dead shells to the source. Both commands now filter the
window's owned ids through routing::boot_list_ids, the pure helper
pty_request_init already uses for the same reason; its doc and the test
a_boot_list_never_names_an_arrivals_shells now name the kill and interrupt
sets as the other two consumers. The ids are bound before the blocking
sidecar round trip so the arrivals guard is not held across it.

window_at_cursor called is_visible and is_minimized, and seed_geometry
scale_factor, while holding GeometryState::rects, against that struct's own
rule; it was safe only because sync commands run on the main thread. The
rects are copied out first (CachedRect is Copy) and the scale is read before
the insert.

WindowState::focused's doc claimed the quit walk reads focus order; it
never does — quit_order over a HashMap puts main last and the rest in no
order. It now says what focused is for (Route::Focused).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
capabilities/main-only.json scopes updater:* to main, and
auto-update.rationale.md records the widening as reverted, but three
comments still said every window holds it and that the walk ends on the
most recently focused window. Left standing they read as a reason to put
the grant back into default.json and drop the guarantee tauri-conf.test.mjs
pins. They now say what is true: only main checks, only main can install,
and a main-less session has nothing to install.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
…ts flow

rerun() re-enters flow.request(intent) synchronously from inside
holder.abandon(), and the outer request then carried on: it gated the same
intent again, the confirm store refused the second dialog by cancelling it,
and that cancel was quit_cancel for the whole app under the Quit dialog the
rerun had just opened. With no dialog needed the same path archived and
voted twice. The outer request now returns when the flow is no longer idle
after abandoning the holder.

deferredQuit.against was always the other claim, so the quit flow's own
reset never cleared it: a quit cancelled elsewhere survived in the entry
and a later archive-failed decline re-opened a Quit dialog for a quit Rust
had abandoned, whose vote went into an idle machine. The entry now records
the flow that registered it (by), and that flow's reset clears it.

Three cases in teardown-arbiter.test.ts pin the pair.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
A refused adopt_done was only logged. The ARRIVAL_MAX watchdog gives it a
live cause: once it has expired the record and run hand_back_arrival, the
source has cleared its transferring mark and kept the Workspace — but
createWorkspace and setActiveWorkspace already ran here, so the same
Workspace, notes and pane ids were live in two windows, both persisting
it, and the next launch restored it twice over one set of PTYs.

adopt_done is now awaited, and a rejection unwinds the mount the way a
departure leaves the source: the Wall's transfer verb releases every
Session without killing one (the shells are the source's again), the notes
go with it, the Workspace is closed, and its record and parked boot plan
are forgotten (new forgetWorkspaceBootPlan). The test drives the refusal
and checks the store, the snapshot, the plan, the release, and that
nothing was killed or reported as adopt_failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
…lost

resumeOrRestore passed retryTimeoutMs unconditionally, so a host whose
requestInit answers nothing — the lib dev server on FakePtyAdapter, the
VS Code smoke boot — waited 500 ms + 3 s before first paint. The retry
exists to keep live shells from being restored over; a record naming no
terminal pane has none to protect. It is now gated the way restoreWindow
already gates it, the two comments that described the old timing are
corrected, and transport.md states the rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
…ose seq do

Quit flow claimed a transferring Workspace is killed by neither end and
restored exactly once. The first half is true only now that the target's
kill set excludes arriving ids; the second was never true across the whole
gap — a source torn down before its target adopts leaves the Workspace in
no snapshot. The sentence now says what this branch does (at most once,
and from where), without claiming the durability a later stage adds.

Also written down: a trigger that finds no window exits; a cancelled close
never reuses its watchdog seq; the target's kill and interrupt sets exclude
an arrival's ids; a refused adopt_done unwinds the mount; the re-drive
gates once and a quit cancelled elsewhere forgets what it deferred; and a
session naming no main relaunches with a fresh main in front, which is the
current behavior rather than something handled. Budget ratcheted for the
additions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
Carries the review fixes from the window and persist stages up the stack.
The teardown-budget pin follows this stage's rename of the graceful kill
command, and the wedged-write test drives the vote-then-walk flow.

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

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback on work in progress, not a merge verdict — mark it ready when you want the full pass.

The six fixes land: the empty-label exit, the never-reused close seq, the arrival filter on both teardown commands, the hoisted platform reads, the gate-once re-drive, and the by/against split all trace clean, and a_cleared_close_never_hands_its_seq_to_the_next_request plus the two arbiter tests pin exactly the sequences that were broken.

One finding, inline: adopt_done was made awaited-and-unwound in adoptWorkspace but not in bootFromTearOut, which still calls it through settle. The new §Arrival queue bullet states the invariant without qualification — "A refused adopt_done unwinds the mount." — so the spec now covers a path the code doesn't.

Comment thread standalone/src/workspace-move.ts Outdated

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback on work in progress, not a merge verdict — mark it ready when you want the full pass.

The tear-out boot now settles before it installs anything, and its refusal cleanup matches prepareWorkspaceTransfer().commit() surface for surface — notes dropped, helper forgotten ahead of its Session, released and never killed — so the double-persist from the last round is closed on both adopt paths. boots fresh without installing a refused tear-out or retaining its Sessions pins each half of it.

One finding, inline: the second call site makes releaseSession's own doc comment false.

Comment on lines +610 to +613
* **Never reachable from a Wall unmount.** A Wall unmounts on a reload, a
* StrictMode double-mount, and a Workspace switch, and releasing there would
* silently strand every PTY the Window still owns. The only caller is the
* explicit transfer verb on the Wall's handle.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bootFromTearOut's refusal path calls this now too, so "the only caller is the explicit transfer verb on the Wall's handle" is no longer true. The invariant above it still holds — that path has no Wall and no unmount — but the enumeration is what pins the invariant, and left standing it reads as "not the Wall handle, so this call is the mistake." The next editor deletes one or the other.

Suggested change
* **Never reachable from a Wall unmount.** A Wall unmounts on a reload, a
* StrictMode double-mount, and a Workspace switch, and releasing there would
* silently strand every PTY the Window still owns. The only caller is the
* explicit transfer verb on the Wall's handle.
* **Never reachable from a Wall unmount.** A Wall unmounts on a reload, a
* StrictMode double-mount, and a Workspace switch, and releasing there would
* silently strand every PTY the Window still owns. Only a caller that knows the
* ids are another Window's reaches it: the Wall handle's transfer verb, and the
* torn-out boot unwinding an arrival Rust already handed back.

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