Commit Graph
257 Commits
Author SHA1 Message Date
l0ng-ai a88c49904c Merge branch 'main' into fix/wait-free-remote-panes
#731's `PortProbe` and #774's `TerminalModes` both landed on main while this was
open, each adding a field beside one this branch adds — `PaneProcs.probe` next to
`context`, `PaneState.modes` next to `remote_prompt_seen` — so every struct
literal for the two conflicted without either side being wrong. Both fields are
kept everywhere. `procinfo::snapshot` is #731's rewrite, with `context: None`
moved onto the `finish` helper that now builds the reply; the comment saying why
only the pane can fill it comes along.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 15:01:01 +08:00
l0ng-ai 5c387e2ba4 Merge pull request #830 from l0ng-ai/fix/731-port-probe-honesty
fix(procinfo): stop the listening-port probe failing silently (#731)
2026-09-10 14:34:54 +08:00
l0ng-ai 104268ba81 Merge pull request #828 from l0ng-ai/fix/774-pty-source-and-replayed-modes
fix(terminal): a per-pty parked-cursor repair, and modes restored on re-attach (#774)
2026-09-10 14:34:00 +08:00
l0ng-ai 93e804b20e Merge pull request #824 from l0ng-ai/perf/ssh-prove-server-once-per-connection
perf(ssh): prove the remote server once per connection, not once per pane (#695)
2026-09-10 14:28:51 +08:00
l0ng-ai 76c607a8a9 Merge branch 'main' into fix/wait-free-remote-panes
#711 landed on main while this was open and added two `ShellState` literals to
pane.rs's tests, which `mark_at_prompt` makes incomplete — the merge did not
compile on any target even though both sides did. The marks those two tests
stand up say "at a prompt", which is exactly the reading the new field carries,
so both get `mark_at_prompt: true` and the replay gate they exercise is
untouched. `cli_e2e`'s test table took both sides' new entries.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:25:06 +08:00
l0ng-ai 2ec515c206 Merge pull request #831 from l0ng-ai/fix/716-remaining-three
fix: the three remaining items of #716 — the misdirected rename, the missing adopt verb, the absent tree backup
2026-09-10 14:19:07 +08:00
l0ng-ai 0381c0270c Merge remote-tracking branch 'origin/main' into review-828
`main` dropped the agent-turn cuts from the reader's `flush_batch!` and
added `foreground_command` to `replay_state`; both are kept, with the
per-pty `repair_cursor` flag replacing `REPAIR_PARKED_CURSOR` in the
simplified cut loop and the mode restore ahead of the new signature.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:13:53 +08:00
l0ng-ai 4be07107b2 Merge pull request #855 from l0ng-ai/fix/capture-empty-and-tail-841
fix(cli): a resize was emptying capture; add --tail N (#841)
2026-09-10 14:12:45 +08:00
l0ng-ai 8c508a5282 fix(ssh): hold the note's lock across a restart or a replace
The memo made the connection's `proved_server` slot both the note and the
install gate, and `ensure_remote_server` holds it across the probe for a
reason the two operations that change the server share: a pane arriving
mid-install must wait rather than upload over the file the winner is
renaming into place. `restart_remote_daemon` and `replace_remote_server`
only cleared the note and then let go, so a pane opening during a replace
still probed against a half-moved binary — and now *kept* that answer for
the life of the connection, where before the memo it cost that one pane
and no other. The sharp end is the mismatch the note re-files on every
hit: probe the machine while the replace has uploaded but not yet
restarted, and the outgoing daemon is filed as a mismatch that every
later pane on that connection hears about, long after the replace the
user asked for fixed it. Both now run inside the guard, the note dropped
first, the way `wsl::replace_wsl_server` holds its distro's install lock.

That makes the hold as long as a replace, so the router's forget on a
silently closed link moves off the thread polling the route: it took the
same non-reentrant lock, and waiting there kept the client's half of a
link that was already gone open for as long as whatever held it.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:03:37 +08:00
l0ng-ai 8ee9474045 fix(daemon): leave the ring the modes it still carries
`replay_state` sends the mode fold ahead of the ring so the replayed
frames land in the buffer they were drawn for, and it sent every mode
that was on — including the ones the ring itself still switches on.
`?1049h` is a no-op in the emulator once the mode is already set, so the
ring's own copy then stopped clearing the alternate screen: everything
the ring holds *ahead* of that sequence, which is the shell scrollback
the user had behind the program, was painted into the alternate buffer
instead. That buffer keeps no history, so those lines were thrown away,
and the primary buffer the program's exit returns the client to was left
empty. Reconnecting a minute after opening `vim` is the ordinary case,
and it lost the prompt the user left behind.

Only the modes a replay of the ring cannot speak for go ahead of it now,
and they are read from a fold over the bytes the ring actually still
holds — so a sequence the front cut in half counts as lost, exactly as
it will for the emulator that reads the same bytes.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:02:13 +08:00
l0ng-ai 16b663a407 fix(procinfo): report a Windows table that would not answer, and stop crying wolf
Three things the new probe verdict still got wrong.

`GetExtendedTcpTable` refusing came back as an empty buffer, which read
exactly like a family with no listeners, so the Windows branch answered
`Ok` about ports it had never looked for — the silence #731 is about, on
the platform the panel was written on. `tcp_table` says `None` now, and
only "neither family answered" is `Unavailable`: a machine with IPv6 off
keeps its `Ok` and its IPv4 ports.

The `Unavailable` warning was written on every `QueryProcs`, and the
Info panel sends one every two seconds — thirty identical lines a minute
into a log that truncates itself at 4 MiB, which costs a reporter the
rest of the session they turned logging on to capture. Same reason, once
a minute.

And `Restricted` on Linux took `/proc/<pid>`'s owner for the process's
uid. The kernel hands that directory to root whenever it clears a
process's dumpable attribute, which is what executing a set-user-ID
binary or one carrying file capabilities does, so a plain `ping` in a
pane had the panel apologising for sockets it can read perfectly well —
the opposite mistake, and just as wrong. The `Uid:` line of
`/proc/<pid>/status` settles the few rows that look foreign and are in a
pane's tree, so an ordinary pane pays nothing for it.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:02:08 +08:00
l0ng-ai aa82dfade0 Merge pull request #827 from l0ng-ai/fix/ssh-repeating-password-prompt-820
fix(ssh): stop the password prompt coming back after a reconnect (#820)
2026-09-10 13:58:52 +08:00
l0ng-ai a249379bf8 fix(machine): format the three items and stop reading the tree per persist
`rustfmt` is a required check and was red on three hunks of this branch:
`owner_of`'s signature, the `--cwd` argv in `tab_new`'s adopt test, and the
two `tab_create` calls in `the_document_being_replaced_is_kept_beside_it`.

`keep_a_generation` also read the whole document before asking whether it
was going to keep anything. It runs on every persist — pane facts alone
flush every couple of seconds — and answers "too soon" on almost all of
them, so that was a full read of `machine.json` per write to produce one
copy every five minutes. The spacing check moves ahead of the read; the
`NotFound` arm still covers the machine that has never written a tree.

And a line continuation was missing from an assertion message in
`a_typed_name_waits_for_the_create_rather_than_racing_it`, so the failure
would have printed eighteen spaces mid-sentence.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 13:57:54 +08:00
l0ng-ai dcb3cd5eee fix(daemon): keep the newest mark's reading when suppression collapses a batch
`suppress_relayed_prompt_marks` compares everything but `mark_at_prompt`, so
that two marks the local editor can no longer tell apart still collapse into
one `Prompt` message. But `dedup_by` keeps the *earlier* of the pair, and a far
shell's whole turn can arrive in a single read: the previous command's `D`, the
prompt's `A`/`B`, then the next command's `C`. With `at_prompt` cleared across
the batch those two entries differ only in the mark's own reading, and the `C`
was the one being dropped — leaving `mark_at_prompt` standing at `true` while a
remote command was starting, which `PaneContext::at_prompt` reports and
`pane_freeness` answers `free` from.

The reverse order loses the prompt instead: a command that starts and finishes
inside one read collapses onto its own `C`, and the pane reads busy for as long
as it takes some unrelated output to arrive — which on an idle prompt is never.

Both need the same thing, so carry the reading onto the survivor before the
later entry goes. `command` only tells the pair apart when the far shell names
it; nushell's integration and several third-party ones emit a bare `133;C`.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 13:56:15 +08:00
l0ng-ai 2805478e44 Merge pull request #832 from l0ng-ai/fix/711-replayed-prompt-scrubs-the-alt-screen
fix(daemon): stop a replay claiming a prompt a running program contradicts (#711)
2026-09-10 13:55:31 +08:00
l0ng-ai c0366cd143 fix(cli): name the limit of the empty-segment fix, and settle the captures the e2e compares (#841)
CI found both new end-to-end cases red on Linux and macOS while green on
Windows. One was a bad assumption in the test; the other was the test
telling me the fix is narrower than the first commit claimed.

The real finding is the resize case. On Unix a resize raises SIGWINCH and
the shell repaints its prompt, so the segment the resize opens is *not*
empty — it holds the repaint. Dropping byte-less segments therefore
leaves the newest non-empty segment being a bare prompt, with the pane's
output still stranded in the segment sealed behind it: on CI the default
form came back as 174 bytes of prompt escapes and no marker. On Windows
nothing answers the resize, the segment stays empty, and the fix reaches
past it to the output — which is why the assertion passed there. It was
asserting an accident of the platform.

So the fix stands but is smaller than "a resize no longer costs you the
pane's output": it makes the zero-byte answer impossible, and that is
all. It cannot do more. Nothing in the byte stream distinguishes a prompt
repaint from output the pane meant, so no client-side rule can tell which
side of the boundary the answer is on. The boundary is the flaw — the
default form's unit is the last resize, an event in the window rather
than in the pane — and moving it means redefining what the default
returns (the last screenful of the ring, say), which would shrink what
every caller with a never-resized pane gets today. Left alone, and said
plainly instead: in `what_was_asked_for`'s doc comment, and in a warning
in the CLI reference telling anyone reading a pane under the GUI to ask
for `--scrollback`.

The test now asserts what the fix actually guarantees on every platform —
the default form answers with bytes rather than with the resize's
placeholder, and it is the end of what `--scrollback` returns, which is
what would catch a fix reaching for the wrong segment. The marker is
pinned against `--scrollback`, the form that promises to hold it.

The `--tail` failure was a race in the test, not in `--tail`: the whole
and the tail were separate calls and the pane advanced between them, so
the tail carried a prompt line the whole capture had not caught up to.
Both cases now read the whole answer on either side of the others and
require the two readings to match before comparing anything, which is
what makes the comparison a statement about the code rather than about
the moment. Both also read `--scrollback` now, so neither depends on
where a segment boundary happens to fall, and neither pins exact pane
content — the macOS runner prints a zsh banner into the pane.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 12:05:51 +08:00
l0ng-ai 48d712143e fix(cli): answer wait --until free on remote and SSH panes (#840)
`pane_is_free` read the pane's local process tree and folded two very
different answers into one `false`. On a pane that is only the near end
of a connection that tree describes the tunnel: a pane routed to a
remote daemon has no local pty at all, so `DaemonPane::procs` returned
`Default::default()` and the tree came back empty; a pane whose shell is
running `ssh` has a tree whose depth-1 process is the `ssh` itself, busy
for exactly as long as you are logged in. Either way `free` was
unreachable structurally, `seen_busy` was set on every poll — which
suppressed the one hint that would have pointed at the gap — and the
wait rode the whole `--timeout` before recommending `--until free`, the
flag that had just failed.

Freeness is now three-valued: free, busy, or "nothing here can answer",
and the last one carries its reason.

On a remote pane freeness is the far shell's own OSC 133 prompt marks.
That is sound because the near shell cannot be at a prompt while the
connection owns its pty, so a prompt mark on such a pane can only have
come from the far side. Two things had to change for the daemon to be
able to say it. The reader suppresses relayed prompt marks so a
foreground program cannot engage the local line editor — right for the
editor, and precisely wrong here — so `ShellState` now keeps the mark's
own unsuppressed reading beside the editor's. And "not at a prompt" on a
remote pane means nothing until the far shell has proved it reports at
all, since the newest mark is otherwise the near shell's own "I started
`ssh`", which nothing will ever supersede; a latch records the first
prompt mark that arrives while the pane is remote, and is cleared on
every hop. `PaneProcs` carries all of this to the CLI in a new optional
`context` — remote target, whether this machine holds the pty, the
mark's reading, the latch — so the answer still costs one request per
poll and an older server, which omits the field, keeps today's
tree-only behaviour.

When the far host has no shell integration the honest answer is that
this machine cannot tell an idle remote prompt from a running remote
command. `wait` says so — exit 1, `status: unknown`, a `free_unknown`
string naming the host — after one poll of grace for a handshake still
in flight, and only when `free` was the only state that could still
answer, so `--until done,free` keeps waiting on `done`. Without that it
would hang forever on a wait with no `--timeout`. The `no-agent` timeout
hint now fires only for a caller who did not already pass `--until
free`, and the reason freeness never resolved is printed and put in the
JSON in its place.

Deliberately left alone: on a local pane the process tree still holds
the verdict. A prompt mark can only turn a busy tree into free — which
is what fixes a plain `ssh` pane on Windows, where the daemon has no way
to name the pane as remote — never the other way round, so no pane that
reads busy today can start reading free because an integration went
quiet. `free` therefore now means "will take input" rather than strictly
"back to the bare shell": a pane sitting at a nested shell's prompt is
free, and the reference says so. The handoff record is unchanged, so a
pane mid-`ssh` that survives an exec comes back reporting "cannot
determine" until the far side's next prompt rather than guessing.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:54:46 +08:00
l0ng-ai 5ac26b5dd9 feat(cli): add capture --tail N (#841)
Every "how did the last command end?" query went through `| tail -n 5`,
which ships the whole grid down a pipe to throw most of it away — and on
Windows, where `capture` is just as useful, there is no `tail(1)` to pipe
to. `--tail N` keeps the last N lines of the answer instead.

It is a third independent choice beside `--scrollback` (how much of the
ring) and `--plain` (in what form), and it composes with both. The trim
runs last, after `--plain` has decided what a line is: a shell line the
pane wrapped over three rows is one line to the grid and three to a byte
counter, so `--plain --tail 1` hands back the whole of the last line
rather than its final row. The raw form counts the same way `tail` does,
splitting on the newline and leaving the CR of a CRLF attached to the
line it ended, so the bytes stay the pane's own. A trailing newline
terminates the last line rather than opening an empty one, which is the
difference between `--tail 1` answering the last line and answering
nothing.

`N` must be at least 1: a tail of zero lines would print an empty result
that reads exactly like the blank-pane ambiguity this issue is about, so
it is a usage error (exit 2) instead. `--json` reports the tail in `text`
but leaves `bytes` the size of the whole replay, so the pair still says
"this was trimmed" rather than "this came back short".

Left alone deliberately: the daemon still replays the entire ring on
every observe, so the saving here is the pipe, not the wire. Bounding
what crosses the wire means teaching `ClientMsg::Observe` a limit and
versioning the protocol for it, which is a much larger change than the
papercut warrants — and the default (newest-segment) form has always
received the whole ring and discarded most of it, so this adds no new
cost. The docs say so rather than implying otherwise.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:41:32 +08:00
l0ng-ai 0752110b54 fix(cli): stop a resize from emptying capture, and say when a replay renders blank (#841)
`tty7 capture %n` answered a live pane with zero bytes and exit 0, which
is byte-identical to a pane that had genuinely never printed. The report
guessed at the replay grid. It is not the grid — it is the segment the
grid was handed.

The daemon's replay ring splits on resize: `ReplayRing::resize` seals the
segment holding the output and pushes an empty one at the new geometry,
and `ReplayRing::replay` sends every segment it holds, empty tail
included. `RealBackend::capture` then kept "the newest segment" for the
default (non-`--scrollback`) form, and for any pane resized since it last
printed, the newest segment is that empty placeholder. A pane restored
from disk lands in the same state, because seeding the ring ends in a
resize too.

Measured end to end against a real daemon before the fix: a pane holding
954 bytes of scrollback answered `capture` and `capture --plain` with 0
bytes and exit 0 while `capture --scrollback` returned all 954. The fix
drops byte-less segments before choosing the newest one — they carry
nothing in either form, so `--scrollback` drops them too and both forms
describe the same bytes. The daemon still sends them: its trailing `Size`
is how an attaching client learns the pane's current geometry, and that
is not the CLI's to take away.

What this does not explain is the reporter's other half — that
`--scrollback` came back empty in the same episode. Filtering empty
segments cannot cause that, and neither could I reproduce it: 60 rounds
of `capture` against a pane spewing 4000 escape-laden lines produced no
empty result, and an 8 MiB ring (the cap) replayed whole in ~400 ms, well
inside the 300 ms per-frame settle window that was the other suspect. So
the silent-empty class is left distinguishable rather than declared
closed: `--json` now carries `bytes`, the size of the replay counted
before anything renders or trims it, and the one case where an empty
answer is not an empty replay — bytes in, no text out — says so in a line
on stderr. Zero bytes is a pane that printed nothing; bytes with no text
is a screen whose content did not survive the grid. Deliberately no retry
loop and no warning on a condition that has not been established.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:41:32 +08:00
l0ng-ai b559ae658d fix(daemon): clear the dead TUI's input modes on restore (#850)
A restored pane came back with the killed program's mouse reporting still on,
so every pointer move over it was typed into the new shell's line as an SGR
report and the line grew for as long as the pointer stayed there.

The chain is short and each link is deliberate. A snapshot is a raw byte
stream, not a rendered grid, and `scrollback.rs` says so in its own module doc.
`ReplayRing::seeded` puts those bytes into the restored pane's ring verbatim,
and the client hands them straight to its parser at `DaemonMsg::Snapshot`. So
the dead program's `?1002h` is not a description of a screen, it is an
instruction, and it is executed a second time against a shell that never asked
for it. There is nothing after it to undo it: `DaemonPane::kill` is a hangup
with no grace period, so the program never emitted its own `?1002l`, and the
snapshot was photographed before the kill anyway. `restore_preamble` already
exists to keep exactly this kind of leftover away from the incoming shell — it
resets the alternate screen, the cursor, autowrap and the graphic rendition —
and it simply stopped short of the modes that make the terminal talk back.

It now also turns off mouse reporting and its encodings, focus reporting,
bracketed paste, application cursor keys and the kitty keyboard flags.

Unconditionally, which was the choice worth making. The alternative was to fold
the snapshot's bytes through a mode tracker and clear only what the fold says is
still on, which is what #828 does for the neighbouring re-attach path. That
precision is essential there and buys nothing here: there the pane is alive and
re-sending a mode it had turned off would be wrong, whereas here the target
state is a constant. The incoming shell is brand new, it has not written a byte
when the preamble is appended, and it asked for none of these modes, so there is
nothing to preserve and no mode whose value has to be discovered. The risk is
not symmetric either. Switching off a mode that is already off is a no-op in
every emulator, so the blunt version cannot fail; a fold that misparses one
sequence leaves the mode on and the bug exactly as it is today, silently. The
blunt version also needs nothing from #828, so this can merge in either order.

Left alone on purpose: `?1007` alternate scroll, which a default terminal has
on, so clearing it would walk away from the default rather than back to it;
`?2026` synchronised update, which the client's processor already closes out
when a replayed frame ends inside one; and `stale_mode_resets` in the client,
whose conditional shape is right for the live pane at a prompt that it serves.
Scrolling regions and origin mode are a different failure with no report behind
them and are not touched.

The test that locked in the short reset list now locks in the long one, and
asserts the `?1007` omission so it stays deliberate. A second test runs the
real chain — a snapshot ending inside a full-screen program, `ReplayRing::seeded`,
the preamble — and asserts that the ring a client would replay has `l` as its
last word on every reporting mode. Both fail on the previous preamble.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:23:25 +08:00
l0ng-ai b90b903527 fix(daemon): stop a replay claiming a prompt a running program contradicts (#711)
A pane that came back from a workspace switch came back empty. All that
was left was the shell banner and the `claude --resume … --fork-session`
line the pane was born with; the agent above it was still running and
still painting, but only in fragments, into an otherwise blank screen.
Switching tabs did nothing. Resizing the window fixed it, and only for
the tab that had focus, which had to be repeated tab by tab.

Nothing is lost on the wire. The replay arrives whole — every segment,
every byte — and is applied. What happens is that the client throws the
result away three frames later.

`replay_state` closes an attach by reporting the pane's shell state, and
a client that hears `active && at_prompt` scrubs the TUI modes it finds
in its grid, the alternate screen first (`stale_mode_resets`). For a
live report that is sound: a shell that is prompting cannot have a
full-screen program underneath it, so a `?1049h` still set in the grid
is residue from one that died without its `?1049l` — an `ssh` dropped
mid-`vim` — and sending the `?1049l` is the repair.

For a replayed report it is not sound. The alternate screen the scrub
finds there is the one the ring has just rebuilt, and `?1049l` does not
undo a stale mode: it swaps a live screen away and puts the primary
screen back in its place, which for an agent pane is precisely the
banner and the launch command. The program never learns any of this. It
goes on sending differential updates — only the cells it believes
changed — into a grid that no longer holds what those updates are
differences from, which is why the status line came back as fragments
with the middle blank. And a resize is the one thing that repairs it,
because a resize is the one thing that reaches the child: new geometry,
`SIGWINCH`, a full repaint. A tab switch reports geometry the daemon
already has, so it repairs nothing.

The stale claim comes from `st.shell.at_prompt`, which is only ever as
fresh as the last OSC 133 mark the pane produced. A shell that printed
its prompt (`133;B`) and then handed the terminal to a program that
sends no `133;C` of its own leaves that flag set for as long as the
program runs. The live path already declines to believe such a mark: the
pane reader drops `at_prompt` from any prompt mark that arrives while a
foreground command owns the pty. The replay was the one place that
re-asserted the stored value with no check at all.

It now asks the pty the same question, once, before it takes the state
lock, and reports a prompt only when nothing but the shell owns the
terminal. That keeps the scrub's purpose intact — when the shell really
is prompting there is no foreground command, the report goes out
unchanged, and a genuinely stranded alternate screen still heals on
reattach — while removing the case where the report contradicts a
program that is still there.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:20:14 +08:00
l0ng-ai 29312d5bb8 fix(procinfo): stop the listening-port probe failing silently (#731)
The Ports section says nothing when a pane has no listeners, and it said
exactly the same thing when the code that looks for listeners never ran.
#731 is a report from inside that gap: a Go service started with `go run
main.go` on macOS serves requests, and tty7 shows no port.

The reported shape itself holds up. `snapshot()` walks the whole descendant
tree from the pane's shell, `go run`'s compiled binary sits at depth 2, and
the `lsof` invocation is the one that finds it. What did not hold up is
everything around that.

The walk stopped at 64 processes, and it is depth-first over children in
ascending pid order. A shell whose earlier children brought a crowd — a
build, a container runtime, an agent's worker pool — could spend the whole
budget before the traversal reached the newest child, and the newest child,
highest pid and visited last, is precisely the server someone started ten
seconds ago. The walk now runs to a far larger bound and the probe is asked
about all of it; only the list handed to the panel is cut back to 64 rows.

Every way the probe can fail arrived as the same empty vector. `lsof`
missing from the daemon's PATH read as "nothing is listening" — and the
daemon's PATH is not the shell's, while macOS keeps `lsof` in /usr/sbin,
the sort of entry a hand-written `export PATH=...` drops. So did a probe
that hung: `Command::output` has no deadline and this runs on the thread
answering `QueryProcs`, so one `lsof` wedged on a dead mount takes the whole
pane's process list with it, permanently. The probe now falls back to the
absolute paths, is bounded at three seconds, and says which of those
happened.

A server under `sudo` is visible as a process and invisible as a socket:
`lsof` running as this user cannot read another user's fds. The walk now
carries each process's effective uid, and a tree holding someone else's
process says so rather than claiming the pane is quiet.

`PaneProcs` grew a `probe` verdict, `serde(default)` so an older
`tty7-server` at the far end of a remote workspace still parses and its
silence still reads as a complete answer. The panel spends it on the one
muted line where it used to write "None", and `tty7 procs` — the command
the issue asks reporters to run — prints a note under the empty PORTS
table. No banner and no new colour: an honest empty state, not a warning.

Deliberately left alone: `lsof`'s exit status. It returns 1 for a pid it
could not locate, and a pane's tree loses processes between the walk and the
probe as a matter of course, so reading that as a broken probe would put a
doubt on screen every time a command finished. A non-zero exit that also
found nothing gets a debug log line and no more. The Windows path is
untouched beyond its new return type — `GetExtendedTcpTable` has no tool to
be missing and no subprocess to hang.

I could not reproduce #731, and none of these is proven to be the
reporter's bug. Each is a way the panel could be silently wrong, and the
verdict is what will make the next report say which one.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:13:51 +08:00
l0ng-ai 97c1cb9b44 feat(cli): re-home an orphaned pane with tab new --pane (#716)
A pane can come out from under its tab with its shell still running — an
interrupted `tty7 run`, a `ws rm` that could not hang everything up, or a client
that closed nineteen tabs whose shells were all alive (#716). `pane ls --all`
has been able to *show* those for a while, but everything the CLI offered to do
about one was to kill it: `pane close %<id>`, or `pane close --orphans` for the
lot. The shells were fine. There was simply no verb that put one back on screen,
so recovering meant recreating tabs by hand and reaping the originals.

`tab new` grows a `--pane` that builds the tab around a pane that is already
running instead of spawning a shell for it. Nothing new had to be invented on
the wire: `ControlRequest::TabCreate` has always taken a `PaneSeed` with a pane
id in it, which is how `run --keep` files its pane into a tab.

The part that needed designing is where the seed comes from. `tab_close`
retains the orphaned panes out of `m.panes` at the same moment it drops the
tab, so by the time anyone wants a pane back the tree has already forgotten its
record — cwd, title, shell. Reading the seed off the tree would therefore work
for an interrupted `run` and fail for exactly the case this verb exists for. It
is rebuilt from the live pane registry instead, which still has the pane
because the pane is still running, and which is the same list `pane ls --all`
walks. That does mean `ssh_spec`, `agent` and `shell` are not recovered — the
registry never carried them. They cost nothing while the shell lives, since the
tab is a view onto a pty that is already there, and only matter if the pane
later dies and something tries to restore it from the seed. Reconstructing them
from a running pty is a different problem; a tab you can see beats a shell
nobody can reach.

Two refusals rather than one guess: a pane the server is not running cannot be
re-homed, and neither can one a tab already holds — that is what `pane split`
is for, and accepting it would put a single pane in two places in the tree. With
no workspace named the pane goes back to the one it was spawned for, which is
the `owner` that `pane ls --all` already prints; `$TTY7_WS` cannot help here,
because a shell recovering from this is by definition not inside tty7.

The `pane ls --all` footer now names the way back as well as the two ways to
kill, since the listing is where an orphan is found and so is where the recovery
has to be written down.

Deliberately not done here: the switcher's orphan rows still carry only a Close
button. Adopting from the GUI is not the same one-line change — the row lists
orphans machine-wide while a window speaks for one workspace, and a pane may
only be attached by the workspace that owns it, so the button has to decide
where the tab goes before it can build one. That is its own piece of work.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:10:09 +08:00
l0ng-ai cd1353fd98 fix(ssh): stop the password prompt coming back after a reconnect (#820)
Three things had to line up for a password sheet that keeps reappearing
after the connection has already succeeded, and cannot be closed. Two of
them are here; the third was the reason closing it never helped.

The connection cache keys one slot per host, and that slot's mutex is
what makes a reconnect ask for a password once rather than once per pane
— everyone dialling the same host queues on it, and all but the first
find the connection the winner left behind. Eviction removed the map
entry outright. A dropped TCP connection kills every pane riding it at
the same instant, so all of them reach the dead-reuse branch in
`run_session` together: the first removed the entry, its `open_connection`
inserted a fresh mutex, and the pane a moment behind it removed *that*
one — the mutex a handshake was already holding — and inserted another.
Each pane ended up alone on a mutex of its own, ran its own handshake and
raised its own prompt. Answer one and the link comes up; the rest are
still queued behind it. Eviction now empties the slot instead of
replacing it, and leaves a slot somebody is dialling on completely alone.
The entry outliving its connection is what the map already looked like
everywhere else, and `routes()` has always reported such a slot as
disconnected rather than omitting it.

The second is what happened on close. A declined prompt failed only the
method that raised it, and `authenticate` walked on to the next one — but
`password` and `keyboard-interactive` are one question asked two ways, and
a server offering both wants the same secret either way. Closing the
password sheet put a keyboard-interactive sheet up asking for the same
password. Nobody declines a *method*, so a declined prompt now ends the
attempt, with a reason distinct enough for a caller to recognise.

That caller is the workspace supervisor, which reconnects on a clock and
treated the refusal as a transient failure: it dialled again a second
later, and every thirty seconds after that, for the rest of the session.
It now suspends the machine instead. The strip already says why and
offers Retry, which is the user asking to be asked again.

A key passphrase is left alone deliberately. Closing that sheet declines
one key, and the methods still to come ask a different question — someone
who cannot remember a passphrase is usually closing it precisely to be
asked for a password instead. So is the fact that a password is only kept
across reconnects when "Remember" was ticked: that is a choice about
storing a secret, not a bug.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:09:32 +08:00
l0ng-ai 237b2a14c4 fix(daemon): restore a pane's terminal modes on re-attach (#774)
A pane's screen comes back on re-attach out of the replay ring, and the ring is
a window: eight megabytes wide, dropped from the front as it fills. That is the
right shape for text, which is only worth what is still on screen, and the
wrong one for modes. A full-screen program announces itself exactly once —
`btop` sends `?1049h` and its mouse-reporting modes when it starts and then
does nothing but refresh — so a long enough run of refreshes pushes the only
copy of that announcement out of the front of the ring. What the client
replays is then a screenful of alternate-buffer frames with nothing left to say
they belong on the alternate buffer: it paints them onto its primary screen
with reporting off, and `wheel_route`, which reads exactly those modes, sends
the wheel to the scrollback of a screen that has none. That is the "a screen
that should not scroll starts scrolling" in the report.

`replay_state` already refuses to rely on the ring for anything that matters —
cwd, prompt state, the remote context, the agent, the exit — because all of
those are facts about the pane rather than bytes on it. The modes are the same
kind of fact and were the exception, so the daemon now folds the bytes it hands
the ring into a small tracker (`core::term_modes`) and `replay_state` re-sends
what is still on. Tracked are the modes that decide input routing or which
buffer is on screen: the alternate screen in its three spellings, the mouse
reporting level and its encodings, alternate scroll, DECCKM, focus reporting
and bracketed paste. They are replayed in the order the application set them,
because the emulator treats the reporting modes as a level and not as
independent bits, so the last one set has to be last here too.

The frame goes *ahead* of the ring rather than after it. That way the replayed
frames are painted into the buffer they were drawn for, and re-entering an
alternate screen the ring turns out to still carry is a no-op in the emulator,
so a prefix and a ring that both carry the mode cannot fight. Where the ring
does still carry a toggle it wins on its own terms, since the fold runs over
every byte the pane ever wrote and therefore agrees with the ring's last word
on any mode the ring still mentions.

Cursor visibility (`?25`) and autowrap (`?7`) are deliberately not tracked. Any
frame of a running TUI repaints them within milliseconds, whereas restoring a
stale `?25l` would leave a shell with an invisible cursor — a worse failure
than the one being fixed, and one the existing `restore_preamble` already goes
out of its way to avoid. A daemon handoff starts the fold empty rather than
carrying it, so a pane adopted across a daemon restart is no worse off than it
is today; the ring it carries is all it ever had.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:09:15 +08:00
l0ng-ai dbef42a03d fix(machine): keep earlier generations of the machine tree (#716)
`MachineStore::persist` serialises the whole document and lands it with
`write_atomic_private`, so the file on disk is never torn — but the document it
replaces is simply gone. The tree is rewritten whole on every mutation, which
means the write that loses a layout is also the write that erases the only copy
of it. That is what turned #716 from an annoyance into a lost afternoon: a
client's arrival queued nineteen `TabClose`s, each one persisted, and by the
time anyone read `machine.json` there was nothing anywhere on the machine that
remembered what the workspace had looked like.

`persist` now rotates the document it is about to replace into a small ring of
backups beside it — `machine.json.bak`, then `.bak.1` and `.bak.2` behind it —
before the new one is written.

Two things decide whether such a ring is worth anything. The first is what
"previous good" means. It cannot mean "the last document that parsed": an
emptied tree parses perfectly and is exactly the state you want to recover
*from*, so validity is no signal at all. Age is the only signal available, so
the ring is spaced: a generation is taken only when the newest one is at least
five minutes old. Without that spacing the failure mode above — a burst of
writes seconds apart — would have rolled three copies of the damage through the
whole ring before a human noticed. With it, the oldest generation is a quarter
of an hour of history, and three generations is where the ring stops so a file
rewritten every few seconds does not grow a history without bound.

The second is the atomicity of the rotation itself, since a rotation that can
lose both copies is worse than none. The live file is never renamed, only read:
at every point in `keep_a_generation` the tree is still completely at its own
path, and the new generation lands through `write_atomic_private` — a sibling
temporary renamed into place, which also means the copies inherit the 0600 the
live tree is written under rather than widening anything. A crash mid-rotation
costs at most one backup generation and never the tree. A backup that cannot be
written is logged and the new document is persisted anyway; a machine with no
backup still has to work.

`load_machine` now also falls back to the newest generation that parses when the
live document is unreadable or corrupt, instead of starting from an empty tree.
Deliberately only for those two cases: a tree that parses always wins, however
empty it is, because that is the case a human has to judge — which is why the
files are plain JSON under obvious names, ready to be copied back by hand.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:00:28 +08:00
l0ng-ai 8adebdf24d perf(ssh): prove the remote server once per connection, not once per pane (#695)
Opening a second tab on a machine tty7 was already connected to and already
serving cost the same wait as the first one. The SSH connection is reused, so
none of that wait was handshake cost: every route called `ensure_remote_server`
unconditionally, and that runs the whole installer probe again — `uname -sm`,
an SFTP realpath for the home directory, an SFTP stat, a control probe that
spawns the server binary, and `check_running_build`, which walks `/proc/[0-9]*`
with a `readlink` per PID and shells out to `ps` on the machines that have no
`/proc`. Five serial round trips before the pane's own channel opened, to
re-learn what the pane before it had just learned.

WSL fixed exactly this in #479 by remembering where a distro's server was last
proved to be. SSH now does the same, with one difference that matters: a distro
name is the whole identity of a WSL target, but an SSH connection can die and
be replaced under the same key, so the note is kept on the `SshConnection`
rather than in a map beside its key. Keying by connection generation is then
not a discipline anyone has to keep — a reconnect is a new `SshConnection` with
an empty slot, and nothing has to remember to forget.

Memoizing must not quietly cancel the version check, which is the one thing
that could make this a bad trade. Three things keep it honest. The note carries
the build mismatch the probe found and re-files it on every hit, because the
warning is raised inside `Installer::run` and each route drains its own sink —
without that, only the first pane on a connection would ever hear that a
different build is serving the machine, and every window after it would attach
in silence. `replace_remote_server` and `restart_remote_daemon` forget before
they act, not after, so a restart that fails halfway leaves the next pane
looking rather than trusting a note written before the upheaval. And the router
forgets when a routed link closes without the remote sending a byte, the way it
already does for a WSL bridge: `exec` succeeds whatever the command turns out
to be, so a binary deleted or moved since the probe is discovered exactly
there.

A failed probe is deliberately not remembered. A host that was briefly
unreachable, or an install the user declined once, must not pin every later
pane on that connection into the same failure — the slot is written only when
the probe got all the way through. The note carries the binary path and the
mismatch and nothing else: `installed`, `launched` and `confirmed` describe an
event rather than a state, and serving them again to a later pane would only
make the log lie.

Left alone on purpose: the probe itself, which is unchanged and still the only
thing that decides what a pane runs; the WSL memo, which keeps its own shape;
and the macOS-server half of #695, which shipped in v26.9.1.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 17:54:45 +08:00
l0ng-ai ab5752e27b fix(daemon): notice a pane came home from ssh without waiting for output
The foreground probe that clears a pane's remote context only runs when
the reader thread has bytes in hand. The prompt a shell draws after a
command is the last output a pane produces until the user types again, so
an `ssh` that exited inside the poll interval left the pane reporting
itself as remote indefinitely — nothing came along to probe on.

Everything keyed off that context stayed on the far end. Most visibly the
history scope: ↑ read the remote list, which for a host with no history
of its own is empty, so ↑ appeared dead until some unrelated output
arrived. Pressing Enter looked like it unblocked the pane because an
empty command is the cheapest way to make output.

A prompt mark that survives the foreground suppression is the shell
saying the command it ran is over, so the foreground has just gone back
to being the shell itself. Probe right then instead of waiting out the
interval.

Switching history scopes also dropped the list it was leaving, and the
reload that refills it is a background task, so ↑ had a second window of
recalling nothing. Park each scope's list instead, capped at four, and
step back into one instantly.

Claude-Session: https://claude.ai/code/session_01Mnerr8RZ23Nd4cxyfeqxiu
2026-09-09 11:10:27 +08:00
l0ng-ai a077d47051 fix(daemon): let the reap wait for a seat that is coming back
`reap_stranded_clears_a_seat_holder_with_no_pidfile` failed on macOS CI
often enough to red a PR that touched nothing near it: after a confirmed
reap the lock file still named the dead holder.

A seat is not free the same instant its holder is confirmed dead. The
kernel releases the lock while tearing the process down, and any
descriptor a `fork` left behind holds it a moment longer — BSD `flock`
counts an inherited descriptor as another reference to the one lock
rather than a second lock, which this module already records on the
claim side (`a_reference_a_forking_neighbour_left_behind_does_not_lose_the_seat`,
and the retry loop `claim_within` exists for). `clear_record_if_free`
had no such patience: one `EWOULDBLOCK` ended the attempt, and nothing
ever revisits the file, so the dead pid stayed in it for good — where a
later pre-recording build holding the seat would make that number, by
then possibly reused, read as the holder.

It now retries for 500ms. The rule it enforces is unchanged: the record
is truncated only while the kernel says the seat is free, so a live
holder that outlasts the grace still keeps its record.

The guard was measured both ways — with the grace at zero
`clearing_the_record_waits_out_a_seat_that_is_about_to_come_back` fails
with exactly the assertion CI reported.

Claude-Session: https://claude.ai/code/session_01Mnerr8RZ23Nd4cxyfeqxiu
2026-09-08 23:12:10 +08:00
l0ng-ai ea7543490a Merge pull request #811 from l0ng-ai/feat/remote-ports
feat(ports): detect and forward what a remote pane is serving
2026-09-08 21:56:23 +08:00
l0ng-ai aafd26de72 fix(ports): restore three doc comments and name the right machine
Three items were inserted between an existing doc comment and the item it
described, so `bind_failed`, `action_strip` and one test each lost their
documentation to the newcomer above them — and the peer-procs test carried
two `#[test]` attributes as a result.

The Ports panel's fallback said "This machine's tty7-server is too old",
which reads as the local one; the server that cannot answer is the far
side's.

Claude-Session: https://claude.ai/code/session_01TPXrptp2rCGKjXaz4xE3Lq
2026-09-08 21:40:18 +08:00
l0ng-ai 03c3081a6e feat(ports): detect and forward what a remote pane is serving
A remote workspace's ports were never listed. The pane lives in the
peer's registry and QueryProcs asks this machine's daemon, which has
never heard of it, so the answer was an empty list — indistinguishable
on screen from a pane serving nothing. Add a control request so the peer
answers instead, gated on a feature so an older server says "I cannot
tell you" rather than "nothing is listening".

With the ports visible, the forward becomes something the user should
not have to think about: a port opens on a click, and a new one is
forwarded unasked, at the same number where that number is free here.
The watch runs with the panel shut, which is when a port appearing is
most worth saying something about.

Ports and Forwards were two sections that never mentioned each other; a
row is now a port, and the forward is where that row says it comes out.
Adding one by hand asks for one number instead of five fields, with the
rest of the ssh -L grammar one disclosure away.

Claude-Session: https://claude.ai/code/session_01TPXrptp2rCGKjXaz4xE3Lq
2026-09-08 21:01:50 +08:00
l0ng-ai 9842fddd4c Merge pull request #810 from l0ng-ai/feat/sidebar-custom-groups
feat(ui): custom sidebar groups a tab can be put in by hand
2026-09-08 17:44:41 +08:00
l0ng-ai b063f93431 Merge origin/main into feat/sidebar-custom-groups
main's #806 changed the fold test while this branch changed
toggle_sidebar_group to take Option<&GroupKey>; the textual merge left
one call site on the old signature. Fixed here.

Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
2026-09-08 17:29:10 +08:00
l0ng-ai 5c2a02f064 fix(ui): give an agent's mark one colour, not one per draw site
Two places draw an agent's mark and each decided its colour on its own:
the tab strip took a `TraeCode` branch added in #807, and the tray icon
recoloured every glyph white regardless. So TraeCode came out green on a
tab and white in the tray — one agent with two faces.

The colour now lives on `CLIAgent::icon_rgb`, next to `accent_rgb`, and
both sites read it. White for every mark that is a silhouette on its
brand colour; TraeCode is the one whose mark carries the colour itself.

`the_tray_draws_each_mark_in_the_agents_own_colour` walks `CLIAgent::ALL`
and asserts the tray's solid pixels contain the agent's own mark colour,
so a future agent whose mark is not white cannot be added to one draw
site alone.
2026-09-08 17:03:41 +08:00
ayamir fc94022ed0 feat(agent): add TraeCode CLI support (#807)
* feat(agent): add TraeCode CLI support

* fix(settings): index TraeCode agent hooks
2026-09-08 16:57:04 +08:00
l0ng-ai e7e5520411 refactor(ui): give a sidebar group a stated kind, not just a path
A sidebar group was a bare `Option<PathBuf>` the sidebar recomputed from the
tab's cwd every frame. That is right for a group the sidebar derived and wrong
for one a user states by hand: the probe overwrote whatever it found, so a
hand-placed tab would be dragged back to its repo on the very next frame.

`GroupKey` names which of the two a key is. `Repo` stays the probe's to change;
`Custom` is never recomputed. One enum rather than a path plus a `pinned` flag,
because the flag and the path are then free to disagree — and because a custom
group's name is not a path at all, which is the second half of this: it must
skip `group_names`, whose job is to lengthen repo roots until they stop
colliding. Run a name through it and `work/urgent` prints as `urgent`.

The flat spelling the protocol, the session file and the fold list all need
marks a custom group with a `custom:` prefix. No absolute path can collide with
it, and every group written before this decodes as a `Repo` — so old sessions
and old daemons keep working untouched.

Under `SidebarGrouping::None` a custom group hides like everything else. It is
tempting to exempt it, but `sidebar_sections` gives Scratch a header as soon as
any keyed group exists, so exempting one would draw two headers for a user who
asked for none. The key stays on the tab; switching grouping back on brings it
straight back.

No custom group can be created yet — this is the data layer alone.

Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
2026-09-08 15:52:16 +08:00
l0ng-ai 4c02ad3881 feat(ui): fold sidebar groups; stop the diff header clipping its controls (#804)
A sidebar group folds shut when its header is clicked, and stays shut across
launches. A search outranks the fold — a row a live query matches shows
whatever its group says — and so does the active tab's own row, which a fold
must never hide: `spawn_group` seeds a new tab with the group it came from, so
otherwise Cmd-T inside a folded group would draw nothing but a header count
going up by one. Folded rows register no rectangle, so a pane cannot be dropped
into a group that is shut, and the header still counts every row the group has.

Separately, the diff overlay's header stops clipping its own controls. Every
element on it but the commit subject was `flex_shrink_0`, and one of those was
a focused file's path, which is unbounded — in a docked column the two things
you can actually click were pushed off the end and clipped mid-word. The path
and the byline now yield first, ahead of the subject, because each has a second
home elsewhere in the overlay and the subject has none; the path yields
head-first so the filename is the last thing to go. `split_path_leaf` moves to
`path_display` with its tests, which is where a path's display spelling lives.
2026-09-08 12:13:03 +08:00
l0ng-ai 28530a476a fix(git): key a repository by one spelling of its root (#796) 2026-09-07 22:55:01 +08:00
l0ng-ai c125803077 feat(daemon): list a Windows pane's listening ports (#787) 2026-09-07 22:31:23 +08:00
l0ng-ai dac70ec4c8 perf(protocol): put a frame's header and payload on the wire in one write (#797)
Refs #713.
2026-09-07 22:11:06 +08:00
l0ng-ai 893172f57d fix(control): raise the dialect to v9 rather than walk it back to 7
Reverting the projects layer took CONTROL_VERSION back to 7 with the
verbs it had been raised for. The dialect that number describes is
correct — this build speaks v7's messages again, one for one — but the
number is not, because v8 is already deployed.

A version that moves backwards stops being an identity. A 7 on the wire
would mean "before projects" or "after them" depending on which build
sent it, and the handshake has nothing but the number to tell those
apart: a v8 peer would read our 7 as an older server it may keep
talking to, and every project verb it pushed would fail to decode and
drop the link.

v9 is a number no peer has seen, so a v8 peer is turned away at the
handshake instead. Nothing else changes; every other reference to the
constant is relative to it.

Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
2026-09-07 11:24:37 +08:00
l0ng-ai 31ce382226 revert: drop projects as a declared sidebar layer (#769)
Reverts cebd871c. The declared layer sat beside the derived repo groups
rather than replacing them, so the sidebar carried two kinds of heading
that look alike and behave differently, plus the verbs to create, rename,
re-root, reorder and delete one. That is more interface than the problem
was worth, and the derived grouping already covers the common case.

Everything the commit touched goes back: the Project entity on the
machine, the per-tab reference, the six control verbs and their layout
deltas, the sidebar rail and tab strip surfaces, the mirror and tree-sync
paths, and the i18n strings.

CONTROL_VERSION drops back to 7 with the verbs it was raised for. A
remote box still serving the v8 build will be turned away at the
handshake until its tty7-server is replaced.

Sessions written while projects existed still load: `projects` and a
tab's `project` are unknown fields now, and serde drops them.

Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
2026-09-07 11:01:19 +08:00
l0ng-ai be2eb4c086 fix(daemon): clear a stale pane socket before binding it (#779)
A daemon that died without unlinking its Unix socket stopped every later
daemon on that machine from ever starting. The client launched one, it
exited on the bind, the client launched another, forever.

run_with does clear a stale endpoint, but only after a probe it skipped
whenever the recorded daemon was known dead — reasoning that the bind below
would overwrite the file. That is true of a Windows port file, and the whole
of #639, where the skip came from, is Windows ports: it added a connect
timeout so a firewalled stale port fails fast. On Unix the endpoint is a
socket file and bind refuses any path that exists, so the one path that
reached the bind with a leftover still there was the one that skipped its
removal.

What decides the removal is now the single-server seat, not the pidfile.
Holding it means nobody else can be serving this config dir, so anything
still at the endpoint belongs to a process that is gone — safe by
construction, which is the property `singleton` exists to provide. Removing
on one failed connect instead is the race that module was written to retire,
so it is used only where there is no seat, and even there a socket that
answers is refused rather than removed.

Reproduced on a Linux box: with a socket nothing was behind and a pidfile
naming a dead pid, main exits 1 on the bind and this starts and serves.

Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
2026-09-07 10:38:35 +08:00
l0ng-ai e98586bdbd fix(remote): keep the reason a remote server failed to start (#774) (#777)
A remote workspace could sit in a loop nobody could get out of: every
reconnect failed with "started but nothing was answering on the control
socket after 15s", the strip showed a copy bar frozen at 100%, and no
button was offered.

The daemon is the root of it. When its control listener would not open it
logged one line and kept running — and a running daemon holds the
single-server lock, so every later --daemon stood down at once and every
client probe failed, forever. Whether something else is serving cannot be
read off the errno: bind_control_socket clears the leftovers it can, but a
path it cannot clear comes back AddrInUse in the same words a live server
does. Ask by connecting, and exit when nothing answers.

The reason was thrown away twice over: the daemon's stdout and stderr went
to /dev/null, and the readiness probe kept only out.success(). Both are
kept now — output and exit status land beside the binary, stamped with the
launch's own nonce so a restart never reads the outgoing daemon's status as
the incoming one's. A start that has already failed no longer waits out the
full timeout.

The UI half: an automatic reconnect never retired its install progress, and
a leftover entry draws an install in flight instead of the failure and its
button. And a long error stretched the status card to 1978px in a 1440px
window, taking the retry button off the screen with it.

Closes part of #774. The Vim :wq cursor and the btop re-attach items in that
issue are not touched.

Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
2026-09-06 23:08:49 +08:00
l0ng-ai cebd871cb4 feat(sidebar): add projects as a declared layer beside the derived groups (#769)
* feat(sidebar): add projects as a declared layer beside the derived groups

The sidebar's repo groups are derived: a group's identity is a path
recomputed every frame from a leaf's cwd, it appears when a tab lands in
it and vanishes with its last tab. That layer cannot carry a name of its
own, cannot be created before a tab is opened in it, and orphans anything
keyed to it when a directory is renamed or moved.

Add a Project as a real entity on the workspace — an id, an optional
name, a root — and an optional reference to one on each tab. Nothing
probes it: a tab joins a project only by an explicit action, and a tab
that leaves one lands back in the group the probe would have put it in,
so declaration and inference never disagree and no third membership
state is needed.

The derived grouping, the cwd probe, the write-back and the
SidebarGrouping config are untouched; the only difference is the tab list
they are fed. A server that predates the projects feature ignores the new
array and serves today's sidebar.

Closes #756

* fix(sidebar): stop a searched row claiming a chord it does not own

A live search deliberately ignores a folded heading — the query is asking
about tabs — so the rail draws rows the chord order has taken out. The badge
was read from a `Vec<usize>` that started at zero, so every one of those rows
claimed ⌘1 while ⌘1 opened something else. It is `Option<usize>` now, built by
`badge_positions` off the same order `activate_visual` walks, and a row the
order left out wears no badge at all.

Also in the rail: the block loop reads "declared" off the section key rather
than the position it happens to sit at, and an unreachable `continue` for a
folded empty derived block is gone — `sidebar_sections` never makes one.

Projects:

- `MAX_PROJECTS` is held on the window side too. The machine refuses past it
  and a refusal resynchronizes, which would re-push the project this window
  kept and be refused again. Checked before the folder panel opens, so a full
  workspace says so before asking for a folder rather than after.
- `set_project_root` keeps the one-project-per-directory rule `declare_project`
  holds on the way in; pointing one project at another's folder reached the
  two-headers-that-mean-the-same-thing state by the back door.
- Opening a rename box over one already on another project commits it instead
  of dropping it with its subscription, which threw the typing away.

Sync:

- Project reordering moves after `retire_projects`. `to` indexes the machine's
  whole list, so a project on its way out pushed the survivors along and spelled
  a move for one already in place.
- `adopt_projects` reports whether it changed anything and the callers repaint
  when it did; it was mutating the window's list with nothing to notify.
- `migrate_panes` gets its doc comment back — `reconcile_projects` had been
  inserted between it and the comment describing it.

Dead `L10nKey::ProjectNew` removed: translated four times, used nowhere.

* fix(control): move the dialect to v8 for the project verbs

`CONTROL_VERSION`'s own doc says to move it whenever a variant is added to
`ControlRequest`, `ReplyOk` or `ControlEvent`, and says why the feature strings
are not a substitute: they cover what a peer can safely ignore — a field added
to a message it already decodes — while a variant it has never heard of fails
to decode and takes the whole link down with it.

The project verbs shipped behind a `projects` feature string instead. That
gates what a client *sends*, so a v7 server never saw a verb it could not read,
but nothing gates what a server *pushes*: a v7 client meeting a v8 server that
had grown a project would take the `ProjectCreated` delta, fail to decode the
frame, and lose the link — `read_until_closed` calls `fail_all` on any decode
error. Only the number can turn that pairing away at the handshake.

So the number moves and the feature goes. It was redundant even for the
direction it did cover: `MACHINE_TREE` and `PROJECTS` were pushed under the
same `services.machine.is_some()`, so within one build they were always equal
and only a cross-version pairing could tell them apart — which is exactly what
v8 now refuses at the handshake. Keeping both would be two mechanisms for one
job, and the weaker one silently covering half the problem.

Removed with it: `is_project_op` and the `pump` filter it fed.

Disk compatibility is a separate axis and is untouched — `Workspace::projects`
and `Tab::project` keep their `serde(default)`, and the test that reads a tree
written before either still passes.

Remote workspaces need their `tty7-server` pushed before they will connect.
That is the dialect-refusal path v7 was minted to make reachable: the parked
strip and its Update Server button.

Also: the two sidebar `+` buttons now fade in on their own heading's hover
rather than the whole rail's, so a control appears where the pointer is.
2026-09-04 21:19:05 +08:00
l0ng-ai cd70338486 fix(daemon): let a clean version probe clear the mismatch record (#770)
* fix(daemon): let a clean version probe clear the mismatch record

The restart prompt was armed from a global that only ever accumulated:
`note_daemon_mismatch` could set it, and nothing could take it away
except the window that consumed it. `ensure_running`'s agreeing path
never touched the record at all.

That matters because `ensure_running` is the first thing every
control-link reconnect attempt runs, and a mismatched daemon is one no
connect succeeds against. The link backed off and retried, arming the
prompt again each time round — including in the seconds the user spent
reading the dialog it had already opened. Restarting the daemon then
fixed the daemon and not the record, so the next window built took that
last arming and asked a second time about a server that was already
gone.

Make a probe's verdict settle the record rather than only add to it: a
daemon found to be ours wipes what an earlier probe left. The probe
judgement moves into `judge_probe`, and the handoff's own return
judgement into `judge_handoff_return` / `land_handoff_return`, so both
are testable apart from the sockets it takes to reach them.

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

* fix(daemon): tell a silent control socket apart from an agreeing one

`control_dialect_refusal` returned `Option<DialectRefusal>`, and `None` meant
both "it answered with our own dialect" and "it never answered at all" — a
connect that failed, the handshake timing out, a peer that hung up before
`HelloOk`. That conflation was harmless while silence only meant "record
nothing". It stopped being harmless when a clean verdict started *clearing* the
mismatch record: a control socket that times out now wipes a refusal the
control link had already met, and the window built next opens with no tabs and
nothing on screen to explain why — the exact state the record exists to
prevent. The function's own doc comment still promised the opposite.

Give the answer three shapes (`DialectAnswer::{Agrees, Refuses, Silent}`) and
carry the distinction through to the verdict (`MismatchVerdict::{Clear, Found,
Unchanged}`). Only `Agrees` clears. Silence leaves the record exactly as it
stands, which is the cheap side of the asymmetry: a stale record costs one
prompt about a daemon that turned out fine and the next probe takes it away,
while a wrongly cleared one costs a window its tabs.

The landing also moves out of `ensure_running` into `land_probe`, so the
logging and the record write are one thing a second caller can reuse.

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

* fix(daemon): settle the mismatch record on the restart path too

`ensure_running` only settles the record on the branch where a daemon is
already listening and answers. `restart()` is `stop()` + `ensure_running()`, so
by the time it runs the connect is refused, the endpoint is reaped, a daemon is
spawned, and the startup poll loop returns having written `note_local_daemon`
and nothing else. The record still describes the daemon the user just killed.

That is the path a mismatch is most likely to take. A daemon from before
protocol versioning reports no version at all, so `local_daemon_supports`
answers false for the handoff feature and the prompt's Restart takes
`restart()`, not `hand_off()` — the one branch that was given a clear.

Judge the freshly spawned daemon with `judge_probe` and land it with
`land_probe`, the same pair the already-running branch uses. The control
listener is up before the pane endpoint binds, so the dialect can be asked this
early; a daemon that answers neither handshake is still recorded as nothing,
same as before.

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

* fix(daemon): drop a probe verdict about a daemon that is already gone

A probe is not one instant: it connects, asks the pane endpoint and the control
socket, and only then writes what it found. In between, this build can stop the
daemon, hand it off, or spawn a new one — and now that a verdict *settles* the
record rather than only adding to it, a late one is not a stale read but a
wrong write.

Against a mismatched daemon that is the normal case, not a corner. The control
link retries on a backoff and every retry runs `ensure_running`, so when
`land_handoff_return` clears the record, a probe that connected to the outgoing
image before the exec lands afterwards and re-arms the prompt about the daemon
the user just replaced. The clear was best-effort against its own retry loop.

Stamp each verdict with a counter that moves whenever this build deliberately
changes which process serves — `stop`, `reap_stranded`, `spawn_detached`, and
the handoff at the point the exec is asked for — and drop a landing whose stamp
is stale. Probes against the same daemon are still last-one-wins, which is what
a record of "what is running now" should do.

Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe
2026-09-04 15:58:59 +08:00
ayamirandl0ng-ai e231b16fb3 feat(ssh): allow remote image clipboard writes (#766)
* feat(ssh): allow remote image clipboard writes

* fix(ssh): keep a profile's clipboard grant across a re-attach

A native ssh pane's OSC 5522 permission is decided by the spec that
dialled the host, and the daemon is the only side that holds it. A window
reopening onto a pane that outlived it attaches by pane id, has no spec
to read, and sends `allow_remote_clipboard_write: false` — which the
daemon took as the new answer and the pane's own view took as a refusal.
Both sides then said no, so the first restart after switching the
permission on turned every copy into an `EPERM` with the switch still
reading "on".

Pin the spec's answer in the pane and route both attach and detach
through one decision point, so a pane that carries a spec keeps that
spec's answer whatever an attaching client claims, and a pane without one
— everything on a remote `tty7-server` — is exactly as permitted as its
controller says. On the client side, refuse only what the pane can see is
forbidden and leave the verdict to the daemon otherwise.

Also: release a failed transfer's buffered bytes instead of parking up to
`MAX_CLIPBOARD_BYTES` per pane until the next request, and answer the
capability probe with the permission actually in force rather than a
constant that always reads as "off".

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-02 14:12:28 +08:00
oldhuandl0ng-ai 0b6c332618 feat(settings): add interface font family configuration in appearance typography (#761)
* feat(settings): add interface font family configuration in appearance typography

* fix(settings): let the interface font go back to the system face

Three things the new **Interface font family** row spelled once and needed
twice.

`apply_theme` only wrote `Theme.font_family` when the setting was `Some`,
and `Theme::change` never puts it back — it rewrites the field only when a
theme config names a face, and none of ours does. So picking a font worked,
and picking **Default** back saved `None`, redrew every window in the font
the user had just cleared, and only came true at the next launch: a setting
that looked like it had applied instantly and had not. The face is now
assigned in both directions, against the stock value read once before
anything overrode it.

The dropdown's first row borrowed the bold/italic label, "Default (match
primary)" — which promises the *terminal's* primary family. The interface
falls back to the system UI font instead, so the row said the chrome would
come out in Hack while the description beside it said the opposite. It gets
its own label in all three locales.

`ui_font_family` was also the one key in `config.json` that disappeared when
unset; every other optional key is written as `null`. Dropped the
`skip_serializing_if` so the file still lists it, and documented the key in
the two tables that enumerate the typography settings.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-02 14:07:59 +08:00
webdev c23483ba85 fix(ssh): close every channel tty7 abandons before the server does (#715) (#727)
A remote link worked for a while, then every operation on it failed with
"could not identify the remote machine: could not open a command channel:
Failed to open channel (ConnectFailed)", and Try Again only made it worse.
sshd was refusing the session channel: its stock MaxSessions is ten, and
tty7 had left ten open on the cached connection.

russh closes a channel in exactly one case. When the server sends
CHANNEL_CLOSE first, the session task answers it on arrival. Dropping a
`Channel` sends nothing — the one close-on-drop it has sits behind
`into_stream`, which the remote link and SFTP already ride and which a
command whose output is read with `wait` does not. So a command that ran
and exited cost nothing, and a channel abandoned while the far side was
still running it cost a session for the life of the connection. There
were four ways to abandon one. The installer's `exec` returned early on a
failed exec request, and more to the point was dropped mid-drain by the
timeouts in `run` and `spawn_detached`: a `uname` that hangs or a daemon
launch that does not answer within its budget is what those timeouts are
for, and each one pinned a session. The shell and env probes broke out of
their drain on EOF or at their output limit and dropped the channel. And
`drive_channel`, the pane's own shell, closed only on the pane's Close: a
pane whose reader had gone while the shell still ran broke out of its
loop and left that shell's session held for as long as the cached
connection lived. That last one is the "after some use".

A command now rides a `CommandChannel`, which closes on drop: the `?`
after the open, the normal return and the timeout's cancellation all
queue the CHANNEL_CLOSE for the session task, the way russh's own
close-on-drop does. The runtime it spawns on is taken at construction,
on the runtime by definition, rather than looked up from whichever
thread the drop lands on. The probes ride the same type. `drive_channel`
closes after its loop on every exit; after a close the server sent
first, russh has already taken the channel out of its table and the
redundant EOF and CLOSE put nothing on the wire.

The safety net, for a leak this change did not find: a connection whose
session open comes back ConnectFailed marks itself dead, and `is_alive`
is what the cache consults before handing a connection out again, so the
next Connect — Try Again included — dials afresh instead of retrying a
link that will refuse forever. It is not the fix: a fresh connection to
a leaking client is ten operations from the same wall. The shell probe
also no longer remembers a "no integration" it got from a link that
refused it a channel, which would have kept integration off that host
for the rest of the run.

The install layer is tested against `FakeRemote`, which has no wire, so
none of this was visible. An SSH server now runs in the test process —
russh's server half, accepting every session up to a limit and answering
`exec` as a command that exits or one that hangs — and counts the
channels the client opened and closed. It shows a timed-out command
closing its channel, twelve abandoned commands against a limit of ten
with none refused, a finished command's close answered exactly once, a
gone pane closing the shell behind it, and a refused open retiring the
connection. Each was checked against the old code. What it cannot show
is sshd's own accounting; the reporter did that, with a paramiko script
that exec'd freely while closing each channel and was refused on the
eleventh it left open.

Diagnosis and reproduction by xAlisher.
2026-08-26 20:11:52 +08:00