mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
chore/issue-forms
257
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
5c387e2ba4 |
Merge pull request #830 from l0ng-ai/fix/731-port-probe-honesty
fix(procinfo): stop the listening-port probe failing silently (#731) |
||
|
|
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) |
||
|
|
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) |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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) |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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) |
||
|
|
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 |
||
|
|
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 |
||
|
|
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) |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
ea7543490a |
Merge pull request #811 from l0ng-ai/feat/remote-ports
feat(ports): detect and forward what a remote pane is serving |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
fc94022ed0 |
feat(agent): add TraeCode CLI support (#807)
* feat(agent): add TraeCode CLI support * fix(settings): index TraeCode agent hooks |
||
|
|
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 |
||
|
|
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. |
||
|
|
28530a476a | fix(git): key a repository by one spelling of its root (#796) | ||
|
|
c125803077 | feat(daemon): list a Windows pane's listening ports (#787) | ||
|
|
dac70ec4c8 |
perf(protocol): put a frame's header and payload on the wire in one write (#797)
Refs #713. |
||
|
|
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 |
||
|
|
31ce382226 |
revert: drop projects as a declared sidebar layer (#769)
Reverts
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |