Commit Graph
38 Commits
Author SHA1 Message Date
l0ng-ai 44ce34a978 refactor(tree-sync): one definition of the panes a window answers for
`settle_census` and `sweep_parked` each built `parked ∪ spawned` from the
state, separately. I introduced that duplication myself when I added the
first of them earlier on this branch, by copying the shape of the second.

They are not interchangeable readings. One decides whether the tree is
worth pulling, the other which panes to end — so drift means either
ending a pane the census never weighed, or pulling for one the sweep will
not judge. `census_of` is now the single answer to "what is this window
still holding".

Which also made the gap visible. Mutating the census showed the `spawned`
half held by nothing: `the_census_holds_the_panes_this_window_made` read
`s.spawned` straight off the state, so it proved the set was filled in
and never that the decision reads it. Dropping `spawned` from the census
left the test green while a pane spawned and not yet in the tree went
back to being invisible to the sweep — which is the whole of the stray
shell leak that set exists to close.

It asserts through `census_of` now, on both halves, and each fails when
dropped. `stranded_of`'s two conditions were already held.
2026-08-23 21:27:23 +08:00
l0ng-ai c4950bd3ba style: run rustfmt over the branch
CI runs `cargo fmt --check` and I had not run it once across this branch,
while making most edits by inserting text rather than writing it. 27 files
were non-conformant; `origin/main` is clean, so all of it is mine and CI
would have failed on the first push.

No behaviour change — the suite is identical either side of it. Also checked
clippy the way CI does, `--locked --workspace --all-targets -D warnings`,
which is stricter than the invocation I had been using.
2026-08-23 13:48:17 +08:00
l0ng-ai cb44751cdb fix(switcher): keep live panes off the list that offers to close them
The switcher's orphan section asked one question — is this pane alive, and
does the local machine mirror hold it — and put everything else under a
button that hangs it up.

