Commit Graph
94 Commits
Author SHA1 Message Date
l0ng-ai 32ccd6609e docs(skill): teach the shipped contract what an orphan is now
The docs site's reference was corrected when `--orphans` learned to spare
panes a client is attached to. The skill that ships with the product was not,
and it is the worse of the two to have wrong: an agent reads it before it runs
anything, and it still said `--orphans` "closes every pane no workspace holds"
and "closes exactly what `pane ls --all` marks orphaned".

The reference is drift-guarded against the CLI; the skill is guarded against
nothing, which is exactly why it drifted. Both are now held to stating the
attachment half of the test — to the rule, not to a sentence, so a reword that
drops it from either file fails at the moment someone can still check the
other.
2026-08-23 13:21:35 +08:00
l0ng-ai c14f05829f fix(cli): flag a row as an orphan by the same test the reaper uses
Half a change, found by reading the docs rather than the code. `pane ls --all`
learned to count strays by whether anybody is attached; the per-row `orphan`
flag beside that count did not. The same JSON object contradicted itself, and
the reference says `--orphans` "closes what `pane ls --all` lists as orphaned"
— which had stopped being true for exactly the rows that matter, the panes a
window is adopting during a restore.

A script filtering `.panes[] | select(.orphan)` would have gone after panes
the reaper deliberately spares.

The row, the count, the doctor's row and the reaper now make one test, held by
a test that walks all four. The reference states both halves of what an orphan
is, since one of them is the half that keeps the command from taking a session
that is still coming up.
2026-08-23 13:15:26 +08:00
l0ng-ai 2886262d8c fix(doctor): count the strays the reaper it recommends would actually end
The row reads "N running that no workspace holds — `tty7 pane close
--orphans` ends them", and it was counting a different set from the one that
command takes: every pane a window is adopting during a restore counted here,
and the reaper now correctly ends none of them. A row that says seven beside
a command that ends nothing is a worse answer than either alone.

All three surfaces that tell the user about this set — the reaper, the
listing's count, the doctor's row — now spend one predicate, so the next
change to what counts as stray cannot land on two of them. Deleting the
attachment half of it fails a test on each.
2026-08-23 11:40:58 +08:00
l0ng-ai 8a7302ae34 fix(cli): stop pane close --orphans reaping a session mid-restore
The reaper's test was "the registry is running it and no workspace holds
it". A window restoring a layout spawns each pane, attaches to it, and only
then files it into the tree — so for a moment every pane it is adopting
answers to that description.

Not theoretical. Polling `pane ls --all` through a cold start of a seven-tab
window reported one, then two, then three, then six, then seven live panes as
held by no workspace, and the restore afterwards was correct: all seven were
wanted. `--orphans` at any point in that window takes the whole session, and
a script that reaps on a timer will eventually sit in it.

Attachment is the missing half, and it is the daemon's own fact rather than a
guess about timing: `attach` takes the pane's seat and the connection closing
calls `detach`, which clears it. A window adopting a pane is attached to it.
A pane whose layout was thrown away had its view dropped, which closed the
connection, which emptied the seat. Nothing else in the registry tells the
two apart, which is why the GUI had to ask its windows and the CLI could not.

`pane ls --all` counts the same set, because the line it prints tells the
reader to run the reaper.

Same race after the change: zero false positives across 300 polls, restore
intact. And a stray with nobody attached is still reaped — checked by
deleting the new filter, which fails both tests.
2026-08-23 11:36:47 +08:00
l0ng-ai ac90f6c212 fix(doctor): say when the server is running panes nothing holds
An interrupted `tty7 run` strands a pane. So does a window reconciling its
layout while something else edits the tree — measured at 17 from 160
operations, every one a live `zsh` holding a pty and its descriptors, and
`tty7 pane close --orphans` ended all 17.

None of that was news to tty7: `pane ls --all` names them, the switcher lists
them, and the reaper works. What was missing is that nothing volunteered they
existed. `doctor` printed `status  pid …, up 6s, 2 panes` and counted the
stray among them, so the one verb somebody runs when something feels wrong had
the number and did not say what it meant.

A row and a `server.orphans` count. Deliberately not an exit code: one stray
after an interrupted `run` is ordinary and the reference documents it as such,
so `tty7 doctor || alert` firing on it would cry wolf. What was missing was
the sentence, not an alarm.

Counted inside the arm that already has the machine tree in hand for the
dangling-context row, so it costs no extra round trip, and under `server`
because it takes a server to have panes at all.

The first draft put the count at the top level of the JSON, and
`the_doctor_json_sections_are_the_ones_the_reference_names` — added earlier
today for exactly this — caught it as an undocumented section before it went
anywhere.
2026-08-23 08:13:50 +08:00
l0ng-ai 9e49b6a412 test(cli): walk the nested verbs when checking the reference for flags
`the_reference_documents_every_flag_of_the_verbs_it_covers` read one level:
`cli.get_subcommands()`, then that verb's own arguments. Two things followed.

It never descended, so every flag a level down was unchecked — `tab new --cwd`,
`pane ls --all`, `pane split --ratio`, `pane close --orphans`,
`server restart --hard`. And its gate for "is this verb documented" looked only
for ``` `tty7 <name> ```, which the page uses for leaf verbs; a group is headed
``` ## `pane` — panes ```. So `ws`, `tab`, `pane`, `machine` and `server` were
skipped whole, taking their children with them. Fifteen flags were checked
where there are twenty-two.

Both fixed: the walk recurses, and the gate accepts either heading shape. The
reference turns out to be complete — nothing was actually undocumented, which
is worth knowing rather than assuming.

The matching rule was too strict as well, and the extension is what showed it:
requiring a backtick immediately before the dashes reported
`server restart --hard` missing when it has its own table row, written as
``` `server restart --hard` ```. It now looks for the flag anywhere on the
page.

The floor is five named nested flags rather than a count, because a count
drifts with the surface and what has to hold is that the walk went down.
Verified both ways: dropping `--orphans` from the page is reported, and
stopping the recursion is reported by name.
2026-08-23 07:28:52 +08:00
l0ng-ai fa5b901d8d fix(doctor): check the shell every new tab is going to launch
$ cat config.json
    { "shell": { "program": "/nonexistent/shell" } }
    $ tty7 new
    tty7: spawning a shell: daemon refused Spawn:
          no such program on this machine: /nonexistent/shell
    $ tty7 doctor | grep config
    config           ok

Nothing can open a tab, and the verb that checks the install says the config
is fine — which it is, in the only sense that row means: the file parses. The
same table already reports an unusable `custom_shells` entry, and that costs a
menu row. This costs every tab, every `tty7 new`, and the GUI's new-tab button.

The check is the daemon's own `shell_program_problem`, moved from
`daemon::pane` to `core::shells` beside `unusable_custom_shells` so both
callers share one definition and the row cannot drift from the refusal it is
predicting — it prints the same sentence the spawn will. Missing, a directory,
and not executable are told apart, because they are three different fixes.

A program given as a bare name is still not reported: the OS resolves it
through PATH and guessing at that would be worse than silence — the moved
comment says so and the moved tests pin it. Skipped under `-m` for the same
reason the hooks row is: a path checked here would answer about the wrong
machine.

Verified against a live daemon across all five cases — missing, directory,
non-executable, bare name, and no `shell` set — with exit 1 for the three that
break and 0 for the two that do not.
2026-08-23 06:11:12 +08:00
l0ng-ai 4e5699dc89 fix(config): say what is wrong with a config file, and where
Three different mistakes, one answer:

    { "shell": "/nonexistent/shell" }  ->  NOT VALID JSON
    { "font_size": 12,, }              ->  NOT VALID JSON
    { "font_size": "big" }             ->  NOT VALID JSON

