Commit Graph
537 Commits
Author SHA1 Message Date
l0ng-ai 949a336831 feat(cli): make a captured pane readable, and stop panicking on a closed pipe
The CLI's own --help calls it "built for coding agents", but `capture` handed
back the daemon's raw PTY bytes, which is the least readable thing it emits,
and every verb panicked when its reader hung up.

`capture --plain` replays those bytes through a terminal grid instead of
stripping escapes from them, using the same alacritty_terminal rev the GUI
renders panes with. The difference is not cosmetic: only the grid knows that a
break at the pane's width was a wrap rather than a newline, that a CR meant
"overwrite this line" rather than "end it", and which cell a wide char shares
with its spacer. A regex gets the easy 90% and then invents the rest — on one
real pane it turned 1193 lines into 2806.

The size each segment needs comes for free: the daemon already sends
DaemonMsg::Size right before every Snapshot, and the CLI was discarding it.
Panes here measure 249 and 86 columns, so the hardcoded 120 would have wrapped
both in the wrong places. Observing still resizes nothing.

The pipe fix is two mechanisms with one contract. On Unix SIGPIPE goes back to
its default disposition, which covers every write site at once and ends the
process the way it ends `cat` (141). Windows has no such signal, so stdio::out
recognizes the hung-up write and leaves quietly. Before this, 16 of 19 verbs
printed a panic and a backtrace note for `tty7 ls | head -1`; `run` instead
reported it as a failure with exit 1.

Also adds skills/tty7, the Claude skill for driving this CLI. It shipped with a
Python ANSI stripper, which is what prompted --plain; the script is gone.

alacritty_terminal moves to [workspace.dependencies] so the GUI and the CLI
cannot drift onto two revs of the fork.
2026-07-31 18:30:29 +08:00
l0ng-ai a4927e0bcd test(core): the git-stream idle tests should not race the scheduler
a_slow_stream_outlives_its_idle_timeout proved that the idle window
resets per message by having a thread sleep 60ms between sends and
trusting that to stay under a 150ms timeout. That is a 90ms margin
against the OS scheduler, and the darwin CI job lost the bet.

It also tested the wrong thing: whether recv_timeout fires after the
deadline is the standard library's contract, not ours. What is ours is
that the loop restarts the window on every message rather than budgeting
the whole stream.

Make the one blocking call injectable and script it. Both timing tests
now feed drain_git_stream a fixed sequence -- no threads, no sleeps, no
wall clock -- and the pair runs in microseconds. The queue-budget tests
keep their real channel, which is what they are about. Rename the second
test to say which property it holds.
2026-07-31 17:22:03 +08:00
l0ng-ai 0c9f4baa3a fix(cli): make the PATH install reversible, honest, and safe to migrate
Follow-up on the review of #277. Seven fixes, no change to what the feature
is for.

An AppImage copy is now claimed with a marker file instead of being inferred
from "am I an AppImage right now". Keying off the runtime meant that a user
who moved from the AppImage to the tarball hit their own copy, read it as
somebody else's binary, and never got another install for as long as that file
sat there.

The Windows uninstaller takes {app} back out of HKCU\Environment. Nothing did
before: the entry is written by the app at runtime, so Inno never knew it
existed and every uninstall grew the user's PATH by one dead entry. Unix has no
equivalent hook and still leaves its symlink behind; that is now stated in the
module docs rather than left to be discovered.

An occupied candidate directory no longer ends the scan, and every platform now
reports whether the install actually wins the lookup. `Occupied` on
/opt/homebrew/bin used to mean giving up while ~/.local/bin sat free, and
Windows — which appends to PATH and so never collides — reported `Installed`
even when an existing tty7 earlier on PATH kept beating it. A new
`InstalledShadowed` names the winner.

`cargo run --release` no longer repoints the developer's real tty7 at a build
tree. `cfg!(debug_assertions)` only covered the debug half of that.

The Windows registry PATH is read, matched, and written as UTF-16 throughout.
It went through `to_string_lossy` before, so a value the registry holds but
Rust cannot represent as a String would have been written back with U+FFFD in
place of its characters — the exact PATH corruption the surrounding code is
careful to avoid.

Two tests mutated $HOME and $PATH while the rest of the binary's tests ran
beside them, and src/ui/home.rs mutates $HOME too. `candidate_dirs` takes home
as a parameter, `place` takes its mode, and the PATH-joining and registry-
joining rules are pure functions — so no test in this module touches the
environment any more. 5 tests become 11, and the Windows joining logic is
covered on every platform.

Also: the config flag reaches Settings → About and both features docs instead
of being config.json-only, startup reads config.json once instead of twice, and
the CLI's strip failure warns like its sibling instead of being swallowed.
2026-07-31 16:49:22 +08:00
l0ng-ai c275960ceb feat(cli): ship the CLI in every installer and put it on PATH at launch
The `tty7` CLI was built by every release run and thrown away: all four
bundle scripts copied only `tty7-app`, and the upload glob covers `dist/`,
which the CLI never reached. Nothing put it on PATH either, so the
agent-facing half of the product was unreachable from a shipped install.

Bundle it on all four platforms, and have the GUI link it up itself rather
than hiding the step behind a menu item most people never find.

The install has two halves. The environment half prepends the CLI's
directory to this process's PATH before the daemon is spawned, so every
pane inherits it — that alone makes `tty7` work where agents actually run,
writes nothing to disk, and behaves the same everywhere. The on-disk half
symlinks into a directory already on PATH (Unix) or appends to
HKCU\Environment (Windows), and is allowed to fail.

Candidate directories are a fixed list intersected with PATH, not the first
writable entry on it: pyenv/rbenv/asdf/mise shim directories sit at the
front of PATH on many machines and are writable, and anything dropped there
is deleted on the next rehash — silently, days later.

Debug builds get the environment half only. `target/debug` holds a `tty7`
too, so otherwise a `cargo run` would repoint the developer's real `tty7`
at a debug binary, and each isolated dev-verify instance would rewrite the
PATH of the machine it is meant to stay away from.
2026-07-31 16:49:22 +08:00
l0ng-ai e230583108 fix(core): a wedged wsl.exe must not hold up the shell menu
Listing WSL distros shells out to wsl.exe with no time limit, so a WSL
service that is starting up, updating, or simply wedged blocks the whole
shell probe -- and with it the menu of shells the window offers. The
Windows CI job hit the slow end of this often enough to flake.

Add proc::output_within, which spawns the child, drains both pipes on
their own threads so a large write cannot deadlock the reaper, and kills
the child once the deadline passes. Give the distro listing three seconds
of it; a machine that cannot answer by then simply reports no distros,
which is what an unreachable WSL already produced.
2026-07-31 16:42:49 +08:00
l0ng-ai d5fdea555d test(ui): the shell probe needs longer than five seconds on a busy runner
The Windows CI job flakes on a_local_window_lists_this_computers_shells:
the local shell probe runs off-thread and, on Windows, spawns wsl.exe to
list distros. With the whole test binary running in parallel on a slow
runner that can take longer than the five seconds pump_until allowed, so
the poll gave up on a probe that was merely late.

Raise the deadline to thirty seconds, matching the other polling tests in
this repo. A probe that does land still returns immediately, so a healthy
run costs nothing extra.
2026-07-31 16:26:25 +08:00
l0ng-ai 901df4f3cc fix(core): the endpoint-sibling test is unix-only, so gate it
Both socket_path_for and socket_path_in live behind cfg(unix) — Windows
serves the control channel over a named pipe. The test referencing them
broke the Windows lib-test build. Gate the test, and narrow the
socket_path_in re-export to test builds so it stops warning as unused.
2026-07-31 15:14:31 +08:00
l0ng-ai 95ceed533d fix(cli): name a mistyped subcommand instead of offering it to the GUI
`tty7 tree` answered "launching the GUI is not wired up yet (would open tree)".
Bare `tty7 [PATH]` opens the GUI, so any word clap has no subcommand for lands
in that positional — every typo came back as an offer to open a directory named
after it.

A word with no separator, no leading ./~, and no matching file on disk is a
mistyped verb, and is now reported as one. Real paths still reach the launcher
and fail there for the honest reason.

Also: `ws`'s help said "the named session trees", missed by the session -> shell
pass, and long_about still advertised $TTY7_SOCKET, which no longer exists —
it is $TTY7_CONFIG_DIR now.
2026-07-31 15:07:03 +08:00
l0ng-ai a94dac3fca fix(ui): a GUI-spawned pane carries its workspace, so $TTY7_WS is set
spawn_once hard-coded `workspace: None` — the field was added to make the call
compile when the protocol grew it, and never wired up. Every pane the GUI opens
therefore reached its shell without $TTY7_WS, and the GUI is how panes are
normally opened: `tty7 ws tree`, `tab ls` and `run --keep` all refused inside
one, and `doctor` reported the workspace as missing.

The id was already there — `owner` carries it and is passed straight through.
It is taken separately rather than reused after the filter below it: `owner` is
also gated on FEATURE_PANE_OWNER, while the workspace field rides the c4p5 spawn
kind and needs no feature probe. Local routes only, matching `owner`, since a
remote server keeps its own machine tree and this id names a workspace in ours.

Extracted as spawn_workspace so a test pins it; passing None again would be
invisible otherwise.
2026-07-31 15:06:51 +08:00
l0ng-ai 8000461706 fix(core): derive both endpoints from the config dir, publish the dir itself
Manual testing found `tty7 run`, `send`, `capture`, `procs` and `split` broken
against any normally-installed server — the CLI's entire hot path. Only the
control verbs worked.

Two endpoints, two rules. The pane socket came from the config dir; the control
socket ignored it and sat in $XDG_RUNTIME_DIR/tty7 or ~/.local/share/tty7 —
under the same basename, `daemon.sock`. So they were told apart by directory
alone, and the CLI, handed one path in TTY7_SOCKET, reconstructed the other with
with_file_name: on the default layout that returns the input unchanged. Pane
verbs dialed the control socket and the daemon hung up on them. A --config-dir
server was worse: it published the *default* control socket to the shells it
spawned, so a CLI inside an isolated instance drove a different server.

The e2e suite passed throughout because its harness set TTY7_CONTROL_SOCK
explicitly, placing both endpoints in one directory under different names — a
layout production never produces. It had removed the bug's precondition.

Now: the control socket is derived from the config dir like the pane socket
(control.sock beside daemon.sock, mirroring Windows' control.port/daemon.port,
with -control on the hashed fallback so the two cannot collide), and panes are
handed TTY7_CONFIG_DIR instead of a socket path. A CLI inherits it, so
ControlClient::connect and PaneClient::local resolve the same two sockets the
server opened, through the same functions. No second derivation to disagree.

remote_link's remote_control_socket was a third copy of the old rule, used to
locate a remote server's endpoint before connecting; it follows the config dir
too, and the env probe now reads $TTY7_CONFIG_DIR.

Drops the CLI's server-lifecycle guard: stop/start already follow the config dir
through transport::connect and --config-dir, so there is no longer a mismatch to
refuse. The e2e case that covered only `status` over a lone variable now also
runs a pane verb — the asymmetry it missed is exactly what broke.

Note: this moves the control socket for existing installs. A running pre-change
daemon will not be found at the new path, which is the honest outcome — its
control dialect is v3 against this build's v4, so reaching it only produced a
version error anyway.
2026-07-31 14:41:29 +08:00
l0ng-ai a61bd486d3 merge: main — kitty graphics fans out to observers too
Two conflicts, both where main's graphics work and this branch's observer work
touched the same lines.

daemon/protocol.rs: both sides appended frame kinds. INPUT_ACK (51) and
IMAGE/DELETE_IMAGE (60/61) do not collide; both kept.

daemon/pane.rs: main taught the reader to forward a chunk as an ordered
GraphicsFrame sequence instead of one Output, so an image lands at the cursor
cell the sender drew it at. This branch had lifted the same send into
fan_out_output, which also feeds read-only observers and holds each to its
budget. fan_out_output now takes the frame sequence: the no-graphics fast path
still sends one Output, and Image frames reach observers as well, gated on their
own length. A Delete selector rides `notify`, which is ungated but still drops
an observer that has stopped draining — matching the drain accounting in
server.rs.

An observer is a read-only mirror of the pane, so it sees images for the same
reason it sees text.
2026-07-31 13:59:36 +08:00
ayamir 83d9a1c547 feat(graphics): render kitty graphics with shared-memory transport (#272)
Adds kitty graphics protocol support: a daemon-side APC tokenizer lifts image transmissions out of the PTY stream before the replay ring, forwards them out-of-band as compact binary frames interleaved in stream order, and the client decodes off-thread with newest-wins coalescing per image id. Local panes take the file/shm fast path; remote panes keep pixels compressed in-tunnel. Cell size is now reported to children in device pixels so pixel-aware senders render at native resolution.

Closes #213.
2026-07-31 13:47:59 +08:00
l0ng-ai 3b68a42cb8 fix(installer): delete the pre-rename tty7.exe on upgrade
Builds before the tty7/tty7-app split installed the GUI as tty7.exe. Upgrading
only adds tty7-app.exe, so the old binary stays on disk — and a taskbar pin,
which Inno cannot rewrite the way it rewrites [Icons] shortcuts, still points at
it. The user keeps launching the previous version from their pinned icon,
against the same daemon endpoint as the new one.

[InstallDelete] runs after PrepareToInstall has stopped the daemon and released
the file lock, and before the new files land. A fresh install has nothing to
remove.
2026-07-31 13:05:16 +08:00
l0ng-ai 54f498aa6c fix(cli): surface orphan panes, answer --json everywhere, scope the server verbs
An interrupted `tty7 run` leaves its pane running with nothing referencing it:
no workspace holds it, every listing walks the tree, and the orphan sweep only
logs. `pane ls --all` reads the server's registry instead and marks what nothing
holds; `pane close` falls back to hanging a pane up directly when no workspace
can route a PaneClose, so an orphan is stoppable.

--quiet silenced failures as well as successes, leaving a bare exit code and
nothing to debug; it now suppresses only output on success, and covers --json
too. `run` exited through a path that skipped the report entirely, so
`run --json` printed nothing at all; it now carries its report, with
exit_code_known distinguishing the command's own 1 from the stand-in 1.

The server lifecycle verbs can only drive the default endpoint — spawn::stop
dials transport::connect() — while every other verb follows $TTY7_SOCKET. They
now refuse when that names a different endpoint rather than acting on a
different server than `tty7 status` reports on.

Also: tables pad by display width, so a CJK name or path no longer skews every
column after it; --h/--v become --horizontal/--vertical with the short forms
kept as aliases; the verbs that are not implemented say so in --help instead of
only at runtime; capture's help admits it decodes as lossy UTF-8.
2026-07-31 13:05:02 +08:00
l0ng-ai fc01b3e31f fix(client): stop timeout setup from masking the daemon's refusal
On macOS, setsockopt against a socket whose peer has already closed fails with
EINVAL. The daemon answers a bad request by writing one Error frame and hanging
up at once, so PaneSession's `set_recv_timeout(...)?` would fail before the
refusal was ever read — turning "no such pane 42", already sitting in the
buffer, into "Invalid argument".

Bounding the reply wait is an optimisation, not a correctness requirement, so it
is now best effort in both attach/observe and spawn. Nothing can hang as a
result: a closed peer returns EOF immediately, and a live peer is exactly the
case where setsockopt succeeds.

This is what made client_lib's reattach test red.
2026-07-31 13:04:51 +08:00
l0ng-ai 400d7d76b6 fix(daemon): wake displaced controllers without polling, keep busy links usable
run_stream polled pane.controls(epoch) every 200ms behind a read timeout, so
every attached pane woke its thread five times a second just to notice a
handover that may never come. The writer already learns of the handover the
instant it happens — its channel closes — so it now shuts the connection's read
side down on its way out, and the reader goes back to a plain blocking read.

SshManager::routes() reported a link as disconnected whenever its slot's
try_lock failed, which is precisely when the link is in use. The CLI's
-m <machine> refuses to route over a link it is told is down, so an actively
used connection would intermittently fail. Busy now reads as connected, matching
how SshConnection::is_alive resolves the same contention.

Also: the exit-code probe takes the child lock with try_lock, since Drop holds
it across a blocking wait(), and its window drops from 2s to 500ms — it only
needs to cover the race between pty EOF and the child becoming reapable, and
everything past that is a pane that looks frozen to every client. Observer
budget now covers status traffic and the initial replay, not just output.
Uptime is anchored where the control listener opens so a GUI-hosted server
does not report itself as freshly started.
2026-07-31 13:04:33 +08:00
l0ng-aiandl0ng-ai 40a8ba711b fix(input): stop a Tab on a detached remote pane from eating the line (#273)
* fix(input): stop a Tab on a detached remote pane from eating the line

Tab reaches the view through the SendTab/SendBackTab actions, which bypass
the read-only guard in on_key_down. On a remote workspace whose link is not
attached, that let a failed completion run handoff_line_to_shell: it cleared
the editor's line and wrote it to a writer nobody was reading, so the command
vanished, the editor stood down for the rest of the prompt cycle, and every
later keystroke was swallowed by the guard the Tab had skipped.

Guard tab_pressed, handoff_line_to_shell and submit_command with
accepts_input, so a pane that cannot reach its shell keeps the line instead
of handing it to nowhere. A late SFTP listing now also checks that the editor
still owns the line before acting on it - that round trip can take seconds.

A listing that fails still hands the line to the shell, same as locally.

* fix(input): name the reason a detached link turns a Tab away

Fold the link check into a `link_inactive_reason` so the Tab path logs why
it did nothing, and let `remote_path_results` consult the link as well as
the editor instead of leaning on `handoff_line_to_shell` to catch it.

Reword the failed-listing warning: with the drop guard in place the line
does not always reach the shell.

Dispatch the Tab in the disconnected-pane test through the real `SendTab`
action, since the bug was that the action skips `on_key_down`, and add a
test that a Tab on a detached pane never starts an SFTP listing — the one
thing only the Tab-path guard prevents.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-31 12:11:24 +08:00
thomasandClaude Fable 5 794ae89d24 feat(cli)!: drop the attach verb and the design doc
The CLI's user is the coding agent; run/send/capture/events cover it.
Workspace-level ws attach/detach stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 11:36:25 +08:00
thomasandClaude Fable 5 05dffdf1f4 fix(cli): send rides the one-shot SendInput instead of a preempting attach
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 11:05:15 +08:00
thomasandClaude Fable 5 b047b4cc06 merge: CLI review fixes — CI coverage, kept-pane filing, endpoint honesty
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 11:03:24 +08:00
thomasandClaude Fable 5 2c3bf51ccb merge: daemon review fixes — SendInput, displaced-controller close, observer budget
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:55:32 +08:00
thomasandClaude Fable 5 acf6ee63d8 fix(daemon): one-shot SendInput, displaced controllers close, observers get a budget
ClientMsg::SendInput (kind 55) writes to a pane's PTY without touching the
controlling subscriber, its epoch, or the size, answered by DaemonMsg::InputAck
(kind 51) or an Error for a missing or exited pane; PaneClient::send_input
wraps it. Both stay within protocol 5.

run_stream now polls its half of the socket and epoch-checks against the pane
before forwarding Input or Resize, so a controller displaced by a preempting
Attach stops writing into the shell and has its connection shut down instead of
half-open forwarding forever.

Each observer meters its queued Output through its own OutputGate; one that
lets 8 MiB pile up is pruned rather than growing daemon memory, while the
controller and the PTY never wait on it.

The pidfile reap guard accepts any legitimate daemon exe name (current exe,
tty7-app, tty7-server, tty7; .exe optional, case-insensitive on Windows), and
the agent-hooks console fast path matches tty7-server.exe and tty7.exe next to
tty7-app.exe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:54:53 +08:00
thomasandClaude Fable 5 b46183688e fix(cli): review findings — CI coverage, kept-pane filing, endpoint and lifecycle honesty
- workspace: tty7-cli joins default-members, so a bare root cargo test runs it
- run --keep files the pane into its workspace via TabCreate (and refuses to
  keep a pane no workspace would list); --ws help says what it really does
- server start|stop|restart|logs refuse -m instead of silently acting locally
- server start kills the spawned process when it never opens its endpoints
- -m over a down link is refused instead of redialing with auto auth
- capture help tells the truth: raw ANSI bytes, last ring segment by default
- a missed exit-code probe exits 1 with a stderr note, not a fabricated code
- TTY7_SOCKET is honored: control dials it, the pane endpoint is its sibling
- attach's success JSON says attached, not detached_from
- e2e daemons ride a KILL_ON_JOB_CLOSE Job Object on Windows, so a hard-killed
  harness cannot leak servers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:51:41 +08:00
thomasandClaude Fable 5 b10a40a581 docs: attach is deferred — the CLI's primary user is the coding agent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:31:59 +08:00
thomasandClaude Fable 5 866ebb6222 merge: real CLI backend — every non-interactive verb live
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:19:19 +08:00
thomasandClaude Fable 5 fafb0019f4 feat(cli): real server backend — every non-interactive verb goes live
StubBackend is gone; RealBackend lazily opens a ControlClient for control
ops and a PaneClient for pane ops. Backend::spawn_shell now returns the
daemon-assigned pane id, so every creating verb (new, tab new, split)
spawns first and seeds the tree op with the real id — client-side pane-id
allocation is deleted. Live end to end: ls, ws/tab/pane verbs, new, send
(attach-input-detach), capture (observer replay; --scrollback = whole
ring, default = the ring's last segment), procs, run (streams output,
passes the child's exit code through, --keep leaves the pane), events
(human lines or NDJSON), agents, status, machine ls, and doctor's server
half (reachability, dialect, status, links). tty7 server start|stop|
restart|logs manage a sibling/PATH/TTY7_SERVER_EXE tty7-server. -m routes
both channels over the local server's link, resolved against Routes by
key or bare host; jump/proxy-chained keys are refused with the reason.
Interactive attach stays stubbed for the next slice. A harness-free e2e
suite drives the compiled tty7.exe against an isolated real server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:18:30 +08:00
thomasandClaude Fable 5 5511818af9 feat(core): pane exit codes, read-only observe, procs and routed pane clients
The daemon now waits its pane child and puts the real exit code on the
Exited frame (and replays it to late subscribers of a dead pane) — the
prerequisite for tty7 run's code passthrough. The client library gains
PaneClient::observe (read-only replay+stream), PaneClient::procs, and
PaneClient::routed for reaching a remote machine's pane daemon over the
local server's ROUTE frame, mirroring ControlClient::routed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 10:18:16 +08:00
thomasandClaude Fable 5 34350018ec fix(client): PaneClient::spawn carries the workspace for TTY7_WS
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:50:21 +08:00
thomasandClaude Fable 5 db04919999 merge: tty7-cli crate skeleton
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:46:10 +08:00
thomasandClaude Fable 5 3285a93137 merge: rename the GUI binary to tty7-app
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:46:09 +08:00
thomasandClaude Fable 5 ee69a078f0 merge: tty7_core::client — public control and pane clients
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:46:01 +08:00
thomasandClaude Fable 5 f24a69de66 merge: server gaps — pane observers, TTY7_* env, aggregate queries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:45:55 +08:00
thomasandClaude Fable 5 c8db432b63 fix(ui): one word per concept — shells, server, connection
The workspace switcher's row menu now uses the same verbs as every
other entry point (Stop/Delete Workspace), running processes are
shells everywhere, SSH links are connections, and the background
process is the server in all user-facing text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:45:46 +08:00
thomasandClaude Fable 5 753d1dceee feat(core): pane observers, TTY7_* context env, and control aggregate queries
Server-side gaps for the tty7 CLI (docs/cli-design.md, the additive tier):

- Pane multi-subscriber: the single controlling subscriber keeps its
  preemption semantics, and a pane now also carries N read-only observers.
  A new pane-protocol frame `Observe { pane_id, size }` (kind 54) joins a
  connection as an observer: it gets the Snapshot replay, then
  Output/Exited/Size, and its Input/Resize is answered with an Error frame
  instead of reaching the shell. Observers never displace the GUI, never
  defer the dead-pane reap, and are pruned when their connection goes away.

- Context env injection: every locally spawned shell now carries
  TTY7_PANE (its pane id), TTY7_WS (the workspace named in Spawn), and
  TTY7_SOCKET (the control endpoint path), next to the existing TTY7
  marker. ClientMsg::Spawn gains an optional `workspace` field, carried
  by the SPAWN_OWNED frame with serde defaults.

- Aggregate queries: ControlRequest::{AgentStates, Routes, Status} with
  ReplyOk::{AgentStates, Routes, Status}. AgentStates snapshots each
  pane's live agent session state through a new Services::panes directory
  wired from the daemon's registry; Routes lists the SshManager's held
  connections with key/kind/liveness; Status reports pid, uptime from a
  process-start instant, pane count, both dialect versions, build, and
  the control socket path.

- Dialect bump: CONTROL_VERSION 3 -> 4, PROTOCOL_VERSION 4 -> 5,
  following the convention set by bed22d8 and 1792bb8/a4972d3 where every
  additive variant bumped the strict-equality handshake versions (and
  with them the tty7-server-c{control}p{protocol} install name).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:44:22 +08:00
thomasandClaude Fable 5 ec19525332 feat(core): extract the public client library tty7_core::client
ControlClient wraps the control-socket machinery behind connect /
connect_at / routed, adds a channel-backed events API on top of the
existing EventSink, and keeps request deadlines and blob replies.
PaneClient speaks the pane socket: one-shot list/version/kill plus
spawn/attach sessions that stream DaemonMsg and split into input and
output halves. transport grows connect_endpoint_at so a client can
reach a server by explicit endpoint file instead of the global config
dir. Integration tests drive both clients against a spawned
tty7-server --daemon in an isolated config/data dir on every platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:36:03 +08:00
thomasandClaude Fable 5 917d09f014 feat(cli): tty7-cli crate skeleton — grammar, addressing, backend seam, output
The thin console CLI from docs/cli-design.md, transport-free for now:

- clap grammar for the full documented surface: ws/tab/pane/machine/server
  nouns, the hot-path top-level verbs (ls/attach/run/new/split/send/capture/
  procs/agents/events/status/doctor), global -m/--json/-q, `run -- <cmd...>`,
  and bare `tty7 [path]` parsing as the GUI launcher stub.
- tmux-style addressing (%42 pane, @7 machine-wide tab ordinal or @<uuid>,
  workspace by name / id / unique id prefix) with implicit-context fallback
  to TTY7_PANE / TTY7_WS and a "not inside a tty7 shell" error naming the fix.
- Backend trait as the integration seam: control() speaks real
  tty7_core::daemon::control ControlRequest/ReplyOk values, plus declared
  pane-side entry points (spawn_shell/send_input/capture/procs/attach_pane/
  run/events). StubBackend fails loudly until the transport client lands;
  MockBackend asserts the exact request shapes every structural verb builds.
- Plain aligned tables and trees for ls/tree/pane ls, one JSON object per
  command under --json. Exit codes: 0 ok, 1 failed, 2 usage (clap default).

agents/status/machine ls stay stubbed: they need ControlRequest::AgentStates/
Status/Routes, which another slice is adding; ws stop and server start/stop
likewise wait on their mechanisms rather than inventing protocol.

Build/test this package alone (cargo build -p tty7-cli): its bin is named
tty7 and collides with the GUI bin until that one is renamed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:34:52 +08:00
thomasandClaude Fable 5 10b6741368 refactor: rename the GUI binary to tty7-app, freeing tty7 for the CLI
The package name and every display name ("tty7" in menus, tray, .desktop
Name, CFBundleName, installer AppName, shortcuts) stay as they were; only
the executable file is now tty7-app / tty7-app.exe, per docs/cli-design.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:28:00 +08:00
thomasandClaude Fable 5 9f88bf223b docs: tty7 CLI end-state design
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:17:05 +08:00
66b238ee8f fix(input): copy and paste answer Ctrl+Shift+C/V, and Paste is rebindable (#271)
Off macOS every GUI terminal teaches the same chords -- GNOME Terminal,
Konsole, Windows Terminal and WezTerm all copy on Ctrl+Shift+C and paste on
Ctrl+Shift+V -- but tty7 answered neither. Both are now defaults, alongside
the unchanged Ctrl+C/Ctrl+V.

Shift+Insert was installed as a hardcoded binding in init(), which made it
invisible: Settings could not show it, record over it, or warn when another
chord collided with it, and a user's own shift-insert binding silently lost
to it in the Terminal context. Copy and Paste are now rows in the default
table like any other action. Shift+Insert rides along as Paste's second
chord through the same mechanism that gives InsertNewline its Alt+Enter --
generalized from a one-action special case into a table -- so rebinding
Paste retires both defaults together instead of leaving Shift+Insert
pasting behind the user's back.

The control-code guard also tightens: Ctrl+2..8 are NUL, ESC, FS, GS, RS,
US and DEL in xterm-style terminals, and Ctrl+` is NUL, so a future default
cannot land on them.

The changelog now also spells out the word-motion trade Alt+arrows made:
Alt+left/right focus panes (Windows Terminal's default), and word movement
in the prompt editor is Ctrl+left/right, with both Shift variants selecting
by word.

Refs #269

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:03:02 +08:00
ccc98b90a9 fix(input): stop the Windows/Linux keymap and CRLF pastes from breaking the shell (#270)
* fix(input): stop the Windows/Linux keymap and CRLF pastes from breaking the shell

Two problems a Windows user hit within an hour of installing tty7, both from
code that only ever had macOS in mind.

A bracketed paste forwarded the clipboard byte for byte. Text copied on Windows
carries \r\n, and vim counts CR and LF as two line breaks, so pasting code into
it left a blank line under every line. Fold \r\n to \n inside the paste markers
so a Windows clipboard produces exactly what the same paste already produced on
Linux and macOS. The non-bracketed path keeps converting to CR: with no paste
mode to tell text from typing, a line break has to arrive as the byte Return
sends.

`secondary` means Cmd on macOS and Ctrl everywhere else, and the default keymap
was carried over from macOS unchanged, which put window actions on top of
terminal control codes. Ctrl+D could not send EOF, Ctrl+[ could not send ESC,
and Ctrl+W, Ctrl+K, Ctrl+P, Ctrl+J, Ctrl+T, Ctrl+Q and Ctrl+S were all taken.
These bindings carry no context, so they match before the terminal sees the key
-- apply_readline_ctrl already sends EOF for ^D, it was just unreachable.

Off macOS, ctrl-<letter> and ctrl-[ ] \ space now belong to the terminal and
window actions move to ctrl-shift-*, the convention GNOME Terminal, Konsole,
Windows Terminal and WezTerm share. Tabs move to Alt+1-9 and pane focus to
Alt+arrow, since Ctrl+Alt+arrow is workspace switching under GNOME. Ctrl+S
keeps saving in the code panel but propagates when the editor is unfocused, so
a shell still gets XOFF. Shift+Insert pastes. macOS is untouched, and a user's
own rebindings still win over the defaults.

A test walks the default table and rejects any chord that lands on a control
code, so the next binding added cannot quietly bring this back; a second one
rejects two actions claiming the same chord, which is how the reshuffle caught
NewTab colliding with ReopenClosedTab.

Refs #269

* fix(history): keep one history store per machine, not one per install

tty7 owns ^R at the prompt and shows its own fuzzy menu, but `history.rs` had
no notion of where a command had run: `load()` read one file and `append()`
wrote one file, so an ssh session was offered the commands typed on the local
machine. That is worse than an empty menu -- the entries look plausible until
one of them runs against the wrong box.

History is now scoped. The local store keeps its existing path, so nobody loses
what they have; each remote gets its own file under `history.d/`, named after
the target with a hash appended so a label that is not a filename ("me@box:/srv")
cannot escape the directory or collide with another host.

A pane picks its scope from the innermost remote it can see -- the ssh target
reported by shell integration, else its host id -- and follows it when that
changes, clearing the menu and reloading in the background rather than showing
the previous machine's entries while it waits. A reload that lands after the
pane has moved on again is dropped.

On a remote workspace, where a Host impl exists, the far end's own
~/.zsh_history and ~/.bash_history are read through the same channel git status
and file listings already use, so the first ^R on a freshly connected box has
something in it. Bare ssh has no Host to read through and falls back to what
tty7 recorded itself for that target, which is still the right machine's
commands.

Refs #269

* fix(test): the control-code guard must admit Ctrl+S, which falls through

The guard forbade every bare ctrl-letter default, but EditorSave staying on
Ctrl+S is the design: its handler propagates whenever the editor does not
have focus, so the terminal still receives XOFF. The guard's real invariant
is "no default may swallow a control code" -- an action that falls through
does not swallow. EditorSave is exempted by name, with the condition for
earning a spot written next to the list.

The test is vacuous on macOS (secondary parses to Cmd), which is why the
contradiction only surfaced on the Linux runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(history): never seed an inner ssh target from the workspace host's files

remote_shell_history_sources gated only on "scope is remote and host is
remote", so a pane that ssh'ed onward from a remote workspace -- scoped to
the inner target -- was seeded from the workspace host's ~/.zsh_history:
another machine's commands in the menu, the exact confusion scoping exists
to prevent. The Host can only reach the workspace machine's home, so seeding
is now reserved for the host's own scope; a pane with a remote_context falls
back to what tty7 recorded for that target, exactly like bare ssh from a
local pane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:11:07 +08:00
l0ng-aiandl0ng-ai 8c1946d763 chore: strip every comment from the Rust sources (#268)
Removed all Rust comments -- line, block, and doc -- from the 139 tracked
.rs files with `uncomment` 3.5.1. It parses each file with tree-sitter
instead of matching text, so comment-like content inside string literals
is left alone: the JavaScript plugin source embedded in agent_hooks.rs
raw strings keeps its own `//` lines.

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

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 21:36:15 +08:00
l0ng-aiandl0ng-ai ac8968643d fix: reattach the last workspace closed, and resupervise a reopened remote one (#267)
* fix(session): come back to the last workspace closed, not the home page

Launch only ever restored a workspace that still had a window at quit, so
closing them one by one and relaunching came up on the empty home page —
with no hint that four workspaces were sitting there detached.

Closing a window here is a detach: its panes keep running in the daemon,
which makes that workspace every bit as much "where you left off" as one
that still had a window on screen. `workspace_to_restore` now falls back
to the most recently active workspace of any kind, and since
`close_window` touches it on the way out, that is the one closed last. An
open workspace still outranks a more recently touched detached one, so a
background agent cannot steal the restore from the window that was
actually on screen. Deleting a workspace still drops it from the file —
that is the one gesture meaning "done with this".

`None` therefore means a genuine first run only, which retires the
`FreshStart::HomePage` launch path along with the enum that threaded it
through `windows::open` and `Tty7App::for_workspace`.

* fix(remote): reopening a remote workspace starts the supervisor again

The supervisor stops — and clears every MachineLink with it — as soon as
no open workspace is on a remote machine, which closing the last remote
window does. The connection itself stays: a closed window is a detach,
so HostLinks keeps the socket for whatever opens next.

Reopening that workspace then found a live connection with no link
behind it. `reopen_remote_at_startup` read the surviving HostLinks entry
as "another window got there first" and returned before starting the
pump, so nothing ever put a MachineLink back and `status_of` answered
Disconnected for good: a "Not connected to <machine>" strip and a dead
keyboard over panes that were visibly still running on the far side.

An existing HostLinks entry is not a reason to skip the supervisor. It
answers "is there a socket"; what the window renders from is `machines`.
Both now go through RemoteLinks::supervise, which is a no-op for a local
workspace and an unconditional ensure_running for a remote one —
idempotent, so a machine already supervised costs a flag check, and the
first tick over a live socket marks it Attached without opening a second
SSH session.

Fixes the same hole on the switcher's path, which never told the
supervisor anything at all: picking a remote workspace from it goes
through `switch_workspace`, not the launch path.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 21:28:44 +08:00
1e5106de59 fix(install): every fixture installer gets the fixture's dialect (#266)
`an_install_reports_both_transfers_to_completion` and
`every_report_carries_the_host` built their `Installer` by hand — `with_version`
and `with_timeouts`, no `with_dialect`. So they ran at
`RemoteProtocol::of_this_build()` while `FakeRemote` answered the `--protocol`
probe with the fixture's fixed `c3p4`, and passed only because the real
`CONTROL_VERSION` currently *is* 3.

Found by bumping `CONTROL_VERSION` to 4 locally: both tests fail with
`DialectMismatch { wanted: c4p4, spoke: c3p4 }` — an error about a fake's
plumbing, raised in two tests that are about *progress reporting*, at the one
moment somebody is busy changing the wire and least able to spare the
attention. The fixture's own comment says the dialect is a fixed literal
"rather than `RemoteProtocol::of_this_build`", which is exactly right and was
exactly what these two bypassed.

Both now go through `installer()`, whose `release` parameter is a
`&dyn AssetFetcher` so that a chunked fetcher — the only reason either test
hand-rolled the builder — is no longer a reason to leave the helper. The
helper's doc says what forgetting it costs, since that is the failure mode a
reader needs and the compiler cannot give.

No behaviour change, and the same 644 tests before and after.

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:15:41 +08:00
2a4b5c7f68 fix(release): name the server assets for whoever downloads them, not for cargo (#265)
`tty7-server-x86_64-unknown-linux-musl` was never a name anyone chose. Both
workflows staged the file as `tty7-server-${{ matrix.target }}`, so the build
triple went straight into a published filename — and the triple's *vendor*
field, for a Linux target with no particular vendor, is the literal word
`unknown`. It has been sitting on the releases page reading like a failed
lookup.

Of the triple's four fields only two say anything to whoever downloads this:
the architecture, which is what `asset_for_uname` picks by, and `musl`, which
is why one file runs on any distribution. So:

    tty7-server-x86_64-unknown-linux-musl  →  tty7-server-linux-x86_64-musl
    tty7-server-aarch64-unknown-linux-musl →  tty7-server-linux-aarch64-musl

`<os>-<arch>` in that order because that is what the GUI assets in the same
release already use (`tty7-<version>-linux-x86_64.tar.gz`). One release should
be one naming scheme; it was two.

The triple stays everywhere it really is a build target — `cargo zigbuild
--target`, the `target/<triple>/release` path, the rust-cache key, ci.yml's
matrix. The workflows now carry both: `target` for the build, `asset` for the
filename, deliberately not the same string.

This name is a contract with more than the release step, and all of it moves
together:

- `install::asset::{ASSET_X86_64, ASSET_AARCH64}`, which is what the client
  appends to a release URL.
- `bundle-windows.ps1`, which stages the musl binary for WSL. `wsl.rs` looks
  for `<dir>/<asset name>` with nothing translating, so the *filename* is as
  much a contract as the `server/` directory is — now said out loud in both
  places, along with the consequence for `TTY7_BUNDLED_SERVER_DIR`: a
  cross-compile has to be copied to the asset name, not left as `tty7-server`.
- The GUI's install prompt fixture, the checksum manifest fixtures, and the
  `MissingBundled` assertions.

Nothing globs the old shape: `gh release upload dist/*`, `checksums.txt`'s
`find`, and the installer's `server\*` are all name-agnostic.

A new test pins both names as literals — the module header already says this
naming is "a *literal* contract with the release workflow", and asserting the
consts against themselves asserted nothing. It also fails on the substring
`unknown`, since that word only ever arrived here by way of `matrix.target`,
and checks neither name contains the other, which is what
`checksums::expected_digest` says out loud that it relies on.

Compatibility: a stable client asks its own frozen tag, which keeps whichever
name it shipped with, so every released client keeps working. The rolling
`nightly` tag is replaced each night and its prune step drops assets the run
did not upload — so an *already installed* nightly client 404s on the server
download until it updates itself. Accepted deliberately; the next release is
what has to be right.

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:55:52 +08:00
66fcc95e2e fix(install): name remote servers by dialect, and prove the name before publishing (#264)
* fix(install): name remote servers by dialect, and prove the name before publishing

A remote tty7-server was installed as `tty7-server-<version>`, and one stat of
that path decided whether anything got installed at all. The version string is
not an identity: every build between releases carries the same one, so a client
speaking control v3 found the released v2 binary sitting at the exact path it
looks for, skipped the install, adopted a server it cannot talk to, and died in
the handshake with no way out. `check_running_build` compared the same string
and so never even probed.

Name the file after the two numbers that actually decide compatibility —
`tty7-server-c<control>p<protocol>` — and after uploading, ask the binary what
it speaks and publish it only if the answer matches the name it is about to get.
That turns "the filename is the dialect" from a convention into a fact a stale
bundle or an older release tag cannot break, and surfaces an architecture
mismatch at install time rather than as `Exec format error` inside a later
daemon launch.

The keep-or-restart prompt offered "Keep Sessions" for a state where keeping
means the connect fails a moment later; it now offers Cancel. A handshake that
does fail gets a Restart Server button, gated on that one error so no other
connect failure can reach a destructive action that would not fix it.

* fix(remote): offer "Restart Server" only where there is one, and keep a torn-down pane's resume record

`RemoteTarget::is_ssh` answers the question the verb depends on, in one place
rather than re-spelled at each call site: a WSL distribution's server is started
by this client and a `LocalStdio` machine is a child process per connection, so
neither has a long-lived daemon to stop and start — which is exactly why
`router::restart_server` refuses them. The UI that offers the action and the
router that carries it out can no longer disagree about who has one.

"Restart Daemon…" now asks the window which daemon serves it. Run from a remote
window it used to end every local session in every other window and leave the
machine in front of the user untouched — a destructive button that did nothing
its label promised.

A pane torn down with its coding agent no longer publishes an observation. The
kill hangs up the whole process group, so the agent dies before the PTY EOFs and
a poll firing on the bytes still in flight sees nothing recognizable; publishing
that cleared the record's agent and session id, and the reopened workspace came
back to a bare shell instead of the conversation. The same answer about a pane
nobody is tearing down still means the agent exited on its own.

* fix(remote): review follow-ups on dialect-named servers

Six findings from a review pass over the two commits. One commit
because they are all corrections to the same change.

- The progress bar the `Restarting` phase was added for never appeared
  for the flow that needs it most. The switcher only reads a machine's
  phase while *this* window is connecting or showing an error, and
  "Restart Server…" in the machine's `⋯` menu is deliberately offered
  whatever the link is doing — so restarting a connected or offline
  machine recorded a phase nobody read, and the click had no visible
  effect for the length of two timeouts. A `Restarting` phase now
  counts on its own: it is only ever recorded by an action a user
  asked for, it ends every session on that machine including the ones
  other windows are showing, and it is the one flow that transfers
  nothing and so has no other way to say the click landed. The header
  says "restarting…" rather than borrowing "installing…", which
  described bytes that are not moving.

- "Restart Server" on the error card silently did nothing when the
  machine could no longer be addressed: `replace_remote_server` logged
  the failure and returned, past a prompt that had just promised the
  server would be replaced. It reports it, like every other failure on
  that path.

- `is_ssh` gates an action that ends every session on a machine, and
  spelled the three yes-variants as a `matches!` — so a new
  `RemoteTarget` would inherit "not SSH" by falling off the end of the
  pattern, which is the opposite of what its own test claims. An
  exhaustive `match` makes the compiler ask.

- The mismatch prompt's detail explains its buttons by name and the
  buttons were written out again at the prompt. `Keep Sessions` was
  one of them until this branch removed it, which is exactly the drift
  worth preventing twice: `MISMATCH_ANSWERS` is named once beside the
  detail, and a test asserts the detail explains every answer offered.

- `unique_temp`'s pid is private to a process, not to a client: two
  clients on different machines can share a pid. Left alone, because
  the new `--protocol` check stands behind it — interleaved bytes
  cannot answer with our dialect, so they are refused rather than
  published — but said, along with the fact that two installs inside
  one process share the path too and are the locks' job, not this
  function's.

- Ten doc sites still said the installed file is named after the
  version, including the module table's step 2, `client_version`'s
  "the server that matches me", `Installer::run`'s postcondition and
  `ensure_remote_server`'s returned path. Also corrected the claim
  that WSL and `LocalStdio` have no daemon to restart: WSL's is
  started by this client, which is why "stop it and reconnect" is the
  whole verb — the reason the router refuses them, not the absence of
  a daemon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:54:40 +08:00
l0ng-aiandl0ng-ai 33eedad90a fix(ui): the routed-auth test no longer hangs, and no longer loses its prompt (#263)
* fix(ui): the routed-auth test can no longer hang the whole suite

`a_routed_auth_prompt_carries_the_machine_that_raised_it` waited for its own
prompt in an unbounded spin loop. `AUTH_MAILBOX` is process-global and
`pump_auth_sheets` drains every entry in one pass, so any gpui test in this
binary that drives a tick can take that prompt first — and unbounded, the loop
then spins until GitHub's six-hour job limit.

This is the hang CI has been paying for, named twice and on two platforms:
2h50m inside this test on x86_64-unknown-linux-gnu (run 30517182773), and again
on windows-msvc (run 30526538997). It is *not* the cfg(windows) transport
accepts bounded in #261; those were a separate latent six-hour hang.

The loop now has a ten-second deadline and an assertion that says what an empty
mailbox means and whether the responder thread had finished. Note what that does
and does not buy: a stolen prompt becomes a fast, self-explaining failure instead
of a six-hour outage, but the theft itself is still possible, and curing it means
deciding what that process-global mailbox should be under test — a design call,
not something to settle inside a CI fix.

ci.yml keeps only a comment where a post-mortem step used to be, because the
step was worthless twice over. It cannot work: GitHub kills the step's process
tree when `timeout-minutes` trips, before the next step runs, so on run
30526538997 the dump printed two headers and nothing between them. And it is not
needed: libtest already prints "<test> has been running for over 60 seconds",
which was in every hung run all along. The obstacle was only ever that a job's
log cannot be fetched while the job is in progress — which the `Test` timeout
fixes by making the step fail.

* fix(ui): a test waiting on the auth mailbox is no longer raced by a tick

The previous commit made the flake loud instead of fatal; this stops it
happening. CI proved the mechanism on the very next run: the new assertion
fired on windows-msvc with "no routed prompt arrived within 10s ... Responder
thread finished: false", 731 other tests passing, the whole suite done in
12.39s instead of hanging for six hours.

`AUTH_MAILBOX` is process-global and `pump_auth_sheets` takes every entry in
one pass. That is right for the app — one tick, one mailbox — and wrong in a
test binary, where a test waiting for the prompt it just caused shares that
mailbox with every gpui test that drives a tick. The tick drains a prompt it
has no idea was spoken for, and the waiting test never sees it.

`MAILBOX_TURN` arbitrates: a test that needs its own prompt back claims it for
the exchange, and the drain yields while it is held. Both the static and the
claim in `pump_auth_sheets` are `#[cfg(test)]`, so a release build is byte-for
-byte what it was — there is one app, one tick, and nothing to arbitrate.

The compromise is visible and deliberate: test-only synchronisation inside a
production function. The alternative that needs no such thing is to stop the
mailbox being process-global — dependency-injected per app — which is a larger
change to a path this defect does not otherwise justify touching.

No deadlock: the claim is the first thing `pump_auth_sheets` does, before it
locks the mailbox, so the two locks are only ever taken in one order.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 18:08:56 +08:00
l0ng-aiandl0ng-ai 4f37fa8eb8 fix(transport): bound the Windows test hang — no unbounded loopback accept, and CI timeouts (#261)
* ci: bound the Windows test hang and stop superseded runs holding slots

The Windows `Test` step intermittently hangs — roughly one run in ten, on
any branch, while the same commit passes on a re-run. `cargo test` has no
timeout of its own, so every occurrence ran to GitHub's six-hour job limit:
three times in one day a 75-second step held a runner slot for hours and
reported nothing about which test was stuck. One of them took the runner
down with it ("the hosted runner lost communication with the server"), and
while those zombies held slots an unrelated PR's macOS job queued for two
and a half hours.

Three changes, none of which fix the hang itself:

- `timeout-minutes` on the `Test` step (20) and `Build` step (30), plus a
  60-minute job backstop. The honest budget is ~75s warm and ~3.5 min when
  the step also compiles the test targets, so a trip means a hang.
- A Windows-only post-mortem step on failure that dumps the process table.
  libtest names a test when it *finishes*, so the hung one is the name
  missing from a truncated list; the surviving test binary names its crate
  and test target instead.
- `concurrency` with `cancel-in-progress` for pull requests, so a
  superseded run stops competing for the shared concurrent-job budget.
  Pushes to main are exempt: each commit's run is the record of whether
  that commit was green.

The hang's cause is still unknown and cannot be reproduced off a Windows
runner. This makes it report in 20 minutes instead of costing six hours.

* fix(transport): no Windows test may block forever on a loopback accept

The three `cfg(windows)` tests in the transport's test module held five
unbounded `listener.accept().unwrap()` calls, each paired with a client
thread that `unwrap()`s its `connect`. When such a thread panics — a
transient loopback refusal on a loaded runner is enough — nothing is left
to wake the accept, and nothing is left to feed the handshake read after
it. The test does not fail; the whole test binary stops.

That is the shape of the hang CI has been paying for: Windows-only (these
tests are `cfg(windows)`, so no developer's macOS run executes them),
intermittent, and mute — libtest names a test only once it *finishes*, so
no log ever said which one was stuck.

`accept_within` polls a non-blocking listener against a ten-second
deadline, then restores blocking mode and puts a read timeout on the
accepted socket. Winsock hands an accepted socket the listener's blocking
mode, so clearing it on the returned stream is a real step, not a no-op.
Verified on the host target, where the logic is identical std code: a real
client is still accepted and its handshake read still works, and a client
that never arrives fails in 10.0s instead of never.

Whether this is the exact hang CI hit is unproven — it cannot be
reproduced off a Windows runner, and `aws-lc-sys` will not even build for
the Windows target on a mac. It is the only Windows-only cluster of
unbounded network waits in the tree, and it matches every observed
symptom. Either way the six-hour failure mode is gone from here.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 16:23:59 +08:00
l0ng-ai 587887a55a fix(ui): a failed WSL probe keeps the distributions it already found 2026-07-30 13:38:22 +08:00
l0ng-ai 1eacbdcfca fix(wsl): wait for the daemon to answer instead of guessing a second 2026-07-30 13:38:22 +08:00
l0ng-ai 14ca31fb91 docs(core): retire the M8 notes on a WSL target that now exists 2026-07-30 13:38:22 +08:00