The mirror is the wrong sole authority for that, and `tree_sync` says why in
as many words: a pane a window has spawned and registered but not yet filed
is held by nothing in the tree at that instant, and a client is left out of
the deltas its own operations raise (#612). `hang_up_detached` and
`sweep_parked` both refuse to end a pane any window is showing, whatever the
tree says, because a leaked shell is recoverable and a shell killed under a
live window is not. This list did the same thing to the same panes and never
asked.

So it asks now. A window that will not answer subtracts nothing, which keeps
the list at worst what it has always been — it is the recovery tool for the
case where things have already gone wrong, so it must not empty itself when
a window is busy. And it can only ever shrink: a genuine stray is shown by
no window, so nothing that belongs on the list leaves it.
2026-08-23 11:19:39 +08:00
l0ng-ai 5b77b51a28 fix(tree-sync): collect the shells a window strands, from a census of its own panes
Closing a tab straight after making it stranded a live shell — six times out
of six, reproducibly. It holds a pty and an fd and runs until the machine
does not. A person cannot type that fast; an agent loop that opens a tab per
task and closes it when the task is done meets it on nearly every iteration,
which is the shape this product is built for.

The rule for judging a pane was already here and already right: one that no
window is showing and no workspace on the machine names is nobody's. Two
earlier attempts failed because of what was put in front of it. Both parked
panes at the *site* that dropped them, and the leak does not live at a site:
a hydration whose whole session is discarded never compares a before against
an after, so its panes were never offered for judgement at all.

So the window now keeps a census of every pane it has brought into existence,
taken at the one point both spawn routes meet, and judges the census rather
than a list of suspects.

The second half is when. The sweep ran only on a landing that rebuilt from a
full machine tree, and the failures do not lead there — a refused operation
calls `desync`, which primes, and a prime pulls this workspace's mirror
alone. Instrumented, the sweep did not run once across three cycles that
stranded three shells. It now also runs after a prime, and pays for the
machine-wide pull only when a censused pane is not on screen anywhere;
while everything the window made is still showing, that is one set
comparison and no request.

Measured against a live window, same reproducer, same machine:

  before   6 create-and-close cycles    6 stranded shells
  after   16 create-and-close cycles    0

and nothing else was harmed: four unrelated panes kept their ids and their
scrollback across ten churn cycles and a GUI restart, which is the failure
this has to be judged on — a leaked shell is recoverable with `pane close
--orphans`, a shell killed under a live window is not.
2026-08-23 09:29:33 +08:00
l0ng-ai e91e45f129 docs(tree-sync): a second dead end, and what the two have in common
Tried the nearest of the two silent paths: parking the seeded panes of the
queued operations `desync` is about to discard. It looks the most tractable of
anything left, because an op that never left this process provably never filed
its pane, so those are stranded by construction.

It is not a fix. It fires rarely — 0 to 2 panes across ten create/close cycles
— and does not reliably reap even those, because the sweep needs a hydration
to settle and a parked pane can outlive the window's next few pulls. One run
parked two panes and one of them was still an orphan at the end. Totals: 4, 4,
5 against a baseline of 5, 5, 5, which is inside the noise.

Backed out, and recorded next to the first attempt, because what the two have
in common is the useful part. Both park at a *site*. The leak does not live at
one — this comment already said so, and two experiments now agree with it: a
sweep phrased against any particular failure keeps missing whichever path was
not instrumented. It has to be phrased against the end state, over every pane
this window spawned, which is what the paragraph above describes and what
nobody has built yet.

No behaviour change.
2026-08-23 08:27:41 +08:00
l0ng-ai 6635f94f58 docs(tree-sync): bound the orphan leak — it is an agent loop, not a fuzz
The total was already recorded; the shape matters more, and it is not volume.
Measured against a live window:

    tab new / split / send, paced or as fast as the CLI will go   0 orphans
    tab new then tab close, 1s apart, x4                          0 orphans
    tab new then tab close, no pause,  x6                         5 orphans

The last reproduced exactly — 5 of 6, three runs in a row. What strands a
shell is closing something before the window has finished reconciling the
thing that made it, and the gap that matters is under a second.

That reframes the cost. A person cannot type that fast, so nobody driving the
window by hand meets this. An agent does it by default: a loop that opens a
tab per task and closes it when the task is done leaks a live shell on nearly
every iteration, and that is the shape this product is built for.

The strays are visible — `pane ls --all`, the switcher, and `doctor` since
this afternoon — and `pane close --orphans` ends them. Nothing ends them
automatically, which is what the end-state sweep this comment asks for would
do. Recorded in the changelog under Known, because a user running an
orchestration loop should be told rather than left to find it in `pane ls`.
2026-08-23 08:18:59 +08:00
l0ng-ai 96b7597497 docs(tree-sync): record what the orphan leak measures, and one dead end
The comment here already has the right analysis: panes stranded by a refused
operation are the minority path, the other two throw their panes away without
a refusal, and the sweep belongs after a hydration settles rather than at any
one failure. Two things it did not say, both now measured.

The scale and the severity. 160 CLI operations against a live window left 17
orphans, and every one was a live `zsh` — not a stale record — each holding a
pty and its descriptors. `tty7 pane close --orphans` ended all 17, so the
documented recovery does work.

And a dead end, written down so nobody spends the evening on it twice. Parking
the pane here rather than only warning — handing it to the `sweep_parked` that
already exists, which judges against the whole machine's freshly pulled tree
and spares anything a live view shows — is safe, and does not measurably help.
Four runs: of the panes that reach this arm, 33–60% were still orphaned at the
end without it and 43–50% with it. They are mostly already caught by the sweep
the layout rewrite arms. The leak lives in the two paths that never refuse an
operation, exactly where this comment says the fix belongs.

No behaviour change; the experiment was backed out.
2026-08-23 08:05:58 +08:00
l0ng-ai 3b5df0b6b5 fix(tree-sync): adopting a workspace is not creating one (#716)
`chosen_name` is the name a user typed for a workspace a window is about
to create. It travels with the create rather than following it, because a
rename sent before the workspace exists is answered `NotFound`.

When the create came back without that name, `settle_chosen_name` sent it
as a rename. Its own comment gave two reasons the create might not have
run — the other create of this window's pair won the race, or the
workspace was already there — and treated them the same. They are not the
same. The first is this window finishing its own job. The second is
renaming somebody else's workspace.

#716 is the second one from the far end: a client opened a workspace on
another machine that already held nineteen live panes, and the workspace
came back named after the connecting client's local user, because that
side spent a codename it had rolled for a workspace it thought it was
making.

A workspace this window's sibling create just made is empty, so the two
cases separate on whether the workspace holds tabs — and the existing
arbitration tests all pull an empty mirror, so they are exactly the case
that still renames.

The name is still consumed when the rename is declined. It was owed once,
and adopting the workspace is how it stops being owed; parking it would
only fire the rename at the next pull.

This is the naming half of that report. The tab tree it also lost is not
addressed here.
2026-08-22 22:10:05 +08:00
l0ng-ai 791d0d0cfa Merge remote-tracking branch 'origin/main' into polish/ralph-wc
# Conflicts:
#	README.md
#	README.zh-CN.md
#	crates/tty7-cli/src/cli.rs
#	crates/tty7-cli/src/server.rs
#	crates/tty7-core/src/core/config.rs
#	crates/tty7-core/src/core/git/status.rs
#	crates/tty7-core/src/daemon/install/wsl.rs
#	crates/tty7-core/src/daemon/protocol.rs
#	crates/tty7-core/src/daemon/spawn.rs
#	crates/tty7-core/src/daemon/ssh/mod.rs
#	src/terminal/completion.rs
#	src/terminal/remote.rs
#	src/ui/app.rs
#	src/ui/i18n/en.rs
#	src/ui/i18n/ja.rs
#	src/ui/i18n/zh.rs
#	src/ui/tree_sync.rs
2026-08-22 16:48:33 +08:00
webdev f44b667639 fix(restart): fail a silent Attach, and hold the tabs a rebuild could not put up (#673) (#681)
A restart on nightly 26.8.4 came back with every restored coding-agent
pane locked: Ctrl-Z printed its suspended message and never returned to
a shell, Ctrl-C did nothing, no keystroke reached anything (#673). Its
sibling — a restart after an upgrade that came back to an empty
workspace (#672) — was mostly closed by #554 and #579; what is left of it
is closed here too, because both are the same mistake, a restart's
rebuild reporting a success it did not have.

The locked panes are an `Attach` the client took on trust.
`attach_reply_prefix` reads far enough into the daemon's reply to tell an
`Error` frame from a replay, and a read that timed out with nothing in
the buffer fell through to the success branch: silence was read as "a
quiet pane". But a quiet pane is never silent. `attach_subscriber`
replays the pane's ring before the daemon reads a byte of our input, the
ring always holds a segment (`ReplayRing::new` starts with one and every
path that empties it puts one back), and every daemon build there has
been queues a `Size` and then a `Snapshot` first — a pane that has
printed nothing still answers with its geometry. So an `Attach` that
produced no bytes in the whole wait is one nobody is serving: a daemon
still mid-restart, or a socket some process holds open and will never
read. Taken for an attach, it made `spawn_shell_terminal_in` report
`restored = true`, the flag that skips the fresh spawn, the
restored-screen banner and the agent's `--resume`; and `write` threw
every encode error away, so the keystrokes, Ctrl-C and Ctrl-Z all went
into that socket and vanished. Zero bytes is now the failure it is, and
the caller falls through to the path it already had for a pane that is
gone — a fresh shell under the old screen, with the resume typed.
Nothing changes on the wire.

That silence has a second reading, though, and only one of the two is
safe to act on. A daemon merely slow to serve — an execve handoff keeps
the listener and its backlog across the exec, and a fresh daemon adopts
its panes and seeds ids before it takes an Attach — would have served
the connection a moment later, and a fresh pane spawned over that live
one carries its history across (`history::carry` is written for a dead
pane) and starts the agent's resume against a session the old process
still holds. So a silent local Attach is confirmed before it is acted
on: the client asks the daemon `Version` on a fresh connection, which a
daemon answers before it touches any state. Answered, the daemon is up
and serving and the attach socket is one it will never serve — the
verdict stands. Unanswered too, nobody is serving yet; there is no third
path from a synchronous UI-thread call, so the attach still fails, but
the error and the log line say which silence it was rather than
claiming the pane is gone, since that is the line someone reads while
diagnosing an orphaned shell. Only local routes probe: a remote attach
already waits fifteen seconds and a second routed connection is a
second bridge process. The two-second local budget is unchanged — only
a silent connection ever pays it, and N silent panes hold the window
still for N of them.

`write` also stops swallowing the link refusing input. The first refusal
is logged once from the writing side, and unless the reader was retired
for a relink the pane is marked exited by the reader's own signal —
`exited_flag`, then the `Exit` event — since it is the same socket, only
found dead from the writing side first; the reader still raises its own
when it gets there, and the handler is idempotent. A retired link stays
quiet, for the reason the retired reader does. This is hardening for a
closed link, not the cure for #673 — a socket held open and never read
accepts writes into its buffer, and nothing here fires; the attach
change is what keeps that pane from existing.

The tabs that did not come back are the rebuild's licence outrunning
what it rebuilt. `tabs_from_session` drops any tab none of whose panes
would start; `settle_hydration` then marked the window `informed` as long
as *some* tab rebuilt, while the mirror it had just installed still
listed every tab the machine holds. The next `sync_window` ran at
`SyncScope::Full`, and `diff` at that scope emits `TabClose` for every
mirror tab not in `desired` — which the dropped tabs were not, and `held`
did not cover them: it only covers tabs on screen whose panes cannot be
represented. A partial rebuild deleted from the machine exactly the tabs
it had failed to rebuild, panes and all.

They are held now, rather than the licence withheld. `settle_rebuild`
records the wanted ids the window is not showing (`not_rebuilt`), and
`sync_window` carries them into `held`, whose contract in `diff` is
already "mirror tabs the window cannot speak for — close nothing, and
do not reorder around them". Withholding the licence would have been
the smaller change, and it is what the none-rebuilt case does, but it
takes `TabClose` away from the whole window for as long as the failure
stands, and a failure can stand across every restart (a tab whose shell
is no longer on the machine): every close the user made in the meantime
would come back on the next rebuild. Holding only the tabs that failed
leaves the window speaking for the ones it did put up. The set is
rewritten by the next rebuild and pruned against the mirror on every
sync, so a tab the machine lets go of stops being held. The none-rebuilt
guard is unchanged: a window that put nothing up still does not speak
for the workspace at all.

Two things about the held set said out loud. It reads the count of tabs
the tree asked for, not the ids it found: `tree_id` is not serialized,
so a session that reached this path from disk would name no ids, and
"no ids" must not read as "no tabs wanted" — that would hand the licence
to a window that rebuilt nothing, which is #672 again. And holding has a
cost with no retry: `diff` stops before its reorder pass and the
active-tab op whenever anything is held, and nothing rewrites the set
but the next rebuild — a re-prime and an `IfEmpty` hydrate on a
populated window never get there — so a tab that fails to rebuild holds
the window's tab order and active tab off the machine until the next
restart. That state was already reachable, since a pane whose remote
spawn failed stays connecting for the same span, held the same way; this
widens a standing hole rather than opening one, and a retry, or a way to
close a held tab from the window, is separate work.
2026-08-19 14:24:12 +08:00
l0ng-ai f828e9ce22 fix: reunite three more doc comments with the functions they describe
The same defect as the previous commit, found by sweeping for it: a doc
block holding one item's summary followed by another's, with no blank
line between, so rustdoc attributes the whole thing to whichever item
happens to follow and the other is left undocumented.

  daemon/pane.rs   "The pane's screen, capped for storage, with the mark"
                   describes `scrollback_snapshot`, which returns exactly
                   that pair and had no documentation. It was sitting on
                   `scrollback_mark`, in front of that function's own.
  ui/tree_sync.rs  "This workspace's layout, and the name the machine has
                   for it" was on `settle_chosen_name`, which settles a
                   name and returns one. `git log -S` puts it above the
                   signature returning `(WsMirror, Option<String>)` --
                   `pull_or_create` -- so it was moved, not guessed.
  core/ssh_config  The "does this alias still resolve" paragraph, with the
                   four-times-a-second cost that explains the cache, was
                   on `struct AliasCache` rather than on
                   `alias_still_resolves`.

A fourth hit was not this: `file_tree_reveal_path` really does own both
of its paragraphs, and only wanted the blank `///` that separates them --
without it rustdoc runs "use file_tree_show when the tree *is* the
answer" straight into "returns whether the tree could hold it".

Nothing catches any of this. A fused block is well-formed rustdoc, so
`-D warnings` passes; the only symptom is a function with no docs and a
neighbour with somebody else's.
2026-08-16 12:27:04 +08:00
l0ng-ai 43961cae20 docs: restore seven invariants that the comment strip took out
#268 ("strip every comment from the Rust sources") removed 38,164
comment lines. Much has been rewritten since, but 7,587 blocks still sit
in front of code that is unchanged and now undocumented. Last commit I
started implementing a pair of deliberate no-op stubs before `git log -L`
turned up the stripped comment saying they were deliberate — which is
the failure mode this class of loss produces.

Seven restored here, chosen for documenting why something must not
change rather than what it does:

- `LinkShutdown` — "not optional politeness; without it a client cannot
  be closed". A reader parked in a blocking read is not woken by any
  flag, so closing has to act on the fd, and no std trait spans the
  transports. Nothing else says this.
- `peek_frame_kind` / `is_error_kind` — why Attach is classified before
  the payload is paid for.
- `FontFeatures` — a frozen config key deliberately replicating
  gpui's type so tty7-core parses without linking gpui.
- `desired_tabs` / `every_leaf_is_native_ssh` — held vs
  permanently-invisible tabs, and why conflating them either deletes a
  daemon tab mid-revival or freezes a window's ordering forever.
- `control_for` / `TreeLink` / `classify_tree_link` — local and remote
  links unified, and unserved as a fact about the peer rather than a
  transient down.

Restored against the current code, not verbatim: three claims had gone
stale and are corrected. `Duplex` has since adopted `LinkShutdown`
(`Halves` carries one) rather than needing to; `peek_frame_kind` has two
callers now, not one; and `gpui_font_features` moved out of `ui::app`.
2026-08-16 05:28:01 +08:00
l0ng-ai 74d647e5ff refactor(tree_sync): make the sweep's decision assertable
Ending a pane was one function: work out which parked panes nothing holds,
then hang them up. Only the second half needs a running daemon, and mixing
them meant the rule could only be checked by watching a fuzz run --  which
swings from fifty operations to five between runs on the same seed. That is
how a change of mine looked effective for five iterations while doing
nothing at all.

`stranded_of` is now the rule on its own, and a test states it directly: a
pane on screen is never ended whatever the tree says, a pane the tree still
names is never ended whatever the window shows, and only one held by neither
goes. Each of those three has been the difference between a leaked shell and
a killed one at some point in this file's history.

No behaviour change -- same filter, same order, called from the same place.
Verified the test earns its keep: dropping the `showing` check from the
filter fails it with `left: [1, 4], right: [4]`.
2026-08-16 00:07:58 +08:00
l0ng-ai 976bd36566 fix(tree_sync): ask every window before ending a pane, not just one
Both hang-up paths guarded on "the window that raised this is not showing
the pane". The answer they are guarding is the daemon's, and the daemon's is
machine-wide: `collect_orphan_panes` names any registered pane no workspace
holds, whichever window spawned it.

So with two windows on one machine, the second could spawn a pane, have it
registered, and still have its TabCreate queued -- and a close raised by the
first would see it named as detached, find the *first* window is not showing
it, and end it. A pane the other window is about to draw.

The same mistake as judging a pane against a tree that has not caught up,
one window over. Now the guard is the union over every open window, and a
window that will not answer abandons the sweep rather than shrinking it: one
that cannot be read might be showing anything.

Found by re-reading my own two commits rather than by a failure -- the fuzz
that verified them drives a single window, so it could not have caught this.
2026-08-15 23:02:04 +08:00
l0ng-ai 489c394837 fix(tree_sync): end the shells a layout rewrite leaves behind
The window drops a view whenever its layout is rewritten -- a delta arriving
for a tab it had built differently, a rebuild from the tree -- and dropping a
view does not end the shell behind it, because that is also what detaching
is. So every reshape left panes running that nothing could reach again. This
is where most of them came from: a 150-op fuzz against an open window shows
31 layout rewrites to 2 rebuilds and 12 refused ops.

Panes put down this way are *parked*, not ended, and judged later. At the
moment of the drop neither mirror can say whether a pane is still somebody's:
the machine copy is missing whatever this window has just done, since a
client is left out of the deltas its own ops raise (#612), and this
workspace's copy runs ahead of or behind ops still in flight. Judging there
does end live panes -- both a machine-mirror check and a two-mirror check
were tried, and the fuzz caught each of them killing panes the tree still
named. The parked set is swept only against a tree the window has just
pulled, which is the one account of the machine here not assembled from
deltas, and only for panes the window is also not showing. Anything that
came back into the tree or onto the screen meanwhile is forgotten rather
than ended, because that is exactly what the mirrors were wrong about.

Over six fuzz seeds: no run ends a pane the tree still names, and where a
pull lands during the run the stranded shells go from 15-26 to 0-2. A run
with no pull sweeps nothing and leaks as before -- the parking is correct,
the coverage is only as good as how often the window pulls -- and
`tty7 pane close --orphans` still reclaims whatever is left.
2026-08-15 21:40:26 +08:00
l0ng-ai 1b75500b88 fix(tree_sync): hang up the panes the tree hands back
TabClose and PaneClose answer with the panes the tree let go of, and the
shells behind them keep running until somebody hangs them up. The daemon
will not: the same removal is how a pane crosses to another tab, so it
cannot tell an ending from a move, and whoever sent the op has to say.

`tty7 pane close` always has -- hang_up_removed_panes exists for exactly
this. The window did it only where a *person* closed something, and never
for the identical ops its own reconciliation raises, because `pump` sent the
batch with `if let Err(e) = client.call(op)` and dropped the Ok on the
floor. Every pane the window put down while squaring its layout with the
tree left a live shell nothing could reach.

Measured with a 150-op tree fuzz driving the CLI against an open window:
5-6 shells per run were being stranded this way, and are not any more.
It is not the whole leak -- around 14 orphans per run survive, from the
paths ITER 13 named (a superseded hydration, a delta that would not apply),
which strand panes without sending an op at all.

A pane the window is showing is never hung up, whatever the tree says. The
daemon computes its answer with collect_orphan_panes across the whole
machine *after* the change, so a pane that merely moved is never named --
but a pane this window has spawned and registered and not yet placed in the
tree is, because at that instant nothing in the tree holds it, and its
TabCreate may still be queued behind the op that produced the answer.
Ending that one takes down a pane the user is looking at (#628). If the
window cannot be read at all, nothing is hung up: a leaked shell is
recoverable with `tty7 pane close --orphans`, a shell ended under a live
window is not.

The check runs on the main thread after the batch returns rather than
beside the calls, because `pump` may be running inside an App that already
holds the window entity, and gpui answers that by silently dropping the
update -- which stops the window syncing at all.
2026-08-15 21:23:31 +08:00
l0ng-ai d76ed6df50 diag(tree-sync): correct what the strand-a-pane note claims, after measuring it
The note said refused tree operations are how this window strands a shell.
They are one way, and the minority one — which matters, because it is the way
that would attract a fix.

Measured with a randomized tree fuzz over the CLI (70 operations: tab new,
split, pane close, tab close, tab move, invariants checked after each):

  daemon only, no GUI     0 orphaned shells
  same run, window open   16 orphaned shells, 12 panes actually held either way

A second run of 70 left 10 orphans, and the warning this note sits on fired for
3 of them. The other 7 were spawned for a layout the window then threw away
without sending an operation at all — the log shows "dropping a superseded
hydration" and a delta that "did not apply cleanly" forcing a re-pull. Nothing
is refused on those paths, so nothing reaches this arm.

They are live `zsh` processes, not bookkeeping: each orphan answers
`tty7 procs` with a pid.

So the note now says which share this path accounts for, and that the sweep has
to be phrased against the end state — a pane this window spawned that neither
the machine tree nor any live view holds is stranded however it got there —
rather than against any single failure, which would fix a third of it.

Still diagnosis only, for the reason already recorded: the sweep belongs after
a hydration settles, and verifying it needs a GUI this environment cannot
watch.

2949 tests pass.
2026-08-15 18:22:59 +08:00
l0ng-ai 23bbe7d400 diag(tree-sync): name the pane a refused tree operation strands
`TabCreate`, `PaneSplit` and `PaneReplace` each carry a pane the window has
already spawned — the shell is running on the machine before the tree is told
about it. When the tree refuses one of those, nothing ever takes the pane and
the re-pull leaves the window without the tab it was for, so the shell goes on
running with nothing referencing it. The log said only that an operation was
refused, which is the one thing that does not point at the leak.

Reproducible against a running instance: with a window open on a workspace,
`tty7 tab new` and `tty7 tab close` back to back. The GUI restores the new tab,
finds its pane already hung up, spawns a replacement, and `PaneReplace` is
refused because the tab is gone. Four cycles in five leak a shell; leave half a
second between the two and none do. Killing the GUI and repeating the loop
leaks nothing — the CLI half is correct throughout.

Not swept here, and the comment says why: at the point of the refusal the
window still holds a view for the pane and only drops it once the re-pull
lands, so hanging it up here would kill a pane that is still on screen. The
sweep belongs after the pull settles and has to test the whole machine tree
rather than this workspace's mirror, since a pane belonging to another
workspace on the same host is not this window's to end. That change wants a
GUI it can be watched in; this one only makes the leak say its own name, and
`tty7 pane ls --all` already points at the recovery.

`seeded_pane` is pinned by a test, because the set of requests that name a pane
into existence is exactly the set a refusal can strand one from — a new one
added without it would leak silently.

2933 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-ai c957231142 chore(lint): put clippy on the CI gate, and clear the ~200 findings behind it
CI checked `cargo fmt --check` and the build, so nothing ever read the
content of the code — ~200 clippy findings had accumulated, a third of them
in `#[cfg(test)]` modules.

Two of them were real:

- `resolved_background_appearance` took `backdrop`, and only the
  `#[cfg(windows)]` arm used it. Renaming it to `_backdrop` is what clippy
  asks for and compiles cleanly on macOS; on Windows it is an undefined
  name. Kept the parameter and discharged it in the non-Windows arm the way
  `package_for_current_install` already does.
- `SettingsSearchBackdropKeywords` and seven other `L10nKey` variants were
  carrying translations in three languages for strings nothing reads.

The rest is mechanical: let-chains for collapsible `if let`s, struct-update
syntax for `Default::default()` reassignment, `sort_by_key` where a manual
reversed comparator was doing the same job the same file already did with
`Reverse` two functions later.

Where clippy was wrong, the reason is now in the tree rather than rediscovered:
per-platform `#[cfg]` blocks each keep their `return` (dropping it only
compiles on whichever target's block lands last), the loopback parser keeps
three parallel arms instead of folding one into a `?`, and the four wide enums
are all built on the stack and consumed immediately, so boxing them would add
an allocation rather than save one.

`L10nKey` cannot be clean under `dead_code` on any single platform, so the
allow there records how to audit it instead — which is how the eight dead keys
were found.

`english()` gained the cross-platform test its doc comment already claimed;
only the Windows hint had been pinned.

The clippy job is non-required until it has green history, for the same
branch-protection reason `host-boundary` documents.

2930 tests pass.
2026-08-15 18:11:17 +08:00
l0ng-aiandl0ng-ai 9fc0f331e8 feat(tabs): drag a tab in as a pane, and a pane out as a tab (#651)
* feat(tabs): drag a tab in as a pane, and a pane out as a tab

A tab dragged by its chip or its sidebar row can be dropped over the
panes to become one of them, and a pane dragged by its grip can be
dropped on the strip or the sidebar to become a tab of its own. Both
carry the panes across as they are: nothing is spawned and nothing is
killed, so a shell mid-command, an SSH session or an agent mid-turn
keeps running.

The landing is read the way a pane drag's already is, minus the middle:
an arriving tab has nothing here to trade places with, so a pane's core
means "split it the way it is longest". A tab that was itself split
arrives with its own shape intact and takes one share of the row or
column it joined. A pane on its way out is offered a caret between two
tabs, and the last pane in a tab is offered nothing, being a tab of its
own already.

Picking a tab up no longer switches to it: the strip and the sidebar
now activate on the click rather than on the press. Without that the
merge cannot be expressed at all — pressing the tab to drag it would
put it on screen, leaving no other tab to drop it into.

Two things in the machine tree had to follow:

* Panes that change tabs are told as PaneMove, one at a time, rather
  than as a tab closing and another being rebuilt around them.
* The tabs the machine already has are reconciled before new ones are
  created, so a pane leaving for a tab of its own is given up by the
  old tab before the new one asks to register it. The machine refuses a
  pane that is in two tabs at once, and the refusal desynced the window.

Closes #621

* test(tree-sync): a tab grafted above a whole layout still converges

* fix(tabs): keep a click on the close button from switching tabs

Switching on the release rather than the press means every click inside
a chip or a sidebar row now reaches the row itself, and gpui-component's
`Button` does not stop propagation on a click it handled. So one click on
a tab's close button ran `close_tab(i)` and then `activate(i)` — with `i`
by then naming whichever tab had slid into that slot, which moved the
active tab somewhere nobody asked for. A click into the rename field did
the same: it switched away from the tab whose name was being typed, and
took the focus out of the field with it.

Both now hold the click where they handled it, the way they already held
the press.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-15 17:05:34 +08:00
webdev 7fded5afd4 fix(tree-sync): record the panes a window seeded in its own mirror (#628)
A window's mirror of its machine held no `PaneRecord` for a pane the window itself created. Records ride inside layout deltas and a client is left out of the deltas its own ops raise, so the record the daemon mints when it registers a seed reached every window but the one showing the pane; `PaneFacts` closed the gap only when a fact changed, and a pane spawned into its directory and left at its prompt never changes one. The workspace answered no subject path, an unnamed one read "Untitled", and `record_geometry` stamped a null subject over the path views.json remembered.

The window knows what it seeded, so it now puts those records into its own mirror through the same `PaneSeed::into_record` the daemon mints with — opened up rather than duplicated, so ssh-secret stripping stays shared — with the window's own Ready terminals standing in for the daemon's liveness probe. The write is insert-only: a record the mirror already holds came from the machine and outranks what a seed knows.

Also closes the race that reopened the same symptom by another route: a `MachineGet` already in flight installed its tree whole and took the client's not-yet-acknowledged writes with it, and nothing re-inserted them until the next non-empty op. All four optimistic writers now go through one path that keeps each write for the life of a pull in flight and replays it over the tree that lands. The tree stays authoritative for everything it speaks about — only the ops it was built too early to know are put back on top, and the journal is drained once it has landed, so nothing a later tree dropped is resurrected. This covers #604's pushed tabs and workspace ops, not only the seeded records.

Fixes #612.
2026-08-14 16:17:07 +08:00
l0ng-ai e781bfddc6 fix(workspace): let a new workspace keep the name it was given, and cover the abort #618 fixed (#619)
A name typed into the create form was sent as `WorkspaceRename` the moment
the window switched — before the workspace existed on the machine to be
renamed. The machine answered `NotFound`, `unsendable` logged it at debug
and dropped it, and the create that ran afterwards named the workspace
whatever codename it rolled. Nothing replays it: the next sync diffs tabs,
not names. It flashed on screen first, because `fire_workspace_op` notes the
op in the mirror before sending it, so the name appeared and then reverted.

The name now travels with the create instead of chasing it. It is parked on
the window's sync state by `name_new_workspace` and spent by whichever
create runs — `pull_workspace`, which is the one `switch_workspace` actually
reaches, and `pull_or_create`, which races it (both create when the tree
they read did not hold the workspace yet, as the `Err` arm of
`pull_workspace` already described). Both read it inside their spawned task
rather than before it: a window orders its pull first and is named second,
so anything read earlier is still empty.

`settle_chosen_name` arbitrates against what the machine answers. A name it
read back was spent by the create. One it did not means the create never ran
— the workspace was already there — so it goes out as the rename it has
become. A window that chose no name still reads whatever the machine says,
which is what #604 fixed.

Also covers the abort #618 fixed a commit ago. That fix is right and is left
as it is; it landed without a test, and `tabs_on_screen` opening with
`if !cx.has_global::<WindowRegistry>()` is why the whole suite passed over
the read below it — no test installs a registry. The test here installs one,
which is what `WindowRegistry::register` is no longer private for, and fails
with an abort against the code as it stood before #618.

Six tests, each confirmed to fail without the change it guards.

Not verified end to end: there is no fake control client in the tree, so
what `WorkspaceCreate` carries over the wire is covered by reasoning and
unit tests only.
2026-08-13 19:56:32 +08:00
ARNO 1d0b648f66 fix(tree-sync): stop workspace switches from rereading the app mid-update (#618)
fmt
2026-08-13 19:27:33 +08:00
l0ng-aiandl0ng-ai 70ac7f201f fix(tree-sync): tell a window the name the machine gave the workspace it made (#604) (#613)
A workspace a window creates is created with a generated name, and that
name is what `tty7 ws ls` prints and what `tty7 ws rename` addresses. The
window itself was never told it. A client is left out of the deltas its own
ops raise, so the WorkspaceCreated delta carrying the name never comes back,
and both create paths threw away the copy the reply carried: `pull_or_create`
reduced the answered workspace to its tabs, and `pull_workspace` returned the
tree it had read before the create, which does not hold the workspace at all.

The mirror therefore held the workspace unnamed, the chip fell back to the
directory its shells started in, and the GUI and the CLI gave two different
answers to what the workspace was called — a user could read `verify-main`
on screen and not address it by that name. The first pull of the whole tree,
which a daemon restart, a rebuild and a plain relaunch all do, then produced
the name it had had all along and looked like a rename that stuck.

Both paths now carry the name they were answered with: the prime hands it to
the mirror, and the hydrating create puts the workspace it made into the tree
about to be installed. A workspace the machine really has no name for still
reads the directory it is working in, and a chosen name still wins.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 12:55:18 +08:00
l0ng-aiandl0ng-ai 84d6fc223a fix(tree-sync): stop an abandoned Replace debt deleting the tabs it was pulling (#579) (#608)
`take_rehydrate` dropped an owed `Adopt::Replace` whenever the window had
any tabs, on the reading that the user had filled it in themselves. That
reading only ever fitted the one caller it was written for: Restart
Server, which empties the window before it resyncs. Every other
`Replace` — a daemon back as a new process, a restart handoff that was
refused, a remote server restarted, a layout delta that would not apply —
is ordered over a window that still holds its tabs, because those stale
tabs are the whole reason it was asked. The retry was abandoned on its
first attempt, every time, and the resync silently did nothing.

Abandoning it also left the window `informed`: still licensed to prune a
workspace whose layout it had never read. `start_prime` then refilled the
mirror from the machine, and the next `SyncScope::Full` diffed whatever
the window happened to hold against it — one tab opened over an emptied
window became `TabClose` for every tab on the machine, deleting those
panes' records while their shells kept running, with nothing tree-driven
left that could reach them. The damage #554 describes, through a door
#554 did not close, and the same on a remote window.

So the debt is now scoped to the layout it was owed over: `hydrate`
records the tabs on screen as it orders a pull, and only a tab that debt
never saw counts as the user moving on. And a `Replace` takes back the
`informed` licence up front, the way `on_preempted` does, so a window
waiting on a rebuild adds to its machine without pruning it until the
pull lands — however the debt ends.

Pre-existing since #472 (af3928d, e4bd49c); both PR #564 and #569 saw it
and left it alone because the rule is shared with the remote resyncs.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 12:02:20 +08:00
Hongwei Qinandl0ng-ai 1987766a14 fix(daemon): rebuild the window from the tree when a restart handoff fails (#554) (#564)
* fix(daemon): rebuild the window from the tree when a restart handoff fails (#554)

Restart Server clears the window's tabs before attempting the handoff, and
the Err branch then only showed a reason on the home page. A refused
handoff leaves the daemon exactly as it was — still serving the panes the
window just dropped — but every restore path is tree-driven, and the next
sync of the emptied window diffed into "close every tab" against the
mirror: pane records deleted under shells still running, or the whole
workspace removed if the user simply closed the window first, right after
the dialog promised nothing would be interrupted.

The Err branch now runs the same invalidate + resync the Ok branch runs,
pulled out as tree_sync::resync_after_local_daemon_change. Where the
daemon really is gone (an exec that never re-listened), the pull misses
and the rehydration debt is what keeps the empty window from being pushed
up as the layout. The failure is also toasted, since a successful resync
takes the window off the home page where the reason was shown.

* test(tree-sync): anchor the emptied window giving up its mirror

The fix for #554 rests on one property that nothing asserted: after a
restart that failed, the window must stop speaking for the machine. An
emptied window still `informed` over a `Primed` mirror is the shape that
does the damage — the next sync diffs it into "close every tab", and
closing it authorizes a `WorkspaceRemove` outright, while every shell it
named is still running on a daemon that never went anywhere.

The handoff itself is UI-plus-daemon integration and stays uncovered, but
the guard is not: the pull drops the mirror, clears the ops queued off the
emptied window, and takes the workspace back out of disposable range, all
synchronously and before any link is needed. Pinned on
`resync_window_from_tree`, the per-window step both of the helpers above it
work through — which of them a caller picks decides whether the link is
dropped first and which windows are walked, not this.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 10:22:20 +08:00
Hongwei Qinandl0ng-ai 557bfd3f3a fix(ui): resync local windows when the daemon comes back as a new process (#553) (#569)
* fix(ui): resync local windows when the daemon comes back as a new process (#553)

From the client's side a killed daemon is indistinguishable from one
whose shells all exited at once — its DeathReporter says nothing while
it shuts down, and a taskkill says nothing ever — so the window kept
showing every pane with its last title, and the reconnect then pushed
that dead layout back up as the new daemon's truth. On Windows a
force-killed daemon can even leave the shells themselves alive but
permanently unreachable.

The control handshake already carries an instance id per server
process, and the remote path already compared it (server_restarted);
the local link never did. Now LocalLink remembers the hello instance —
across invalidate(), deliberately, or the restart path's own first move
would blind the comparison — and a changed instance on reconnect routes
every local window through resync_after_local_daemon_change instead of
on_link_up: the dead link is dropped first, then hydrate rebuilds from
the machine tree and each pane comes back from its scrollback snapshot
with the "new shell" banner.

note_instance moves to tree_sync as the shared comparison (empty
instance = server predates the field, never a restart, never
overwrites), with the remote callers and their tests repointed. The
restart-server action now uses the same helper instead of open-coding
invalidate + resync, which also fixes it rebuilding only the current
window when several local windows are open.

Per the review, the title wording ("process exited" while the link was
down) and the missing Respawn menu item are a separate PR: the current
wording is pinned by a_dropped_link_does_not_claim_the_process_exited
and changing it is its own decision.

* docs(changelog): entry for the local-daemon reconnect resync (#553)

* fix(ui): keep the reconnect's own link when the daemon came back new

The reconnect installs the new daemon's client and then, on a changed
instance, called the helper that starts by dropping it. The link the
handshake had just proved good was thrown away, every window's pull went
out with nothing to send it on, and the layout had to wait for the next
tick to connect a second time.

That is not only wasted work. `hydrate` gives the link fifteen seconds to
come back and then owes the window a `Replace` — and a `Replace` is
abandoned the moment the window has tabs, which in this scenario it
always does: the dead ones still on screen are the whole bug. So a slow
second connect turned the resync into nothing at all, silently.

Split the helper: `resync_after_local_daemon_change` still invalidates
first, for the restart-server path whose link really does point at a
server it killed, and `resync_local_windows_from_tree` is the half the
reconnect wants, with the fresh link left in place.

The two note_instance tests that moved here with the function now say
what tree_sync's own tests say; the one that is this module's — the slot
is per host — stays.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 09:05:04 +08:00
l0ng-aiandl0ng-ai e4bd49c39c fix(launch): restore the layout when a launch names a directory (#529)
"Open in tty7" from Explorer, and `tty7 <PATH>`, skipped the session
restore whenever no window was already up — `restore_session` said
nothing about it, the condition was simply "an explicit path was given".
The folder arrived as a lone blank terminal in a brand-new workspace and
the previous tabs were left behind: still running on the server, still
`open: true` in views.json, and reachable only through the switcher.

With a window already up the same menu entry had always just added a tab
to it, so one entry had two meanings depending on whether the GUI
happened to be running. Both shapes now restore first and open the
folder as one more tab.

The folder cannot be handed to the new window as its first terminal:
`Adopt::IfEmpty` declines to adopt into a window that already has a tab,
and the pull would then push that one tab back as the whole workspace —
writing the layout it was restoring off the machine. So it travels with
the hydration and is opened by whichever attempt settles it.

A path still declines to follow the layout onto a remote workspace and
starts a fresh local one there, which is the case the old blanket skip
was really guarding: the directory it names is a path on this computer.

Fixes #527

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 23:28:53 +08:00
l0ng-aiandl0ng-ai af3928da1e fix(tree-sync): pay back a remote window's owed tree pull (#472)
* fix(tree-sync): pay back a remote window's owed tree pull

A window opening onto a remote workspace is empty until `hydrate` pulls
the machine's tree and rebuilds its tabs from it, and it has to be: an
empty window diffs into "close every tab", so `sync_window` holds
anything back until the pull lands. When the pull fails,
`owe_rehydration` records the debt and returns, on the promise in its own
doc comment that the next sync settles it — "which is what a reconnect
does through `on_link_up`".

`on_link_up` is called for `HostId::LOCAL` and nowhere else. On a remote
host the debt was only ever settled by a reconnect completing, by an edit
in the window, or by restarting the app. So a pull that failed while the
link stayed up was never noticed again: no reconnect, and an empty window
has nothing in it to edit. The window sat on the home page with every tab
and every shell still on the machine, and only a restart brought them
back.

Two ways to fail a pull with a healthy link, both routine. A `MachineGet`
can overrun its ten seconds on a slow link. And a `WorkspaceCreate` can
lose its race with `start_prime`, which runs the same create from the
other side of the same window opening — that one fires on every remote
workspace opened, and is only invisible because the workspace it usually
lands on is empty anyway.

So: arm a backed-off retry when the debt is taken on, drive it through
`sync_window` where the rules about whether a window may still adopt the
machine's layout already live, and stop treating a lost create as a
failure — read the tree again and hydrate from what is really there.
`on_link_up` is also wired to a remote link coming up, which is what the
comment always claimed: a link the switcher connects finishes no attempt,
so nothing told its windows the machine could be reached.

* fix(tree-sync): end the backoff with the run of failures, and stop shouting

Review follow-ups on the owed-pull retry.

The attempt count paces the retry, so it has to mean "failures in a row",
but it was only cleared when a hydration landed. A debt abandoned rather
than paid — a `Replace` dropped because the user filled the window in
themselves — and a prime that landed both left it standing, so the next
first failure waited the 30s cap on an outage that was already over. It
is now cleared wherever the run ends.

A window left open on a machine that is really gone retries forever by
design, which meant a warn and an info every ~45s for as long as it stayed
open. Once the backoff settles at its cap those lines stop being events
and become a fact about the machine, so they step down to debug. The retry
is exactly as persistent; only the volume drops.

Also: report the create's own refusal when the reread finds the workspace
still missing, and say at debug that the reread happened at all — the race
recovery was silent, so the extra round trip was invisible when reading a
log. And correct the comment on the window-gone guard: closing a window
drops its whole `WsState` through `forget`, debt and all, so nothing is
parked for the next opener.

Tests: the count ends with the run at all three sites, the level steps
down at the settle point, and the armed retry is driven through a real
timer (advance_clock) into the window-gone guard — the first coverage of
the retry actually firing rather than of the predicate it consults.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 23:47:29 +08:00
l0ng-ai c138be687a fix(daemon): keep a pane's shell and its screen across a restart
Two things a pane lost when the background service stopped and started,
both of them things the tree was the only possible place to keep.

**The shell.** `PaneRecord` and `PaneSeed` carried a pane's cwd, its ssh
spec and its agent, but never what it was running. A window rebuilding a
dead pane from the tree therefore had nothing to pass and spawned on
whatever the default shell is now — so a restart turned a bash pane into
a PowerShell one, quietly and in place. The daemon resolves the override
against the config at spawn time and is the only party that knows the
answer, so it keeps it and reports it; the seed carries it too, for the
panes a window spawned itself. A handoff carries it in the blob, because
nothing on the far side of an `execve` can work out the command line of
a child it never spawned.

**The screen.** The startup sweep ran before the endpoint was listening,
which is the one moment nothing can answer the question it asks: the
registry is empty and the windows that know which screens are still
wanted cannot say so yet. A tree that failed to parse made it worse —
`read_machine` quarantines it and returns an empty `Machine`, so one bad
file took every pane's stored screen with it. The sweep now happens only
on the periodic pass, a tick later, with the registry filled in and the
tree caught up; nothing is serving a request in between. Turning the
setting *off* still clears the directory at once, because there the
promptness is the whole promise.

Two smaller ones alongside it: `restorable_pane_ids` now counts the
tree's pane list and not only the panes some tab currently stands on —
the two disagree while a window is between layouts, and being wrong
costs a file swept a tick late in one direction and somebody's terminal
in the other. And `restored_screen` drops the snapshot file *after*
deciding it was not empty, so a snapshot holding nothing is no longer
consumed by the request it could not answer.

The restore path had no end-to-end test, which is how this shipped: the
unit tests cover the file, not whether a window that reattaches is shown
anything. The new one runs a real daemon, puts a marker on a real pane,
stops the daemon, starts another, and reads the wire.
2026-08-10 16:06:54 +08:00
l0ng-aiandl0ng-ai 86799220ca feat(splits): rearrange a tab's panes by dragging one onto the layout (#445)
* feat(splits): rearrange a tab's panes by dragging one onto the layout

Hovering a pane floats a small grip along its top edge; dragging it picks
the pane up and puts it somewhere else in the same tab. Three landings,
resolved from where the pointer is:

* a pane's edge — split that pane and take the side dropped on
* a pane's middle — trade the two panes' places
* the band along the outside of the tab — sit beside everything else as a
  full-width or full-height band, which is the only way to say "make this
  a full-height column" in one gesture from the middle of a 2x2

The landing is highlighted while the drag is in flight, and is offered
only once the tree agrees the drop changes something, so the highlight is
never a promise the drop does not keep.

* pane: move_leaf / move_leaf_to_edge / swap_leaves, each built on a clone
  and installed only when the layout really differs
* pane_drag: the pointer-to-landing geometry, the drag state, and the grip
* tree_sync: reconcile a tab that kept its panes but changed shape with a
  single PaneMove instead of closing and rebuilding the tab

* feat(splits): drop a pane beside its neighbours, not on top of one

Trying the drag out on real layouts turned up three ways the drop model
asked for more precision than it should have.

A drop on a pane's side always halved that pane, so putting a new column
into a row of columns was only reachable at the very edge of the window,
where the band rule took over. A side facing a neighbour in the same row
or column now joins that run: the newcomer takes an equal share and the
others give it up in proportion, keeping whatever relative sizes they
were dragged to. A side facing across the run has no run to join and
still halves the pane it landed on.

The band along the tab's edge was a flat 26px, which on any real window
is a hair's breadth. It is now measured against the pane it is read in —
a sixth of it, floored at 32px and capped at 120 — and only counts on a
side that faces the window rather than another pane. Landing there takes
an even share of the columns that side already has instead of half the
tab, so a third column is a third and not a half.

The highlight is no longer drawn from the rule. The drop is carried out
on a deep copy and the dragged pane's new rectangle is measured off it,
so the preview and the result cannot disagree; the copy is deep because
sharing a run out writes ratios the live tree's splits hold in common.

Also: the grip is a quiet 22x3 bar that grows to 40x5 under a fixed
56x10 target (it needs an id of its own, or gpui settles its size before
the group-hover is known), and every rearrangeable pane keeps an 8px
strip clear above its grid so the grip never sits on the first row.

* fix(splits): pin a drop to the pane it was offered against

Review follow-ups on the pane drag.

A drop zone named its target by position in the tab's leaf order, but it
is read on one frame and carried out on the next: a pane closing in
between shifts every index after it, and the drop lands beside a pane the
user never aimed at. The zone now carries the target itself once the
frame that drew it has resolved it, so a target that has gone refuses the
drop instead of sliding it sideways.

Alongside it:

* `Pane` is no longer `Clone`. The two copies it can be asked for differ
  in whether they share their splits' sizes, which is not a difference to
  leave to whichever one `.clone()` happens to mean; `shallow_clone` is
  now named and private, next to `deep_clone`.
* `edge_landing` no longer hands back a share that only a test read. The
  test reads it off the split the landing produced instead, which is the
  number the drop actually lands.
* A test pins the invariant the drop zones rest on: `leaf_rects` comes
  back in the order `leaves` does.
* Drop a doc comment that had landed on `close_focused` describing a
  different method, and an `Option` in `drop_pane` that was wrapped only
  to be unwrapped two lines later.
* The changelog claimed every rearranged tab now syncs as one `PaneMove`.
  Only a drop beside a single pane does; a drop beside a whole group is
  not something `PaneMove` can name, and still takes the rebuild. Both
  entries move under `Unreleased` — v26.8.2 was tagged before either
  landed.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 22:04:24 +08:00
l0ng-ai 09a653d10b fix(workspace): make the CLI and the GUI agree on what exists (#423)
Five places where a workspace, a tab or an attachment was real on one side of the socket and invisible on the other. They share a root: the GUI kept its own list of which workspaces exist (WindowViews on disk) and consulted the machine tree only for the ones already in that list, so anything created by another client was unreachable by construction.

- The switcher lists workspaces the machine holds but this client has never opened, and opening one keeps its id instead of claiming a fresh one.
- for_workspace_at hydrates whenever the machine holds tabs, so opening a workspace no longer saves an empty session over them.
- finish_hydration writes a full window back over an empty tree, which is what puts a ws rm'd workspace back under the same id.
- A deletion nothing has open is forgotten here too, instead of haunting the switcher until a restart.
- Workspace::attachment travels over the wire (minus the token that proves the hold, which stays on the connection that owns it) and is stripped in persist, so tty7 ls can name the host holding a workspace.
- tab ls / ws tree fall back through name -> agent -> cwd leaf -> process name, and tab ls grew a read-only GROUP column.
- tty7 new --open raises a window on the workspace it just made.
2026-08-09 14:18:19 +08:00
l0ng-aiandl0ng-ai 5e6bc1246f fix(workspace): stop switching workspaces from destroying live sessions (#410)
* fix(workspace): stop switching workspaces from destroying live sessions

Switching workspaces rebuilt every pane it was asked to restore, and a
window that rebuilt nothing then deleted the workspace outright — tree
and store both. Three separate guesses, each one authorizing an
irreversible act:

- `session_from_tree` erased a pane's id when the tree said `live: false`.
  That flag is a cached observation from another process, reloaded as
  false on every server start, so a quiet pane read as dead while its
  shell was running. The restore had nothing to attach to and spawned a
  fresh shell over it.

- Two servers could start against one config dir. `run_with` decided
  another server was dead by failing to connect once, then unlinked its
  socket and bound its own. The loser kept `control.sock` with an empty
  pane registry, so `MachineGet` reported every pane dead and nothing
  logged an error.

- `finish_hydration` marked a window informed before the rebuild and
  without looking at the result. `tabs_from_session` drops any tab whose
  panes all fail to start, which is every tab when the pane socket is
  unreachable — leaving a window that was empty and authoritative at
  once, and the next switch deleted a workspace with ten live tabs.

Each is now settled by whoever holds the truth: attaching decides
whether a pane is there, an advisory lock decides which process is the
server, and a deletion needs the machine's own mirror to agree that the
workspace is empty.

* fix(state): quarantine a corrupt views.json instead of silently discarding it

machine.json already sets a corrupt file aside before falling back to
defaults; views.json just logged and returned None, and the next save
overwrote whatever the file held. Move the quarantine helpers to config
so both loaders share them.

Also make the no-lock-primitive fallback in the daemon singleton report
Unavailable rather than Taken, so a platform without flock still gets a
server instead of one that refuses to start.

* fix(restore): a failed List no longer reads as every pane being dead

Review follow-ups on #410, all three the same shape the PR exists to
stamp out:

- alive_panes_on flattened a failed List RPC into an empty alive-set,
  which made pane_attachable respawn every pane in the batch over its
  running session. The failure now surfaces as None and the attach
  itself decides, the way session_from_tree already leaves it to.
- pub fn run() bypassed the singleton lock entirely; it had no callers,
  so a future one would have silently reintroduced the split-brain.
  Removed.
- Singleton::path() and the field behind it were unused. Removed.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 16:22:19 +08:00
l0ng-aiandl0ng-ai f1f632edd1 fix(daemon): notice a server that is only a control dialect behind (#387)
* fix(daemon): notice a server that is only a control dialect behind

The pane protocol and the control dialect are versioned apart, and the
launch check only compared the first. A server from before the v4 to v5
control bump answers the pane handshake with this build's own number, so
it was waved through as ours while every machine-tree call was refused:
the window opened with no tabs, the only trace a log line the default
config does not write anywhere.

Ask the control socket too, before calling the server ours, and say which
version disagrees when offering the restart. A window that still opens
empty now says why in the window it happened to.

* fix(daemon): do not call a newer server old, and say the empty-window reason once

The dialect handshake reports disagreement, not direction, but the restart
prompt read every mismatch as "from an older build". A daemon left running by
a newer build passes the pane-protocol check the same way an older one does,
so the prompt told users their newer server was old — the same wrong guess the
remote path stopped making in #384. Split the copy in two and pick by which
side is ahead, as `dialect_complaint` already does for remotes.

The window's own explanation had two ways to misfire. A hydration superseded
by a newer one still announced an emptiness someone else was already filling,
because only `owe_rehydration` checked the epoch; it now reports whether it
claimed the debt and only the owner speaks. And since a failed pull is retried
from every `sync_window`, a machine that never answers repeated the same
notification every fifteen seconds — latched to once per window, cleared when a
pull finally lands so a later outage still gets a word.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 21:08:30 +08:00
l0ng-aiandl0ng-ai b9956ad6af feat(switcher): two-column panel with IDEA-style Ctrl+Tab (#380)
* feat(switcher): two-column panel with IDEA-style Ctrl+Tab

The switcher listed workspaces only; reaching a tab inside one meant
opening it first. It is now two columns — workspaces on the left, the
tabs of whichever one the cursor sits on to the right — and Ctrl+Tab
raises it as a most-recently-used tab switcher that commits when the
modifier comes up.

Picking a workspace or a tab now switches this window in place. A second
window is something you ask for, with the platform modifier or "Open in
New Window", rather than what happens by default.

New workspaces get a codename ("amber-yak") instead of inheriting
whatever directory their first shell started in. The generator moves out
of worktree.rs into core::codename so both callers share it.

* fix(switcher): review fixes — platform-gate the Ctrl+Tab key test, park the new-window tab

- The key_intent test asserted only the macOS half of the Tab chord; off
  macOS Ctrl *is* the secondary modifier, the raw key falls through on
  purpose (the chord arrives as NextTab), and the test now says so
  instead of failing there.
- open_at_tab probed a freshly opened window's tabs exactly once, but a
  new window hydrates them asynchronously — route through
  activate_tree_tab so the pick parks until the tab arrives.
- Escape mid-rename backs out of the rename instead of tearing down the
  whole panel.
- A one-tab workspace reads '1 tab', not '1 tabs'.

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

* style: cargo fmt

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 14:30:40 +08:00
l0ng-ai 6374a08621 fix(ui): restarting the server must not strand the window on the home page
Restarting the local server clears the window's tabs and then pulls the
layout back from the machine tree. The pull went out on the control link
we held, which pointed at the server we had just killed: `is_connected`
only flips once the reader sees EOF, so for a moment the dead link still
classifies as Ready. The call on it failed, `finish_hydration` logged a
warning and gave up, and the window stayed empty on the home page.

Drop the local link before resyncing so the pull waits for the new
server instead of racing a dead socket, and record a failed hydration as
a debt the next sync retries. The debt also stops the empty window from
diffing into "close every tab" and wiping the layout off the machine
once the link is back; a `Replace` retry is abandoned if the user has
filled the window in the meantime.
2026-07-31 19:19:47 +08:00
l0ng-aiandl0ng-ai 8c1946d763 chore: strip every comment from the Rust sources (#268)
Removed all Rust comments -- line, block, and doc -- from the 139 tracked
.rs files with `uncomment` 3.5.1. It parses each file with tree-sitter
instead of matching text, so comment-like content inside string literals
is left alone: the JavaScript plugin source embedded in agent_hooks.rs
raw strings keeps its own `//` lines.

Left alone: Cargo.toml comments and the shell scripts under scripts/.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 21:36:15 +08:00
a4972d32d8 feat(core): daemon-owned workspace tree — semantic ops, incremental deltas, thin clients (#260)
* refactor(daemon): share one run_daemon between tty7 and tty7-server

Extract the control-listener-plus-pane-server startup from tty7-server
into tty7_core::daemon::server::run_daemon, and point both binaries at
it. The local daemon now serves the control dialect exactly like a
remote one: one machine = one daemon, whichever binary happens to be
running it.

The bound control socket (and a bind failure) is still reported on
stderr with the historical 'tty7-server:' prefix — a headless server's
log file is off by default, and the remote_router test reads that exact
line back to prove the client derivation and the server bind agree.

* feat(core): daemon-owned machine tree with semantic operations

Add core::machine: the workspace/tab/pane tree a machine's daemon owns
outright, replacing the client-owned-schema model of the opaque record
store. Leaves hold a pane id and nothing else; every fact about a pane
(cwd from OSC 7, title, ssh spec, agent identity) lives once in the
pane registry, which is what makes revival sound: a reopened store
force-clears every live flag, so after a daemon restart the tree itself
says every leaf is awaiting revival — no client-side instance stamps or
id-reuse heuristics required.

Operations (workspace create/rename/delete/touch/set-active-tab, tab
create/close/rename/move/regroup, pane split/close/set-ratio/move/
replace) validate against the held tree, persist atomically, roll back
on a failed write, and broadcast incremental LayoutDelta events with
origin exclusion so a writer never hears its own echo. Persisted to
machine.json beside the old store's file, serde with #[serde(default)]
throughout so the daemon can keep evolving the schema, corrupt files
quarantined instead of overwritten.

* feat(control): machine-tree verbs and incremental Layout deltas

Teach the control dialect the semantic operations the machine tree
serves: MachineGet / WorkspaceTree pulls, WorkspaceCreate / Rename /
Remove / Touch / SetActiveTab, TabCreate / Close / Rename / Move /
SetGroup, and PaneSplit / Close / SetRatio / Move / Replace. Replies
carry the daemon's own tree types (a created workspace or tab comes
back whole; close operations answer the pane ids that left the tree so
the caller can kill their PTYs), and every operation broadcasts a
ControlEvent::Layout delta to every connection but the writer's — the
same origin-exclusion mechanism the record store uses, one delta at a
time instead of whole-record last-writer-wins.

The server advertises a new 'machine-tree' capability bit only when it
actually carries a MachineStore; both daemons now do, alongside the
retired opaque record store, which keeps serving unchanged while
clients migrate. Delta fan-out rides its own bounded queue and
forwarder thread per connection, so a peer that stopped reading stalls
nobody's edit; the drop-on-overflow tradeoff is documented against the
keepalive that reaps such a peer and the full pull every reconnect
starts with.

The request/reply/event enums lose their Eq derive: split ratios are
f32. End-to-end tests drive the shipped tty7-server binary over real
pipes: capability advertisement, tree ops landing in the server's own
file, dead-pane revival across a real process restart, and delta
delivery between two live clients.

* feat(daemon): pane facts flow from the pane server into the machine tree

The tree's pane records are only worth reviving from if they hold what
the machine itself observed, so the pane server now publishes into the
MachineStore the daemon serves: the reader thread reports OSC 7 / probed
cwd changes and the sniffer's agent facts (identity, native session id,
launch argv, coarse status) after each chunk that changed them, and
DeathReporter::report flips the record to live == false however the
death was noticed — that flag is the client-visible 'awaiting revival'
state, and it now comes from the process that owns the PTYs on the very
event, not only from the next restart.

The store rides a process-wide slot (installed by control_services,
same shape as the control event observer) so the three pane-spawn paths
need not thread it through; without one installed, observing is a
no-op, which keeps unit tests and tree-less servers quiet. Facts are
published outside the pane state lock and only on a real change, so the
reader's hot path pays two clones and a compare. AgentFacts.status
tightens from a free string to the existing AgentStatus enum while no
wire client depends on it.

* feat(ui): hold a supervised control link to the local daemon

The GUI now dials this machine's own daemon over the control dialect,
exactly as it does a remote one: one machine, one daemon, one control
link. The link lives in its own global rather than RemoteConnections —
inserting it there would register a wire-backed Host for this machine
(local files and git must keep going through the in-process LocalHost)
and would break the HostId::LOCAL-never-holds-a-control-connection
invariant. No routing either: the daemon's control socket is right
here, so connecting is a Unix connect plus a ControlHello.

Supervised on its own forever loop at the remote pump's cadence,
because that pump deliberately parks when the last remote workspace
closes and a purely local session is the common case. Each turn also
drains the shared control-event queue, so local pushes (Layout deltas,
Preempted) are delivered under HostId::LOCAL even with the remote pump
stopped; the observer install is shared with the remote supervisor so
whichever comes up first, reader threads never find nobody listening.
Reconnects ride the same 1/2/4/…/30s backoff a remote machine gets,
with ensure_running first — the daemon is the GUI's own child, and a
cold start legitimately races its listener.

Unix-only like the control listener it dials; on Windows the loop
compiles to a supervision no-op and the pane path is untouched.

* feat(control): attachment and takeover ride the machine tree too

WorkspaceAttach / WorkspaceDetach (and the hello-names-a-workspace
shorthand) now record their data half on whichever workspace stores the
server carries: the retired record store, the machine tree, or — on a
full daemon while clients migrate — both, since they describe the same
workspace. The behavioural contract is untouched and now survives the
record store's retirement: newcomer always wins, the displaced session
is pushed Preempted (and closed only when its link was dedicated), and
a preempted session's tidy-up detach cannot evict the usurper — the
token check lives in the tree's runtime-only attachment exactly as it
did in the store's. A server carrying neither store answers the same
refusal a store-less server always has.

WorkspaceId gains FromStr (the inverse of its Display) because the
attach verbs predate the typed tree and carry the id as a string. The
end-to-end test drives a takeover on a server serving the tree and no
record store at all, asserting the tree's own attachment record moves
with it.

* fix(core): review hardening for the machine-tree foundation

Findings from a correctness review of the new daemon-owned tree,
applied together:

- A dead pane can no longer be resurrected in the tree by its own last
  output. On Windows the exit monitor reports the death while the
  reader is still draining ConPTY's buffered bytes, and the death
  report is latched; the reader's 'output is proof of life' publish now
  asserts liveness only while the pane state still says alive.
- Delta delivery is ordered. Mutations were serialized by the state
  lock but delivered after releasing it, so one writer's deltas could
  overtake another's and leave every mirroring client on the losing
  state with no cue to re-pull. A notify-order mutex now spans each
  mutation and its own fan-out; cheap, because subscriber callbacks are
  enqueue-only by contract.
- Implicit active-tab changes broadcast. tab_create's activation and
  the close paths' heal now emit ActiveTabChanged, so a client applying
  deltas never re-implements the server's heal rule; the one
  inexpressible case (no tabs) needs no delta because it is a fact,
  not surgery.
- The coarse agent status no longer drives disk writes: it flips per
  hook event and is display-only, so it is outside the changed-facts
  gate and merely rides along when a load-bearing fact changes.
- control_services reports which stores it serves on stderr again —
  tty7-server configures no log sink, and 'no machine tree' was
  invisible exactly where it matters, on a headless box.
- The local link's first connect attempt is immediate instead of one
  backoff step late; the observation-slot test withdraws its store so
  it cannot swallow later tests' observations; and locked()'s poison
  rationale now says what is actually guaranteed.

* feat(control): let clients mint workspace and tab identities on create

A window names its workspace — in the registry, the view file, and any
operation it queues — before its first round trip completes, and the same
holds for a tab the moment the user opens it. Making the daemon the only
minter would force every client to hold its edits until a reply carried
the real id back. Ids are uuids, so a client-minted one is as unique as a
daemon-minted one; WorkspaceCreate and TabCreate now carry an optional
client id, keep it when it is free, and refuse a duplicate rather than
adopt it. Absent (older callers, tests) the daemon mints as before.

* feat(ui): windows speak semantic tree operations for every structural change

The write path of the client migration: each window now keeps a mirror of
what the daemon's tree holds for its workspace, and save_session — the
funnel every structural change already passes through — diffs the window
against that mirror and sends the recovered operations (TabCreate,
PaneSplit, PaneClose, PaneReplace, TabMove, ratio and label ops) over the
workspace's control link: the LocalLink for this machine, the machine's
RemoteConnections entry otherwise. Consecutive saves differ by exactly one
user action, so the diff recovers that action rather than re-shipping the
layout; changes no single op expresses rebuild the affected tab whole,
matching the delta contract's own granularity.

The mirror advances by running the server's own tree surgery (PaneNode's
split/remove/replace are public now), and any disagreement — a refused op,
a dropped link — resolves by one shared recovery path: drop the queue,
re-pull WorkspaceTree, re-diff. Fresh spawns are invisible until their
pane id lands; land_pane's save is when their create goes out. GUI tabs
carry a client-minted TabId, and a primed mirror re-points tabs it
recognizes by their panes, so a rebuilt window adopts the daemon's tabs
instead of churning them.

Workspace-level facts ride along: focus touches, renames, and deletions
now reach the machine's tree too, and the divider drag finally persists
the ratio it lands on (it previously reached disk only as a passenger on
the next structural change).

session.json is still written in parallel; it retires with the read-path
migration.

* feat(ui): local windows restore by asking the daemon's tree

The read path: opening a known local workspace no longer rebuilds from
session.json synchronously. The window opens empty and a background pull
(MachineGet — the workspace's structure joined with the pane registry,
which is where the revival facts live) rebuilds it the moment the daemon
answers; against the local daemon that is milliseconds, so the empty
state is effectively one frame — the same shape a remote workspace's
connect-driven rebuild has always had.

The lowering from tree to window is the revival decision: a leaf whose
pane record says live re-attaches by id, a dead one lowers to an id-less
leaf carrying the record's cwd, SSH spec and agent resume — the exact
shape that makes the existing builder spawn a successor and type the
agent's --resume. The save that follows diffs the successor against the
mirror and sends PaneReplace, spending the old record; revival needed no
op code of its own.

Restored tabs keep their daemon tab ids (SessionTab grows a never-
persisted tree_id), so the first save addresses the daemon's tabs instead
of churning them. A tree with nothing for the workspace falls back once
to the client's cached layout, whose adoption re-populates the tree
through the ordinary diff — the whole of the best-effort import.

* feat(ui): live windows apply the machine's incremental layout deltas

The pump's event drain now lands ControlEvent::Layout instead of debug-
logging it: each delta advances this client's mirror (by the same
surgery the server ran) and then the live window showing the workspace —
renames, regrouping, moves, active-tab changes and ratio drags in place;
TabCreated by building the tab and attaching its (writer-spawned, so
live) panes; TabRestructured by rebuilding the one tab while reusing the
views of panes the window already shows, because re-attaching a pane
this window holds would steal its own stream. Origin exclusion means
every delta arriving is another client's edit, and applying it to window
and mirror in one step leaves the next local diff with nothing to echo.

A delta that will not apply cleanly — a tab the mirror never heard of, a
drifted window — falls back to re-pulling the workspace and rebuilding
the window from the authoritative tree, the same single recovery path
every other failure already uses.

* feat(daemon): report panes the machine tree no longer references

With the tree now populated by clients' semantic operations, the daemon
can finally see panes nothing references. A periodic sweep reports them —
log-only, deliberately: an unreferenced pane is not proof of a leak (a
native-SSH pane opened inside a remote workspace's window runs in this
daemon while belonging to the other machine's tree), and reclaiming one
wrongly kills a session the user is looking at. The sweep's interval
doubles as a grace period: a pane is reported only after being
unreferenced across two consecutive looks, so an adoption still in
flight is never flagged. Reclamation can be layered on once the log has
shown the false-positive rate is zero.

* feat(ui): remote workspaces read and write the machine tree like local ones

Local and remote are now the same shape end to end. A remote workspace
opens empty unconditionally (connected or not) and is filled by the same
tree hydration a local window uses; the connect supervisor's landing
replaces the opaque-record refresh with it — a blinked link relinks the
pane streams and hydrates whatever opened empty meanwhile, a replaced
server process resyncs the window from the tree, whose force-cleared
live flags are what make every leaf revive. The remote picker lists
workspaces from MachineGet, deriving names from the tree the way a
local workspace derives its own; creating one lets the hydration's
WorkspaceCreate mint it on the machine; the record push, pull, refresh
(WorkspaceChanged) and remote delete paths are gone client-side.

Windows that have not yet seen their machine's tree sync additively: a
window that opened empty ahead of its pull may add tabs but never prunes
ones it has not displayed, so its ignorance can no longer read as 'close
everything' — the diff takes an explicit scope, and only hydration (or a
deliberately authoritative open, like restore-off) grants the full one.

* refactor(core): retire the client-side pane-identity defenses

The machine tree made this whole family unnecessary, so it goes rather
than lingers: daemon_instance stamps (a restarted daemon's tree says
live=false about every pane — a fact, where the stamp was a heuristic),
forget_stale_pane_ids on both layers, dedupe_pane_ids (the daemon
refuses a pane appearing twice in its tree, so there is no duplicate to
mop up client-side), the claim/record instance plumbing, and the
whole-record halves of the storage split (to_remote_json,
apply_remote_json, REMOTE_OWNED_FIELDS, CLIENT_OWNED_FIELDS, and the
store's apply_remote / remote_payload), together with their tests.

forget_pane_ids stays for now: it clears the client's cached copy, which
still serves as the one-time import fallback until the view file slims
down to pure view state.

* refactor(ui): a local daemon restart rebuilds from the tree too

The tree file survives the restart and the fresh daemon force-clears
every pane's live flag, so the resync path already expresses exactly
what the hand-rolled saved-session rebuild did: every leaf revives as a
fresh shell in its recorded cwd with its agent resumed. The pull waits
out the local link reconnecting to the fresh daemon.

* docs(core): drop a stale reference to the retired record verbs

* fix(ui): close the review findings on the tree migration

Review fixes, worst first:

- Pane ids never alias across daemon restarts: the pane registry seeds
  its counter past everything the persisted tree references. A fresh
  process minting from 1 handed new shells ids that dead leaves still
  claimed — the tree marked the wrong pane live, revival stalled forever
  on 'already part of this machine's tree', and an attach by the stale
  id stole another workspace's stream. Ids are names now, not slots.
- An empty window only licenses WorkspaceRemove once it is *informed*:
  a window whose hydration has not answered is empty because it is
  waiting, and closing or swapping it mid-pull was deleting populated
  trees. Remote workspaces also hydrate regardless of the restore
  setting — their panes are running sessions, not a saved layout, and
  the restore-off swap used to open them empty-and-authoritative and
  close every tab on the machine.
- Tabs whose panes are all still spawning are *held*, not pruned: they
  are invisible in the desired tree without being absent, and the Full
  diff was closing them (spending the records the landing spawns'
  PaneReplace needed) on every remote revival.
- A preempted window stays passive under deltas: applying the usurper's
  TabCreated/TabRestructured attached to their fresh panes and stole the
  streams they were typing into. The mirror is dropped instead; taking
  the workspace back re-pulls it whole.
- Delta TabClosed tracks the active tab by identity (closing a tab to
  the left no longer shifts focus and pushes the wrong active tab back).
- The hydrate/resync path drops the op queue like desync does, so ops
  computed against an abandoned mirror cannot drain after the snapshot.
- A rebuilt remote tab no longer matches a native-SSH leaf's *local*
  pane id against remote ids; delta-applied ratios clamp to the GUI
  band; async completions use get_mut so a forgotten window's sync state
  is not resurrected.

* feat(ui): a per-machine mirror of each daemon's tree feeds the read surfaces

The switcher, the Window menu, the title bar, the rename seeds, the
stop/delete confirmation and the liveness sweep all answered their
questions (display name, subject path, pane ids, pane count) from the
client's cached copy of the layout. The machine's tree owns the layout
now, so a new per-host MachineMirrors global holds each machine's last
pulled tree — filled by a MachineGet whenever a control link comes up
(and for free off every hydration, which already pulls the whole
machine), advanced by the same Layout delta stream the windows consume,
plus explicit notes for this client's own operations, which origin
exclusion keeps out of that stream.

The readers move over wholesale. A machine not pulled yet reads as
not-knowing rather than a stale guess: pickers show the shared fallback
for a beat (against the local daemon the pull lands within a frame),
and the pane-count prompt says the machine could not be asked instead
of counting against a cache. tree_display_name moves out of the remote
picker into the mirror as display_name_of — it was always the tree
flavour of Workspace::display_name, and now everything shares it.

This is the read-model half of retiring the client's layout cache; the
persistence shrink to pure view state follows on top of it.

* refactor(ui): client persistence shrinks to pure window views

The client file stops carrying layout. session.json's Workspace — id,
name, a whole embedded Session, geometry, open, last_active, host —
becomes WindowView { id, window, open, last_active, host } in a fresh
views.json (no migration by design; an old session.json is simply
ignored, and its panes revive from the machine tree like any daemon
restart). Everything the embedded layout used to answer already moved
to the per-machine mirror, so this deletes the write half:

- WorkspaceStore::claim answers only the id; record shrinks to
  record_geometry. claimable_session / record_session — the
  reachability-gated layout cache — go entirely, and with them the
  one-time empty-tree import in finish_hydration: with no cached copy
  there is nothing to import, and the machine answering "no tabs" is
  the layout.
- The user-set name is purely the machine's fact now. rename /
  rename_locally leave the store; the chip and switcher renames fire
  WorkspaceRename directly (tree_sync::rename_workspace), the
  WorkspaceRenamed delta needs nothing from the window because the
  mirror already applied it, and WorkspaceCreate seeds no name.
- forget_pane_ids / blank_pane_ids and the layout-derived getters
  (display_name, dominant_repo, first_cwd, pane_count, pane_ids) are
  deleted with their tests — each had grown a mirror-side twin.
- switch_workspace always hydrates: with the tree as the only layout
  source, restore-off governs what launch comes back to, not what a
  deliberate switcher pick shows.

The retired opaque record store loses its one test that asserted its
file parses as a client Workspaces document — that coupling is the
thing this migration ends, and the store itself is next to go.

* refactor(server): retire the opaque workspace record store

Clients stopped sending WorkspaceList/Get/Put/Delete when the tree
migration landed, so the coexistence scaffolding comes out:

- core::workspace_store is deleted. Attachment and the data-directory
  resolution (TTY7_DATA_DIR, XDG fallback chain) move into
  core::machine, which was already their only consumer; Attachment
  loses its vestigial serde derives (it never crosses disk or wire).
- The control dialect drops the four record verbs, the ReplyOk::Json
  payload they answered with, and the WorkspaceChanged event. Their
  serde names (and the workspace-store capability bit) are recorded as
  burned rather than reserved by any mechanism — the dialect has no
  numbered slots to hold, so a comment at each site is the guard, plus
  the handshake test asserting the bit never reappears.
- host::server loses Services.workspaces, the verb arms, the
  per-connection store subscription and its WorkspaceChanged forwarder,
  and the store half of attach/detach/teardown. Attachment data now
  lives solely in the tree: a workspace the tree does not list records
  no data half (the registry's live handles still move, so takeover
  behaviour is unchanged), and it appears the moment the workspace
  does. Services::with_workspaces/and_machine collapse into
  with_machine; control_services becomes a single match.
- The attach/takeover tests move onto MachineStore wholesale, attaching
  to workspaces created in a real tree; the record-store round-trip and
  fan-out tests go (tests/machine_tree.rs has carried the tree
  equivalents since the verbs landed), and tests/workspace_store.rs is
  deleted with the serde_json dev-dependency that existed only for it.
  machine.rs gains the two guarantees the old suite held uniquely: an
  attachment dies with its workspace structurally, and the default path
  resolution ends at the documented file.
- The GUI's dead WorkspaceChanged arm and every stale doc reference go.

* refactor(ui): rename RemoteConnections to HostLinks

Purely mechanical, plus the doc sentences that carry the model: the
table holds one control link per machine, and the local machine is a
machine like any other — its link just lives in its own global
(LocalLink) because it is in-process rather than wire-backed. The old
name framed the table as remote-only plumbing, which the tree
migration made false in spirit: local and remote windows speak the
same operations over whichever link their machine answers on.

* fix(ui): a tree-driven tab rebuild keeps the native-SSH split it cannot name

A native-SSH pane opened inside a remote workspace's window runs in
this client's own daemon and is deliberately absent from the remote
machine's tree (its local id would collide with an unrelated remote
pane). The TabRestructured rebuild therefore had no leaf for it and
dropped its view on the floor: the local session kept running,
invisible from every surface — a true orphan only the daemon's log-only
sweep would ever mention.

The rebuild now sets such leaves aside while harvesting reusable views
and appends each back as a fresh half-and-half split on the right once
the tree's own panes are built. The old split geometry is unknowable
from the delta (the tree never held it), so the appended shape is the
one a split created it in; the next save changes nothing, because the
diff already lowers a remote window without its ssh leaves.

The resync path (a delta that fails to apply, a replaced server) still
rebuilds the whole window from the tree and drops such views — that
path discards every view it has by design, and is left as a known
residual. TerminalView grows a test-only ssh-marked pane constructor so
the kept-split property is pinned by a gpui test.

* docs(core): finish pointing the last session.json references at views.json

* fix(ui): kick every local window's sync when the local link comes up

A window built while the local control link was still dialing parks as
Unprimed { dirty } — start_prime's unreachable arm leaves the retry to
"the reconnect-triggered save", but the local link supervisor never
triggered one. On a first launch (window built before the auto-spawned
daemon binds its socket) nothing else re-enters sync_window until the
next structural change, so quitting before one loses the window's
layout: the machine never heard of it.

Reproduced end-to-end on a scratch daemon: fresh launch, no user
action, quit — the relaunch came up empty. With the link supervisor
calling tree_sync::on_link_up on connect, the same launch syncs the
tree within one pump tick.

* fix(ui): read a deleted workspace's kill list before the removal blanks the mirror

delete_workspace fired WorkspaceRemove first, and fire_workspace_op folds
the removal into the machine mirror synchronously on its way out — so the
kill list stop_workspace_keeping then read off that mirror was always
empty, and 'Delete Workspace' ended zero of the sessions its confirm
prompt promised to end. The kill list is now read before the op fires,
and both destructive paths receive it explicitly so the ordering is a
signature rather than a convention.

* fix(control): bump both dialect versions and gate tree verbs on the machine-tree bit

The tree migration deleted four control verbs and added seventeen, but
CONTROL_VERSION stayed at 2 — two builds that cannot understand each
other's requests would have shaken hands as equals. It is now 3, with
the history entry the file's format asks for.

PROTOCOL_VERSION moves to 4 for the service change underneath: a
pre-tree 'tty7 --daemon' has no control listener at all, so a GUI from
this build silently adopting one connects its control link into the
void forever and every window hydrates from a tree that never answers.
The bump routes that meeting into ensure_running's existing
keep-or-restart prompt.

Clients now also consume the machine-tree capability bit before any
tree traffic: a connected peer without it (a server with no home
directory keeps serving files and panes) classifies as a distinct
'unserved' state that is logged once and skipped, instead of a refused
round trip per operation.

* fix(ui): preempted windows stay passive and take-back rebuilds from the tree

Two halves of the same takeover contract were broken.

A preempted window kept pushing: sync_window had no preemption check, so
a click on the read-only tab strip sent WorkspaceSetActiveTab against
the usurper's session, and the next save Full-diffed the stale layout —
rolling the usurper's edits back wholesale. sync_window now returns
early for a preempted workspace, and preemption itself drops the
window's queue, mirror and 'informed' licence (tree_sync::on_preempted,
shared with the delta path's existing reset).

Take Back never rebuilt: the recovery attach ran the ordinary IfEmpty
hydration, which skips any non-empty window — and a preempted window is
by definition non-empty with the pre-takeover layout. retry_now now
marks the workspace as reclaiming, and finish_attempt rebuilds marked
(or still-preempted) windows via Adopt::Replace, honouring the 'take
back re-pulls whole' promise the delta path documents.

* fix(ui): delta application survives pulls in flight

Three overlap bugs between the incremental delta stream and the full
pulls it has no ordering barrier with:

- A TabCreated straddling a pull was applied by both — the snapshot
  already carried the tab, and the delta inserted a second copy into
  the machine mirror and the window mirror, and rebuilt a second GUI
  tab whose attach stole the pane's single stream from the window
  itself. All three application sites now replace by id.

- A delta arriving while a window's prime/hydration was in flight was
  applied to the window even though the mirror side skipped it — a
  TabCreated landing in a still-empty window made finish_hydration
  read 'the user got here first' and skip adopting the tree, leaving
  the window with only the concurrently-created tab forever. Window
  application is now gated on the mirror being primed; the pull's
  snapshot carries the delta's effect.

- A prime answered after a newer cycle (hydration, desync, preemption)
  replaced it would install its stale tree over a mirror that had since
  advanced, and the next diff would re-emit the rollback as operations.
  Every cycle now stamps an epoch, and pulls landing under an old one
  are dropped.

* fix(ui): apply ratio deltas in the server's clamp band

set_gui_ratio clamped to 0.1-0.9 while the server accepts 0.05-0.95, so
another client's 0.07 arrived as 0.1 — and the next save's ratio diff
pushed the rewrite back at the machine, silently moving their divider.

* fix(core): machine-store hardening around seeds and unreadable files

- A PaneSeed entered the registry live:true unconditionally. A pane
  that died between its spawn and its adopting operation had its death
  observation dropped (note_pane_facts ignores panes the tree does not
  hold), and nothing ever flipped the record back — the leaf claimed a
  live pane forever and revival was never offered. The daemon now
  installs a liveness probe on the store (registry-backed), consulted
  at registration; without one (tests, clients) the seed is trusted.

- seed_ids_past computed max + 1, which panics a debug daemon at
  startup when the persisted tree names u64::MAX. saturating_add parks
  the counter at the ceiling instead.

- load_machine quarantined an unparseable file but not an unreadable
  one: a read failure logged, started empty, and the first mutation
  overwrote the very file that could not be read. Read failures now
  quarantine too — by rename, since a copy would need the read
  permission that just failed.

Also de-flakes the pre-existing spawn_writer test: the first write into
a freshly-closed socket can succeed before the kernel processes the
close, so the poll loop now keeps the writer fed until a write fails.

* feat(control): announce dropped layout deltas so lagged clients resync

A connection whose per-link delta queue overflowed lost an edit it will
never hear again — the server logged the drop, and the client mirrored
a tree it was no longer looking at until something else happened to
fail. The subscriber callback now flags the connection lagged, and the
layout forwarder sends the new ControlEvent::LayoutResync ahead of the
next delta it delivers (the flag is only ever set with a full queue
behind it, so the announcement never waits on a quiet tree). The client
answers by re-pulling the machine mirror and resyncing every window on
that machine — the same recovery an unappliable delta already uses,
announced instead of stumbled into. WatchOverflow is the precedent.

* fix(ui): a pure native-SSH tab is invisible to the tree, not held forever

Held means 'spawns are landing, wait before ordering' — but a remote
window's tab that is native-SSH through and through can never land: its
panes live in this client's daemon and are deliberately unnameable in
the remote machine's tree. Filing it as held made every diff return
before the ordering and active-tab passes, freezing tab order and
activation sync for the whole window for as long as the tab existed —
and a mixed tab whose last remote pane was closed kept its dead leaf on
the machine for ever, because the held id shielded the daemon tab from
the close.

Such tabs are now classified permanently invisible: not desired, not
held. Ordering resumes, and the mixed tab's daemon twin closes when its
last tree-visible pane goes. Pending leaves (a connecting spawn, an
empty slot) still read as held.

* docs(core): drop the dead instance helper, the stale title field, and two doc lies

- local_daemon_instance() lost its last caller when the client-side
  pane-identity defenses were retired; deleted.

- DaemonVersion::instance's doc pointed at Workspace::daemon_instance
  (deleted with the record store) and claimed pane ids restart from 1 —
  no longer true of a tree-carrying daemon, which seeds its ids past
  everything the tree names. Rewritten to describe what the field
  actually backs now.

- PaneRecord::title claimed to label panes awaiting revival, but no
  code ever wrote it: the pane's title is a live foreground-process
  query at PaneInfo time, not state the facts path observes. The field
  is deleted (serde-compatible: unknown fields are ignored on read) and
  the decision recorded where it lived; revival labels derive from cwd
  and agent.

* fix(ui): converge the tree after adopting a delta-created tab

Adopting a TabCreated delta whose pane is dead on arrival attaches
nothing and spawns a fresh pane under a new id — and nothing on the
delta path saved afterwards, so the tree kept the dead leaf: other
clients saw a dead tab, and a relaunch would spawn a second successor
beside the leaked first. Reproduced end-to-end (external client creates
a tab with an unspawned pane; the GUI adopted it and the tree never
learned the successor's id).

One sync_window after a clean apply closes it: free when window and
mirror agree (the diff is empty), and exactly the PaneReplace that
spends the dead record when adoption had to spawn.

* fix(core): review follow-ups on the daemon-owned tree

Nine findings from a review pass over the branch. One commit because
they cross the same files, and splitting them would leave an
intermediate that does not build on Windows.

- A dropped delta announced a LayoutResync and then delivered the
  backlog behind it. The queue is FIFO, so everything still in it is
  *older* than the gap: the peer re-pulled on the notice and was then
  walked back through history it had already left — TabRestructured
  restoring the shape a tab used to have, with window and mirror
  agreeing on the stale answer so nothing recovered a second time. The
  forwarder now drops the superseded queue and sends the resync in its
  place.

- Pane facts persisted the whole document, with an fsync, from the PTY
  reader thread — once per OSC 7, so once per prompt per pane — while
  holding the lock that orders every other client's edits. A shell
  looping over directories was a write per iteration. Observations
  (pane facts, workspace_touch) now take Persist::Soon: the delta still
  goes out at once, the file catches up within FACT_FLUSH_INTERVAL, and
  the daemon flushes on the way out. The layout itself is never
  deferred.

- An ordinary output chunk paid two AgentFacts clones and a
  clone-to-compare for facts it could not have changed. Gated on the
  signals that can move one, and the compare no longer clones.

- machine.json was created 0644, naming every workspace's directories,
  the SSH user and host of every native-SSH pane, and each agent's
  session id. It is written owner-only from the first instant the final
  name exists, and a second corruption no longer overwrites the rescue
  copy of the first.

- Windows had no control listener, so on the one platform where the
  tree is the only layout store, tabs did not come back at all. It now
  serves the dialect over the transport its pane socket already uses: a
  loopback listener whose port and 256-bit token live in a user-private
  control.port beside daemon.port — its own token, not the pane
  endpoint's — refusing to rebind over a live one, since binding is
  what writes the marker. run_daemon and the GUI's local link are one
  code path again.

- Workspace names and paths came only from the machine's mirror, so a
  laptop shut since Friday listed every row as "Untitled" with a blank
  subtitle, in the picker whose whole job is offering workspaces on
  machines that are asleep. WindowView carries the label and subject
  the machine last gave, stamped on save and on detach; the tree still
  wins whenever it answers.

- liveness_of read "the mirror has not been pulled yet" as Stopped,
  which tells the user their sessions are gone on the strength of our
  own ignorance. Unknown is what that state is for.

- A WorkspaceRemove that never reached its machine was a debug line,
  though the client had already forgotten the workspace. It is now a
  warning that says what was left where.

- MachineMirrors::install landed a pull without a repaint; the two tests
  the record store's retirement took with it (a closed connection stops
  being a subscriber, concurrent connections can all write) are back
  against the tree; and CHANGELOG records the migration's one-time
  layout loss and the Windows gap this closes.

Suites green: tty7-core 675, tty7 819, tty7-server 9/5/3/3/51, fmt and
clippy clean. The Windows listener is unverified by a compiler here — a
C dependency in the tree blocks cross-checking from macOS — so CI's
Windows job is its first build.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@thomass-Mini.lan>
2026-07-30 12:25:38 +08:00