Two of those *are* valid JSON. They are valid JSON in the wrong shape — a
string where a struct goes, a string where a number goes — which is a
different mistake with a different fix, and telling that reader their file is
not valid JSON sends them hunting for a missing comma that is not missing.

serde has already worked out the answer. It names the field, the type it
wanted, and the line and column. That went to `log::warn!` and nowhere else,
and there is no log unless `TTY7_LOG` is set, so on a default install it went
nowhere at all — which is the same shape as the keybinding faults and the
clamped settings this tree has already fixed.

Now:

    DOES NOT FIT — invalid type: string "big", expected f32 at line 1 column 20
    NOT VALID JSON — key must be a string at line 1 column 19
    NOT VALID JSON — EOF while parsing an object at line 1 column 17

`parse_fault` is a helper rather than a field on `LoadOutcome`, which is
`Copy` and crosses several call sites that only want the verdict; this is
asked once, by a diagnostic, about a file already on disk. The window's
notice appends the same detail on its own line — deliberately after the
translated sentence rather than inside it, because serde's message is English
that is not ours to translate and a placeholder would leave a raw parser
string in the middle of a localized one. The notice's own wording needed no
change: it says "could not be parsed", which was true of all three.
2026-08-23 06:05:50 +08:00
l0ng-ai 4805cb1765 fix(doctor): notice a config directory that cannot be written to
Made the config directory read-only and asked doctor about it:

    CHECK            RESULT
    TTY7_CONFIG_DIR  set (/tmp/…)
    config           none yet — the defaults are the config
    server           ok (build 26.8.3)
    …
    rc=0

Meanwhile, on the same directory:

    $ tty7 new
    tty7: could not write the machine tree at /tmp/…/machine.json:
          Permission denied (os error 13)

Every `new`, every `tab new`, and every settings save fails, and the verb
whose entire job is "check this install" called it healthy. The config row is
about *reading* — it is answering "does the file parse", and there is no file
— so nothing in the table was wrong, and nothing in it was the answer either.
`tty7 doctor || alert` is exactly the thing that should have fired.

Now: a row naming the directory and what stops working, a headline on stderr
so `-q` still says it, `config.dir_writable` in the JSON, and exit 1 alongside
the unparseable-config case it sits next to.

The check writes a probe file and removes it, rather than reading the mode
bits. A read-only mount, an ACL, an immutable flag or another user's directory
all leave `0700` on something that refuses every write, and what is being
diagnosed is whether the write succeeds. A directory that cannot be reached at
all counts as unwritable — an install nobody can open is not a healthy one.

Verified against a live daemon in all three states: healthy exits 0, read-only
exits 1 with the row and the headline, and fixing the permissions goes back to
0. The unit test covers the probe both ways and that it cleans up after
itself; replacing it with an `exists()` check fails it.
2026-08-23 05:47:16 +08:00
l0ng-ai 16370ed30a docs(cli): record what the capture settle timer actually costs
`tty7 capture` takes ~323ms against a local daemon and neither constant said
why. Measured: a pane that has printed nothing costs the same as one holding
22,000 lines, `--plain` costs the same as raw, and lowering the replay grid's
floor from 10,000 lines to 100 changes nothing. The grid work is ~20ms; the
rest is `REPLAY_SETTLE` running out. `tty7 pane ls` over the same socket is
~5ms.

It is paid because the replay has no end. The daemon sends `Size` then
`Snapshot` per ring segment and stops, and the only way to know it has stopped
is to wait. Live `Output` cuts the wait short, so a busy pane returns at once
— but the idiom the reference recommends is `tty7 wait … && tty7 capture …`,
and `wait` returns exactly when the pane has gone quiet. The recommended path
always pays in full.

No behaviour change; this is the note that was missing. It also records the
two wrong ways out, because both look attractive from the outside. Lowering
the timer trades a bounded delay for silently truncated output on a slow link
or a loaded machine. An end-of-replay message is the real fix and is *not*
additive — `DaemonMsg::read` rejects an unknown kind outright, so a daemon
sending one to an older client breaks it. That needs a `PROTOCOL_VERSION` bump
with the daemon gating on what the peer announced, which is a larger change
than a comment.
2026-08-23 05:22:47 +08:00
l0ng-ai a77d691dad fix(cli): send a bad tab address to the table that has the @ numbers
Five places tell someone where tab `@` numbers come from, and they had drifted
to three different answers. The three `--help` strings say `tty7 tab ls`. The
two runtime errors — the ones a person actually meets, because they only
appear once something has already gone wrong — said otherwise.

`'@deadbeef' is not a tab address — @7 as numbered by `tty7 ls`` was simply
wrong. `tty7 ls` lists workspaces: `WORKSPACE NAME TABS PANES ATTACHED`, where
TABS is a count. There is no `@` anywhere in it. Someone following that
instruction finds nothing and has to go looking.

`no tab @N — `tty7 pane ls` shows the @ numbers` was true but sideways.
`pane ls` does carry a TAB column, but the verb the person is already holding
is `tab`, and `tab ls` is the table with one row per tab and the number in the
first column.

Both now say `tty7 tab ls`. The guard reads all three sources for anything
teaching where the numbers come from and requires that answer, so a sixth
place — or a sixth answer — is caught rather than assumed. It skips its own
test module, which names the phrases it searches for.
2026-08-23 05:10:37 +08:00
l0ng-ai a67234266b docs(cli): name doctor's config section, and pin the shape
`tty7 doctor --json` has emitted a fourth top-level section, `config`, since
it started answering for a config file that parses but carries keys tty7 does
not read. The reference went on describing three. Checked the documented shape
of every `--json` verb against a live daemon; this was the one that disagreed.

It matters more here than elsewhere. `doctor` is one of the three verbs whose
JSON is printed even when the verb fails — an unreachable server is the
finding, and `tty7 doctor || alert` needs the rows as well as the code — so
something is always parsing this.

The page now names `config`, says what `ok` and `state` mean, and says which
of the table's rows are prose rather than fields, so the next reader is not
left looking for `config keys` in the JSON.

The guard compares doctor's top-level sections against the ones the reference
line names, both directions. Top level only: the fields inside vary with what
a server could be asked — `context` gains `workspace_gone` and `pane_gone`
only when one answered — and the page explains that in prose it would be wrong
to pin to a fixed list. Verified by dropping the section from the page, and by
dropping it from doctor.
2026-08-23 05:06:39 +08:00
l0ng-ai 020769b3bb docs(cli): server restart keeps sessions, and --hard is the one that does not
`Restart` became `Restart { hard: bool }` and the reference page did not
follow. It still read

    | `server restart` | Stop, then start — same consequence |

which had been true before the flag split the two apart. So the page told
a reader that restarting the server kills every pane on the machine — it
does not — and said nothing whatever about the option that does.

Both halves measured against a running server with a `sleep 600` in a
pane, not taken from the help text:

    server restart          "restarted in place; sessions kept running"
                            shell 74647 still alive, pane still LIVE
    server restart --hard   "stopped and started; sessions ended"
                            shell 74647 gone, pane LIVE=no

Wrong in the cautious direction, which is the quiet kind: someone who
wants their server on a new build reads that line, believes it will cost
them every shell, and does not run it — while the destructive spelling
they were never told about sits one flag away.

The guard walks clap's own command tree and holds every long flag to
being named somewhere on the page. Names only: whether the prose around a
flag is right is not something a test can hold, but a flag missing from
the page entirely is, and that is the state that shipped. Global flags
are exempt — they repeat on all forty-odd verbs and the page documents
them once, in a table of their own.

Checked against the page as it was: the guard reports `restart: --hard`.
2026-08-23 00:58:36 +08:00
l0ng-ai b6b3e782b4 fix(doctor): name a custom shell that can never appear
A `custom_shells` entry with nothing to launch is dropped by
`append_custom`, which says so with `log::warn!` — and there is no log
unless `TTY7_LOG` is set.

The way an entry ends up empty is what makes this worth its own row.
`CustomShell` is `#[serde(default)]`, so a misspelled key *inside* an
entry — `programm` for `program` — is not a parse error but an entry with
every field defaulted. And `custom_shells` is itself a real setting, so
the unknown-key row added alongside this cannot see it: that one compares
top-level keys, and the typo is a level down.

So the file parses, `doctor` says `ok`, both key checks pass, and the
menu row simply never appears. Measured on a config holding one typo'd
entry and one good one.

    custom shells    1 of them name no program, so their menu rows never
                     appear: entry 0 — a misspelled key inside an entry
                     reads as an empty one

By position, because an entry broken this way usually has no label to
name it by — that is the same misspelling, one field over.

The test pins the mechanism rather than only the outcome: it deserializes
a misspelled entry and asserts it lands as `CustomShell::default()`,
because "an unknown key is not an error here" is the whole reason the
report is needed.
2026-08-23 00:42:13 +08:00
l0ng-ai 1552abb010 fix(doctor): name the config keys tty7 does not read
Mistyping a setting name is the likeliest thing to go wrong in a
hand-edited `config.json`, and the quietest. The file still parses, so

    config           ok

while the setting does nothing. `note_unknown_keys` has found these all
along — it says so with `log::warn!` behind a `log_enabled!` guard, and
per `docs/reference/privacy.mdx` there is no log at all unless `TTY7_LOG`
is set. Measured on a config carrying `font_siz`, `scrollback_limitt` and
one real key: nothing anywhere named the two that did nothing.

Unlike the keybindings map, this one `doctor` can ask: `unknown_keys` is
already in the crate the CLI shares, and already guarded by a test that
no real field may ever be reported as a typo. It only needed a way in.

    config keys      not settings tty7 reads, so they do nothing:
                     font_siz, scrollback_limitt — check the spelling
                     against the reference page

Only when the config parsed, and only when there is something to say. A
quarantined config is running on defaults and *every* key in it is
unread; naming them all would bury the row that matters. A clean config
prints no row at all, so this reads as news.

The test asks the pure function rather than the file-reading wrapper.
`unknown_config_keys` reads `TTY7_CONFIG_DIR`, and setting that from a
test steers every other test in the process — the first draft did, and
failed three runs out of three under the parallel suite.
2026-08-23 00:35:46 +08:00
l0ng-ai e7d04066d2 fix(capture): --plain no longer answers with a fraction of the pane
`capture` offers "two independent choices": how much (`--scrollback`) and
in what form (`--plain`). They were not independent. Every `--plain` grid
was built with `Config::default()`, whose 10,000 scrolling lines were
justified in a comment as "the daemon ring's order of magnitude".

The ring is capped in *bytes* — 8 MiB — which at ordinary line lengths is
nearer 100,000 lines. So the plain form silently dropped most of what the
raw form returned, and `--scrollback` could not bring it back. On a pane
that had printed 120,000 lines:

    capture           8,388,609 bytes, from line 30,764   (the whole ring)
    capture --plain     932,651 bytes, from line 109,973
    capture --scrollback --plain   identical to the above

Nine tenths of the pane missing, with nothing said. `--plain` is the form
the orchestration docs reach for (`tty7 wait %3 && tty7 capture %3
--plain`), so an agent reading the end of a long build log got the tail
and no reason to doubt it had the rest.

The grid is now sized from the segment it has to replay. Counting bytes
per column is not enough and the difference is the bug in miniature: a
line shorter than the pane is wide still costs a whole row, so dividing by
the width under-counts exactly when lines are short, which is most output
— that estimate alone recovered sixty thousand lines and still stopped
twenty thousand short. One row per newline plus one per screenful of wrap
is the bound that holds. Both forms now begin at line 30,764.

Over-estimating is free: alacritty grows its history as lines arrive
rather than allocating up front, and the daemon's ring bounds the whole
thing. Measured: an ordinary capture is unchanged at 8 MB resident and
0.3s; a full 8 MiB ring costs 343 MB for the 2.5s it takes.

The regression test uses short lines for the reason above, and fails
against the old fixed 10k grid.
2026-08-22 23:51:47 +08:00
l0ng-ai 449d38e538 docs(wait): say that an unknown pane answers exit, and pin it
`tty7 wait` is the one address-taking verb that does not refuse a pane the
server has no record of. `capture`, `procs`, `send` and `pane close` all
exit 1 on the same address; `wait` answers `exit`, `matched: true`,
`stale: true`, and exits 0.

That is the right behaviour and must not change. The server forgets a
pane once it is reaped, so "the worker finished and was cleaned up" and
"that id never existed" are one question to it — measured, not assumed: a
pane that really ran and exited comes back byte-identical to `%9999`,
and `pane ls --all` has forgotten both. Refusing would break the first
case, which is the ordinary end of an orchestration: you wait on work
that may already be over.

What was missing is that nobody had written it down. Neither the CLI
reference nor the orchestration page said what an unknown pane does, and
both define `exit` as "the pane is gone" — true of a typo, but not what a
reader takes from it when every neighbouring verb errors. An orchestrator
that trusts a bare `wait` as proof the work happened gets an instant
success from a stale id and reads an empty capture as "no output".

So both pages say it, and a test pins it. Without the test this is an
accident that reads like a bug, and the obvious "fix" — make it error like
its siblings — would silently break waiting on finished work.
2026-08-22 23:41:08 +08:00
l0ng-ai 62d44f3d0f fix(doctor): say when a workspace tree was set aside
A `machine.json` that does not parse is copied to `machine.json.corrupt`
and the machine comes up with no workspaces at all — every tab and every
pane layout on it. `MachineStore::open` calls that "recoverable by hand",
and it is, but only for a hand that knows where to look.

Nothing told it. The quarantine announces itself with a `log::warn!`, and
per `docs/reference/privacy.mdx` there is no log at all unless `TTY7_LOG`
or `RUST_LOG` is set — so on a default install the whole thing is silent.
What the user sees is `tty7 ws ls` saying "no workspaces — `tty7 new
<path>` starts one", which reads as an empty machine rather than as a
lost one, and gives no reason to look in the config directory.

`doctor` already makes this argument for `config.json`: a file that does
not parse is exactly the state someone runs `doctor` in, and none of it
is visible from the rows around it. The tree is the same case with more
at stake — settings are still on screen when `config.json` is quarantined;
workspaces are not.

The row only appears when a copy is really there. One that said "no tree
was set aside" beside every intact machine would be noise on every
install, and this has to read as news.

Verified end to end against a running server rather than only in a test:
corrupt the tree, restart, `doctor` names the copy; restore it, the
workspaces come back and the row goes away.
2026-08-22 22:56:55 +08:00
l0ng-ai 791d0d0cfa Merge remote-tracking branch 'origin/main' into polish/ralph-wc
# Conflicts:
#	README.md
#	README.zh-CN.md
#	crates/tty7-cli/src/cli.rs
#	crates/tty7-cli/src/server.rs
#	crates/tty7-core/src/core/config.rs
#	crates/tty7-core/src/core/git/status.rs
#	crates/tty7-core/src/daemon/install/wsl.rs
#	crates/tty7-core/src/daemon/protocol.rs
#	crates/tty7-core/src/daemon/spawn.rs
#	crates/tty7-core/src/daemon/ssh/mod.rs
#	src/terminal/completion.rs
#	src/terminal/remote.rs
#	src/ui/app.rs
#	src/ui/i18n/en.rs
#	src/ui/i18n/ja.rs
#	src/ui/i18n/zh.rs
#	src/ui/tree_sync.rs
2026-08-22 16:48:33 +08:00
l0ng-ai 975e3edf9b Fix Windows path quoting, wire up Checkout to…, bound the Spawn reply (#705)
* fix(windows,scm,daemon): quote paths per shell, wire Checkout to, bound Spawn

Five fixes from a whole-codebase audit, in one sweep because they share
the paths they touch.

Path quoting had two implementations. file_tree::shell_quote_for wrapped
the path in quotes and picked the right ones per shell (#593);
view::shell_escape_path escaped with backslashes, which is POSIX-only
and collides head-on with the Windows path separator, so a dropped file,
a pasted path, a staged image path and an accepted completion candidate
all lost their separators there. completion::complete_path stripped the
same backslashes back off before looking a path up, so inline path
completion could never resolve a directory on Windows either. Both now
go through one core::shell_quote module, and shell_word_start tracks
quoting across the word so a second Tab still finds the word it just
inserted.

"Checkout to..." was registered, listed in the palette, bindable, and
handled by an empty match arm — invoking it did nothing at all. It now
opens an inline input row in the SCM panel, the twin of the existing
"create branch" one.

RemoteTerminal's Spawn read the daemon's reply with no deadline, while
Attach in the same file and PaneSession::spawn_over in core both bound
theirs. A daemon caught mid-restart accepts the connection and never
serves it, and the local route spawns synchronously on the UI thread, so
the silence froze the window on "new tab".

Two Windows papercuts: client_hostname spawned a console program from a
GUI process (a visible console flash) where COMPUTERNAME already has the
answer, and completion generators were a silent no-op with no way to
tell "produced nothing" from "never ran".

Three duplicated implementations merged: proc_name existed twice in the
daemon with a different fallback in each, the GUI's control link was the
one client socket that skipped transport::tune, and fps.rs and perf.rs
were the same windowed meter copied twice.

* refactor(completion): stop declaring spec fields nothing reads

The Fig spec structs mirrored seven keys the completer never looks at,
each held up by its own #[allow(dead_code)]. Serde ignores unknown
fields by default, so dropping the declarations parses the same specs
and drops the attributes with them.

* refactor(daemon): delete the loopback-forward management pipeline

Two protocol messages, their kind codes, encode and decode arms, two
daemon dispatch arms, two wire structs and two GUI client wrappers all
existed to reach SshManager::list_loopback_forwards and
close_loopback_forward, which were hardcoded to Vec::new() and false.
Nothing called the client wrappers either.

The kind codes are left as holes rather than renumbered, the way 13
already is, so the wire format is unchanged for every other message.

known-hosts management looks like the same shape but is not: its backend
parses the real file, fingerprints keys and rewrites through a 0600 temp
file. That one keeps its client half and gains a comment saying it is an
interface waiting for a screen.

* test(ssh): cover the host-key policy table and both proxy handshakes

The host-key decision is lifted out of check_server_key into
host_key_action, so what to do about Known/Unknown/Changed/
ChangedAlgorithm/Revoked can be read and tested without a server, a
broker or a known_hosts file. Eight tests pin it, including the two
subtleties the comments already claimed: verify_host_keys=false still
rejects a revoked key, and a new algorithm asks the unknown-host prompt
rather than a new variant older peers cannot decode.

socks5_connect and http_connect are split into connect + handshake, the
handshake generic over the stream, so nine tests drive them from an
in-memory duplex: length-prefix framing, the variable-length bound
address, auth refusal, reply codes, and the header terminator.

* test(cli,daemon): cover server binary resolution and the procargs parser

server_exe is split into environment lookup and resolve_server_exe, the
latter taking its three sources and an is_exe predicate so seven tests
can pin the precedence without touching the filesystem. Holding the
sibling to is_file rather than exists fixes a directory named
tty7-server shadowing the real binary on PATH.

parse_macos_procargs gets six tests over the KERN_PROCARGS2 layout:
exec-path skipping, however many bytes of alignment padding follow it,
argc bounding argv so the environment stays out, truncation, and a short
buffer.

* test(ui): cover the host-op pool decisions and the local reconnect schedule

The pool's retire condition moves into should_retire with the reason
named: a worker must not retire on the timeout alone, because submit
counted it as idle and so did not spawn a replacement for the job that
landed meanwhile.

LocalLink::tick's schedule moves into due(), taking the clock and the
link's state as arguments. The first attempt going out immediately, the
backoff only applying from the second, and a pending deadline not being
pushed further out by later ticks are now pinned. The identical
scheduler in remote_workspace had TestAppContext coverage; this one,
which every launch depends on, had none.

* fix(completion): unquote across the whole word, not just its first character

The round-trip test caught two things the first cut got wrong. A quote
can open partway into a word — quote_for_shell emits ~/'My Documents' so
the shell still expands the tilde — and a single-quoted body is literal
all through, so unescaping backslashes inside one took the separators
out of 'C:\Users\me'. Scanning with a quote state handles both, and
makes the '\'' seam fall out of the state changes rather than needing a
case of its own.

The GPUI test for accepting a candidate follows the insertion from
backslash escaping to quoting.

* fix(windows): unbreak the Windows build and quote for PowerShell's own dialect

`Instant` was moved behind `#[cfg(unix)]` while the generator cache still
uses it unconditionally, so the Windows target stopped compiling.

The quoting module treated every shell but cmd.exe as POSIX, including
PowerShell. PowerShell does not join a quoted string to the bare word beside
it, so the `'\''` seam is not a seam there — `C:\Users\O'Brien` came out as
three tokens, and the completion un-quoter turned the apostrophe back into a
backslash. Quoting is now a three-way dialect (cmd / PowerShell / POSIX)
chosen once and threaded through completion in place of the escapes flag.

* test(file-tree): name the shell where the quoting rule is the POSIX one

`shell_quote_for(_, None)` answers from the platform, so an assertion about
the `'\''` seam has to say which shell it means or it fails on Windows,
where the unnamed shell is PowerShell.
2026-08-20 23:33:26 +08:00
l0ng-ai 8131ac2f93 Address a tab by the bare id --json prints, and stop the skill sending workers in headless (#699)
* fix(cli): address a tab by the bare id --json hands back

`parse_tab` required the `@` sigil, so the tab id from `tty7 tab new --json`
— the one id a caller is certain of — was the one shape the CLI refused.
`parse_pane` already made `%` optional for exactly this reason (#538); this
aligns tabs with it, keeping the digits-only guard so a leading `+` cannot
read as an ordinal now that the sigil is gone.

* docs(skill): hand a pane worker its interactive mode

The worked example passed the task with `-p`, which draws nothing: the pane
stays blank until the turn ends, `capture --plain` reads back empty, and the
user watching their tty7 window sees a worker that looks hung. Putting a
piped worker in a pane discards the only reason it is in one.

Also documents three things that cost real debugging time: a fresh pane can
swallow the Enter while its shell is still running startup files, `tty7 procs`
reports nothing running for a pane with a live agent in it, and the OSC 777
event stream in a raw `capture` is what actually answers "is it moving".
2026-08-20 18:28:48 +08:00
l0ng-ai f4c31222a4 feat(cli): restart the server in place by default, keeping sessions (#669)
* feat(cli): restart the server in place by default, keeping sessions

`tty7 server restart` used to be stop + start, killing every pane, while
the GUI's Restart Server hands the daemon off to a new image via execve
and keeps everything running. Same verb, opposite side effects.

The CLI now probes the daemon for the handoff feature and asks it to
exec the tty7-server binary in place: same pid, same ptys, sessions
survive. Success is judged by the version endpoint answering with a new
per-process instance id, not by build strings, since the CLI and server
binaries can be on different versions.

A refused or stalled handoff leaves the daemon untouched and reports an
error suggesting `--hard` instead of silently killing sessions. The
stop + start path remains for `--hard` and for daemons that cannot
exec themselves (Windows, pre-handoff builds).

* fix(cli): leave a slow handoff's seat holder alive, and let a hard restart say sessions ended

After a taken handoff, the poll timing out does not mean the daemon died:
the singleton lock survives the exec, so a held seat is the new image
still coming up with every session aboard. Falling back to start() there
would grant it one second of grace and then reap it — bail with the seat
still held instead, and only start over a genuinely free seat.

The stop-and-start fallback (--hard, Windows, pre-handoff builds) now
reports that sessions ended instead of relaying start()'s plain report,
since the default restart's promise is sessions kept.
2026-08-17 22:59:54 +08:00
l0ng-ai 3dc63e2d87 fix(daemon): find and reap a seat-holding daemon that lost both its names (#671)
A daemon can survive quit-and-stop with its endpoint unlinked and its
pidfile gone while still holding the singleton seat (#667). Every later
launch then spawns a daemon that stands down against the lock and times
out red, and nothing on the machine can recover: stop answers "not
running", ensure_running reaps only through the pidfile, and flock
cannot say who the holder is.

Two roads led there, and both are closed:

- The reap identified a daemon by proc_pidpath alone, which fails
  outright for a live process whose binary was deleted — every nightly
  update replacing the installation. The identity check now falls back
  to the kernel's comm name (proc_name on macOS, /proc/pid/comm on
  Linux, both recorded at exec and immune to deletion), strips Linux's
  " (deleted)" marker, and — decisively — no longer deletes the
  pidfile of a live process it cannot identify: the record was the only
  handle left on the survivor.

- When the pidfile is gone entirely, the pid the claimant now writes
  into daemon.lock at claim time is the handle of last resort. The lock
  file is never deleted and holding the flock is the definition of
  being the server, so while the seat is held its content names the
  holder; stop() and the reap fall back to it, and a confirmed reap
  clears the record (only under a momentarily-free seat) so a stale
  number cannot outlive its process. Unix-only: the Windows seat is
  share_mode(0), unreadable while held.

Every road back now clears a stranded seat, not just the GUI's:
ensure_running's stale cleanup is factored into spawn::reap_stranded,
tty7 server start runs it too, and tty7 server stop no longer takes
"nobody answered" for "nothing to stop" when the seat is still held.
A short grace keeps the reap away from a daemon that is merely
mid-handoff or mid-startup — where health is an answered handshake,
never a bare connect: a wedged daemon's listener still completes
connections out of the kernel's backlog. The startup-timeout errors
name the seat-holding pid, with the kill advice identity-gated so a
stale record never tells anyone to kill an innocent process.

Two liveness corrections round it out: a zombie now reads as dead — it
answers kill(pid, 0) like the living but holds no lock and no image,
and no signal can end it, so counting it alive spent both reap timeouts
on a corpse (the GUI never waits on the daemons it spawns, so crashed
daemons are zombies as a rule) — and stop() only pays the
process-exit wait for a shutdown it actually delivered, instead of
watching an unreached survivor not move for five seconds.

The guard tests were each verified to fail against the behavior they
pin (fallbacks, the handshake criterion, the grace, and the wait gate
removed by mutation) before being trusted green; the zombie probe
semantics (proc_pidinfo failing for a zombie that still answers signal
0) were measured, not assumed.
2026-08-17 15:11:16 +08:00
l0ng-ai ac7919ed47 fix(cli): name an agent in the table the way the rest of the CLI does
The AGENT column came from a lowercased `{:?}`, which matched the agent's
slug for most variants by luck rather than by rule. Where it did not, the
table invented a third name for something that already had two: `OhMyPi`
printed as `ohmypi`, against a slug of `omp` — the spelling the hook rows,
`doctor` and the docs all use. It prints the slug now.

STATUS came the same way. The four variants are single words, so
lowercasing Debug happened to produce the documented `idle`/`working`/
`waiting`/`done`, but a variant named in two words would have printed
`needsinput` in the table against `needs-input` in the JSON for the same
pane. It goes through serde now, so the column cannot drift from what
`--json` says.

Neither the wire format nor `--json` changes: `CLIAgent` is serialised
across the control protocol, and that is a dialect not to touch in passing.

The test walks `CLIAgent::ALL` rather than listing variants, so an agent
added later is checked the moment it exists.
2026-08-16 17:51:53 +08:00
l0ng-ai bcbdf51fed fix(cli): a server that closes the event stream is not a success
`tty7 events` blocks forever by contract, so returning at all means the
control connection went away with the server — and it returned 0. A reader
whose server stopped mid-run was told nothing had happened; the loop
consuming its lines simply stopped receiving any, with no exit code to
branch on. Verified against an isolated daemon: stopping the server ended
the stream and the CLI exited 0.

It exits 1 now and says so on stderr. Nothing extra goes to stdout, so a
reader parsing NDJSON is never handed a line of a shape it has not seen —
the same split the rest of the verbs use. An interrupted run is unaffected:
a signal takes the process rather than this path.
2026-08-16 17:47:52 +08:00
l0ng-ai c1b3a10561 fix(cli): print an event as a sentence, not a Rust debug dump
`tty7 events` rendered a layout delta with `{delta:?}`, so a line the docs
describe as an event arrived as a struct literal — internal field names,
`Some(..)` and all:

    workspace f5e0c172… layout: PaneFacts { pane: PaneRecord { id: 1,
    cwd: Some("/tmp"), title: "", osc_title: None, … } }

Debug output is not a format anyone should be reading, and it is certainly
not one to keep stable: renaming a field would silently rewrite what this
verb prints for whoever had started parsing it. All thirteen deltas now
have a line — `pane %1 is gone`, `tab 70e8e933 created at 0`, `renamed to
demo` — and the catch-all arm that dumped every remaining variant is gone
with them.

That arm covered `GitChunk`, whose `{:?}` prints a `Vec<u8>` one decimal
number per byte: a single chunk of a large diff would have arrived as pages
of them. It reports a byte count now.

The docs say the prose is for reading and `--json` is for parsing.

Found by running `tty7 events` against an isolated daemon while creating
and renaming a workspace.
2026-08-16 17:45:15 +08:00
l0ng-ai baeffd0274 fix(doctor): say when the inherited context names nothing here
`doctor` reported `$TTY7_WS` and `$TTY7_PANE` as "set (id)" without ever
asking whether the id resolves. A shell outlives the workspace it was
opened in, and one opened against another machine names an id this server
never had — a state the rest of the CLI already guards against, `run
--keep` in as many words. Doctor is the verb people reach for when the
address-taking verbs have started failing, and on that exact input it gave
the reassuring answer.

The rows now say GONE when the id names nothing on the server that
answered, and `--json` carries `workspace_gone`/`pane_gone` beside the
existing booleans rather than changing them. Both are absent when no
server answered: with no tree to check against, "not gone" would be a
claim rather than an answer.

Found by running `tty7 doctor` against an isolated daemon while the shell
still carried the real server's variables.
2026-08-16 17:39:04 +08:00
l0ng-ai b9c7b8a54e fix(cli): exempt the last column from the cell bound
Bounding every cell cost `doctor` the thing it exists to report: the
config directory came out with its middle replaced by an ellipsis, and a
path a reader cannot copy is not a diagnostic. Caught by running `tty7
doctor` against the isolated daemon right after making the change.

Nothing is padded against the last column — `render_row` trims the end of
every line — so its width cannot push anything out of line, and cutting it
buys nothing. `ws ls` still bounds NAME, which has four columns to its
right; `doctor` and the agent listing, whose findings sit last, print
whole again.
2026-08-16 17:33:04 +08:00
l0ng-ai c5931a9194 fix(cli): bound a table cell so one long name cannot widen every row
`tab_label` clamps a talkative OSC title already, and says why: "so one
talkative tab cannot widen every column in the table". Nothing applied
that to the columns beside it. A 300-character workspace name — which
`tty7 ws new` accepts — padded every row of `ws ls` out past the edge of
the terminal, so a five-row listing arrived as a wall of spaces. The same
was true of any long cwd in `pane ls` and of an agent's status message,
which nobody types at all.

The bound moves into `table`, where it covers every column and every table
added later, at a full classic terminal width so no realistic name or path
is touched. `--json` remains the output for anything that must not be cut.

Found by running `tty7 ws new` with hostile names against an isolated
daemon; the wide-character padding held up in the same listing, checked by
measuring each row in display columns rather than by eye.
2026-08-16 17:25:34 +08:00
l0ng-ai db275b7770 docs(cli): say that a newline inside send's TEXT presses Enter
$ tty7 send %1 "$(printf 'echo ONE\necho TWO')"
    ... echo ONE
    ONE
    ... echo TWO          <- typed, waiting

Correct for a verb whose first line is "types TEXT into the pane exactly
as a keyboard would" -- typing a newline is pressing Enter. But `--enter`
is documented right next to it as the way to submit, which reads as
though TEXT alone cannot, and an orchestrator passing along text it did
not write runs it a line at a time.

So `--help` and the reference now say it, with the shape it takes: the
text before the newline runs, and what follows is left typed at the
prompt it asked for. A test pins the bytes -- neither stripped nor split
into two writes -- so the pages and the behaviour cannot drift apart.

Found while feeding a pane input it does not expect. Three neighbours
came through that unchanged and are worth recording: invalid UTF-8 in
output becomes U+FFFD with correct recovery (`\xc3\x28` is one
replacement and a literal `(`), `capture --json` stays valid JSON over
it, and an OSC title carrying bad bytes is folded before storage so
`machine.json` stays parseable and the tab table stays aligned.
2026-08-16 13:33:46 +08:00
l0ng-ai 2b4067f925 docs(cli): correct what the table sanitiser is actually defending
Two claims in yesterday's comment were worth checking, and one was wrong.

A path really is a live vector. I made a directory called `evil<ESC>[31mdir`,
opened a pane in it, and it lands in the CWD column of `pane ls` -- now as
`evil?[31mdir`, zero escape bytes. That is a hostile name arriving from the
filesystem rather than from a rename the reader typed, which is the case
worth defending.

A tab's OSC title is not, or not any more: the daemon already folds control
characters to spaces before storing one, because gpui paints a label's tail
over whatever sits below it when the title contains a newline. So titles
reach the CLI clean, and the comment overstated the case by naming them.
The sanitiser still covers them and deliberately does not lean on the
folding -- that exists for the GUI and could reasonably move -- but the
comment now says which is which.

Also stops a nearby test asserting `chars().count() == 40` on a clamp that
bounds columns. Its title is ASCII so the two agree and it passed either
way, but read on its own it states the contract the previous commit just
finished correcting.
2026-08-16 12:07:17 +08:00
l0ng-ai 553cb7d128 fix(cli): clamp a tab title by columns, since a column is what it bounds
`clamp` is there so "one talkative tab cannot widen every column in the
table" -- its own comment. It counted characters, so a CJK title went
through at two columns each:

    @1   工作工作...工作…  tty7   1     <- 40 chars, 79 columns

and `tab ls` came out 98 columns wide, past an 80-column terminal, doing
the exact thing the clamp exists to prevent. Every locale this app ships
a UI for is affected, and none of the ASCII tests could see it.

The rest of the file was already careful here -- `width()` exists, and its
comment says padding by `len()` would push later columns out of line --
so this was one function measuring in different units from its caller.

Now it fills a column budget, dropping a wide character whole when it
would straddle the end: half of one is not a narrower character, it is a
different one. `saturating_sub` on the ellipsis's own column also retires
an underflow that `clamp(s, 0)` would have panicked on, unreachable as it
was with a single caller passing 40.

Measured on a live server before and after: 98 columns down to 58, with
every row still starting its fields at the same place.
2026-08-16 12:02:53 +08:00
l0ng-ai 8dd23203b9 fix(cli): an error quoting a name cannot drive the terminal either
The previous commit sanitised the tables. Errors quote what was typed and
did not:

    $ tty7 ws rm "$(printf 'X\033[31mY\033[0m')"
    tty7: no workspace named 'X<ESC>[31mY<ESC>[0m' -- `tty7 ls` lists them

Measured across eight verbs with a hostile argument, every leak was on
stderr and none on stdout -- the tables cover the stdout side already, and
nothing else there embeds free-form input. So this is one boundary, not
one call per message: `main` sanitises whatever an error carries, and a
message written later is covered by having been written at all.

The sanitiser is the one from the tables rather than a second copy, which
is the mistake the daemon's two disconnect logs already made once.

stdout is deliberately left alone. `tty7 capture` prints a pane's stored
bytes with their escapes intact -- that is the point of the verb -- and
sanitising the report path would have quietly gutted it. Checked after
the change: 46 escape bytes still come back raw, `--plain` still returns
none, and the text is unchanged.

The e2e test runs the real binary, because the unit test covers the
sanitiser while only a spawned process covers the single call in `main`
that puts it on the error path -- the part an edit could drop with every
other test still green. It also asserts the name is still readable: a
message that hides which name was refused has traded one failure for
another.
2026-08-16 11:56:54 +08:00
l0ng-ai 15880305fb fix(cli): a name in a table cannot drive the reader's terminal
$ tty7 ws rename <id> "$(printf 'evil\033[31mRED\033[0m')"
    $ tty7 ls | xxd | grep 1b
    ... 65 76 69 6c 1b 5b 33 31 6d ...

The escape went straight through to the terminal. `tty7 ls`, `pane ls`,
`ws tree` and the rest print names the CLI did not choose: a workspace or
tab name, a path -- a directory can be named with an escape in it, which
is the old `ls` trick -- and, through `tab_label`, a tab's OSC title,
which is set by whatever program is running in the pane. With `-m` the
tree comes from another machine entirely.

It threw the columns out as well, which is how I found it. The table
measures in display columns and already had CJK right, but an escape is
bytes with no width, so the padding counted characters the reader never
sees and that row's later columns sat nine over.

Control characters become `?`, the way `ls` has always done it, at the
two places human output is built: every table cell, and the tree, which
formats its names directly. `--json` is untouched -- it has to round-trip
the real name, and an encoder already writes the escape as six safe
characters.

Bidi overrides are deliberately left: they reorder a name without the
terminal obeying anything, and the same codepoints carry ordinary
right-to-left text.

Verified on a live server (zero escape bytes, columns level again across
ASCII, CJK and the hostile name) and the test fails without the fix.
2026-08-16 11:50:50 +08:00
l0ng-ai 5c1e3d344c fix(cli): a wait timeout stops recommending the flag it was given
`tty7 wait %1 --until free --timeout 2` against a pane running `sleep 30`
answered:

    pane %1: still no-agent — timed out
    nothing is reporting agent status in this pane — for a plain command
    wait `--until free`, and for an agent check `tty7 agents` ...

Advice to pass the flag that was just passed. A plain shell reports no
agent status, so a `--until free` wait that runs out lands on the
`no-agent` hint, which was written for someone waiting on agent states
and never checked whether `free` was already in the until-set.

Worse than useless, because it displaced the answer: `free` is polled
every cycle when asked for, so still being here means the foreground
command has not exited. That is now what it says, with `tty7 procs` to
find out what is holding the pane — which names `sleep` on the pane above.

The other branch is untouched: someone waiting on agent states in a
plain shell still needs pointing at `--until free`, and the test holds
both sides so neither hint drifts onto the other's case.
2026-08-16 11:12:42 +08:00
l0ng-ai ed29cdce47 docs: a restored pane is a resized pane, as far as capture is concerned
`--scrollback`'s help said the flag makes no difference "for a
never-resized pane", and the reference said the same. Both are wrong for
the panes where the difference matters most.

I killed a daemon under a running GUI, watched it come back, and captured
a pane that had `RESTORE-MARKER-7788` in it. Plain `capture` answered with
two lines — the restore banner and a prompt — while `--scrollback` had the
marker and everything around it. Nothing had been resized.

`ReplayRing::seeded` ends with `resize(size)`, deliberately: the restored
screen is replayed at the size it was recorded at, and the new shell
writes at the size the pane came back as. When those differ the restored
screen is sealed into an earlier segment, and the default capture cannot
see it.

That is the agent-facing primitive answering "almost nothing" for a pane
that kept its screen, with the help explaining that this only happens
after a resize. Both now say restores count, and say what a plain capture
looks like when it does.

`resize` returns early on an unchanged size, so a pane that comes back the
same shape really does keep one segment — the test covers both sides of
that, which is the part the wording turns on.
2026-08-16 11:05:39 +08:00
l0ng-ai e6b4265621 fix(cli): every argument now says what it is
`tty7 tab new --help` printed:

    Arguments:
      [WORKSPACE]

and nothing else. Every flag in this CLI carries help — I checked that
in a previous pass — and every positional carried none. Twenty-two of
them, the main argument of each verb. The gap is invisible in the source,
where a `value_name` sits where documentation would go and looks like it.

`--help` is where a verb is learned, so the blanks were hiding the things
a reader most needs, and three of them are genuine surprises:

  tab move INDEX  counts from 0 while tabs are addressed from 1, so
                  `@3 0` makes a tab first. Past the end it lands last.
  new PATH        omitted, the shell starts where the *server* was
                  started, not where you are.
  ws new NAME     omitted, the workspace has no name at all — the
                  codenames come from the GUI, not from here.

Each of those was established by running the commands against a live
server and reading the result, not by reading the code: I had guessed
the codename one the other way round.

The guard walks clap's own tree, so it covers a verb added later without
being told about it, and it refuses to pass on a walk that visits almost
nothing — a sweep that reads nothing looks exactly like one that finds
nothing wrong.
2026-08-16 10:58:31 +08:00
l0ng-ai 91c2cac80d fix(cli): say where a workspace id nobody typed came from
`tty7 tab new` in a shell whose workspace has been removed answered:

    no workspace with id 65b90fd4-421f-4cfb-9848-d263c9a80959 on this machine

Nothing in what was typed contains a uuid. `workspace_or_context` falls
back to `$TTY7_WS`, so the id came from the shell — and bare like that it
reads as an internal error rather than something to act on. Every sibling
message in this file carries a hint; this one had none.

Found by running the CLI against a daemon that was not the one my shell
belonged to, which is the same shape as the two cases the comment above
`run --keep` already names: a workspace since removed, or a shell opened
against another machine. That insight was in the source and never reached
the reader.

The id stays whole rather than shortened the way an ambiguity is — it is
the exact string to compare against `echo $TTY7_WS`.
2026-08-16 10:42:03 +08:00
l0ng-ai 67e4d74a8a test: keep the command reference's flags from falling behind
Checked the reference against the CLI it describes, in both directions.
It holds today: every flag it names is real — `--h` and `--v` are the
visible aliases of `--horizontal` and `--vertical`, `--all` and
`--orphans` belong to the `pane` subcommands — and every flag of every
verb it covers is on the page. So are the JSON keys: workspaces, agents,
build, control_version, protocol_version, socket, uptime_secs, ports,
procs.

Adding a flag is one line in this file. The page is somewhere else, and
an agent that cannot see a flag will not use it, so the drift is silent
and lands on the readers the page exists for.

The test asks clap for the flags rather than keeping a list beside it —
a list would be a third thing to drift — and skips the `ws`, `tab`,
`pane`, `machine` and `server` groups the page now says it leaves to
`--help`.

It also counts what it looked at. A cross-file test whose filter matches
nothing passes just as quietly as one that finds nothing wrong, and this
one would have: ten verbs and five flags are the floor.
2026-08-16 10:28:17 +08:00
l0ng-ai 402f40d0c8 docs(cli): ws rm on a workspace a window is showing brings it back
tty7 ws rm 0c4c9162   # exits 0, says nothing
    tty7 ls               # 0c4c9162 is still there

Both are correct and together they read as a failed delete. The GUI logs
what it did — "deleted on its machine while a window still had it open;
putting it back under the same id" — rather than leave a window pointing
at nothing. The panes really are gone; what returns is empty, with a
fresh shell and a new name, which is why the codename changed under an
unchanged id.

Nothing said so on the side the reader typed. `ws rm`'s help now does,
including that a workspace with no window on it simply goes.

Measured twice against a live GUI: rc 0, panes to zero, no orphans, the
same id back with a different name, and the GUI still running.

The reconciliation itself is sound and was what I set out to test: a
workspace deleted underneath an open window produces no warning and no
error, and leaves the tree and the pane registry agreeing.
2026-08-16 10:03:14 +08:00
l0ng-ai ed05c57bfd fix(cli): doctor said nothing about a config it was not using
`tty7 doctor` is described as checking "socket, dialect, config,
versions, agent hooks, links, context". With a `config.json` that does
not parse it printed every row green: server ok, dialect ok, status ok.
Meanwhile every setting in that file was being ignored, a copy had been
kept beside it, and saving was suppressed.

The rows that mention config report `TTY7_CONFIG_DIR` — where the file
should be, which is a different question from whether it is being read.
There is now a row for the answer, from the same `LoadOutcome` the
loader already returns: ok, none yet, not valid JSON, unreadable.

It also exits 1 for the two broken states, for the reason written above
the server check: doctor is the verb people run when something is not
working, and `tty7 doctor || alert` has to fire. A config nothing reads
is that, as much as an unreachable server is.

All four states run against a live server: ok, none yet, NOT VALID JSON
(rc 1), and back to ok after repairing the file with no restart.
2026-08-16 09:58:56 +08:00
l0ng-ai 20fea9826f docs(cli): the idiom I wrote for wait was missing --changed
In 335d982 I gave `tty7 wait %3 --until free` as the form to use for a
plain command. `--changed` is what makes that safe after a `send`, and I
left it out.

The states are levels, not edges. A pane that has not yet started what
you just sent it is still `free`, so a wait that gets there first is
answered by the state the pane was already in — the flag's own comment
says exactly this, and it is why `--changed` exists. The example now
sends and waits the way a delegation loop actually runs.

Being straight about the evidence: I did not reproduce the race. Sending
a six-second command and waiting without `--changed` blocked the full
six seconds, because the shell had gone busy before the second process
started. The window is small and the failure is intermittent, which is
the worst kind to leave in an example — not a reason to call it fine.

`--changed` itself is correct, and now checked: `--until free` on an
idle pane returns at once, while `send` then `--until free --changed`
holds for the five seconds the command takes and the output is there
afterwards.
2026-08-16 09:18:31 +08:00
l0ng-ai a0ecd9fd2b docs(cli): server restart ends sessions, and its sibling already said so
`stop` is described as "Stop the server; sessions end". `restart` was
"Stop, then start" — the same destruction, undisclosed, on the verb
people reach for casually when something seems stuck.

Measured rather than assumed: two panes before, zero after, workspaces
still there. So the layout survives, because the tree is on disk, and
what those shells were running does not.

The long form also separates this from the thing it will be confused
with. The server can hand panes to a new build of itself with their pids
and ptys kept — that is a real wire request (`ClientMsg::Handoff`, into
`hand_over` and `handoff::take_over`), and it is how an update keeps
sessions. `restart` is not that path, and someone who has read about one
should not assume the other.
2026-08-16 09:14:37 +08:00
l0ng-ai 172e8d166f docs(cli): a wait with the wrong --until never returns
`tty7 wait %N` on an idle shell blocks for as long as you let it. The
default states are `waiting`, `done` and `exit`; a plain shell with no
agent is `no-agent` and reaches none of them, so the wait has nothing to
wake for. Verified with a bounded run: still blocked at twelve seconds
against a freshly created pane.

Blocking is the contract — the verb is `wait` — so what was missing is
that choosing the wrong state is a hang rather than an error, and that
`--timeout` has no default to fall back on. Both are now said where
they are read: the trap in the command's help, the absence of a default
on the flag itself.

This is the same shape as the stdin note in the last commit. An agent
orchestrating panes has two ways to stop forever with no diagnostic, and
in both the software is doing exactly what it was asked to.
2026-08-16 09:10:33 +08:00
l0ng-ai 9833c772b0 fix(cli): call it a program, and say that stdin does not reach it
`tty7 run -- /no/such/binary` answered "no such shell on this machine".
The check in front of a spawn serves both the configured shell and a
command handed to `run`, and its three sentences all said "shell" —
so someone who typed `tty7 run -- ./build.sh` was told tty7 had
misunderstood what they asked for. They say "program" now, which is
true of both callers; the CLI already prefixes "spawning `…`" with the
context.

The other half is worse and is documented rather than changed:
`echo hi | tty7 run -- cat` never returns. The command reads the pane's
terminal, which nothing is typing into, so it waits for input that
cannot arrive — and there is no error, because an idle terminal is not
a failure. Output streams back; input does not go the other way.

Measured, after a first attempt sat for ten minutes: a bounded re-run
with `head -1` was still running at ten seconds with the pipe unread.
For a CLI whose stated audience is agents, `cmd | tty7 run -- …` is a
natural thing to try and an unbounded wait is the worst way to answer
it. Forwarding stdin would be a new capability; saying so is not, and
`sh -c 'cat < input.txt'` gets the input there today.
2026-08-16 09:06:57 +08:00
l0ng-ai 2e2cdfa242 docs(cli): say that capture answers with the alternate screen
A pane running an editor or a pager answers `capture` with that
program's screen, and the scrollback behind it is not in the reply.
Measured: with a pane on the alternate screen the capture holds the
alt-screen marker and not the main-screen one; after leaving it, the
main scrollback is back and the alt content is gone, with only the
echoed command left behind — which was typed on the main screen and
belongs there.

That is right, and it is the sort of right that reads as a bug from the
outside. An agent that captures a pane mid-`less` gets a screenful and
no history, and the obvious conclusion — output was lost — is wrong. So
the help says which of the two you are holding, and that an
empty-looking capture is usually a TUI in front of the scrollback.

Also noted there: long output is trimmed from the oldest end, so a
capture after a big build gives the end of it. `seq 1 200000` through a
pane comes back as the last ~70 KB, ending at the prompt.

Checked while here, and correct: `send` carries an 8000-character
payload with nothing truncated.
2026-08-16 08:51:18 +08:00
l0ng-ai dc870d6840 docs(cli): say that --all trades the TAB column for OWNER
`pane ls` reports PANE/WS/TAB/CWD/LIVE and `pane ls --all` reports
PANE/WS/OWNER/CWD/LIVE. The swap is right — a pane no workspace holds is
in no tab, so a TAB column would be empty for exactly the panes `--all`
exists to show — but nothing said so, and it is the sort of difference a
reader assumes they misread.

It also costs something specific: the @ numbers. A "no tab @7" error
sends the reader to `tty7 pane ls`, and reaching for `--all` there, as
one does when a thing seems to be missing, takes the column away.

Verified while here, and all correct: `--key C-c` interrupts a running
command (the `sleep` is gone and `procs` shows the shell back in the
foreground); `send` delivers quotes, backslashes, `$`, backticks, braces
and pipes verbatim; `-q` silences all twelve listing and creating verbs,
still prints errors, still exits 1, and wins over `--json` on success.
2026-08-16 08:07:48 +08:00
l0ng-ai d601a86f11 docs(cli): the environment comes from the server too
Same shape as the working directory, and the same surprise:

    RALPH_MARKER=hello tty7 run -- sh -c 'echo [$RALPH_MARKER]'
    []

The pane is the server's child, so it gets the server's environment.
`FOO=bar tty7 run -- …` is a universal idiom and it silently does
nothing here, with no `--env` to reach for instead.

Documented beside the `--cwd` note, with the workaround that does work
— set the variable inside the command, where the shell running it can
see it. Adding `--env` would be a new flag, not a fix.

Checked while here, and correct: a pane does get a usable environment
(PATH, HOME) from the server; `$TTY7_PANE`, `$TTY7_WS` and
`$TTY7_CONFIG_DIR` are all set in a `run` pane as the top-level help
promises; and the payoff holds — `tty7 procs` with no address, run
inside a pane, resolves `$TTY7_PANE` and reports that pane's own
processes.
2026-08-16 08:03:46 +08:00
l0ng-ai 49daef0c79 docs(cli): say where tty7 run runs when --cwd is left off
It does not run where you typed it. The pane is the server's child, so
it starts in the server's working directory — whatever that process was
launched from. Measured, not assumed: `tty7 run -- /bin/pwd` from /usr
and again from /tmp both answered with the directory the server had been
started in.

The help's own example is `tty7 run -- cargo test`, and a server the app
started is not sitting in your project. So the example as written builds
somewhere the reader did not choose. It fails loudly for a build — no
Cargo.toml — but not for anything that would happily run in the wrong
tree.

Documenting rather than changing it. Defaulting a local run to the
caller's directory is the behaviour most callers expect and would make
the example true, but it is a different result for every existing
invocation, and `-m` routes to a machine where the local path means
nothing. That is a decision to take deliberately, not a side effect of a
docs pass.

Checked while here, and correct: `split` inherits the split pane's cwd,
and a pane's reported cwd follows the shell through `cd`.
2026-08-16 08:00:13 +08:00
l0ng-ai 32f893b3b1 docs(cli): ws attach cannot hold what it claims to take
`tty7 ws attach <ws>` answers `{"attached": "<id>", "took_over_from":
null}`, and `tty7 ws ls` shows the workspace unattached a moment later.
Both are correct: an attachment belongs to the connection that made it,
and this command's connection ends with the command.

A caller has no way to tell that from the reply. An agent reading
`attached` reasonably believes it now holds the workspace, and the next
`ws ls` says otherwise with nothing to explain the difference.

So say it. The claim does not outlive the process, that is not a
failure, and what does last is the displacement: the previous holder has
been told, and a dedicated one has been hung up. `took_over_from` is the
part of the reply worth acting on.

Behaviour unchanged — this is the same reasoning the `Attachment` type
already carries ("an attachment belongs to a live connection, and one
read back at boot would name a holder that no longer exists"), said
where the person running the command can see it.

Also checked while auditing, and sound: `tty7 <PATH>` already stats the
directory and refuses a file (`resolve_gui_path`), `ws stop` says "(not
implemented yet)" in its own help rather than only when run, and repeat
`ws detach` is idempotent on purpose for wire-compatibility reasons the
handler documents.
2026-08-16 07:37:53 +08:00