Commit Graph
34 Commits
Author SHA1 Message Date
l0ng-ai 2d517fa0f3 ci: pin the GITHUB_TOKEN to read-only in the CI workflow (#665) 2026-08-17 14:48:03 +08:00
webdev ac3c95a647 feat(update): install verified Linux AppImage releases in app (#306) (#652)
The last platform from #306: a Linux install running as an AppImage can
now download, verify, and apply a release from inside the app, through
the same tty7-updater helper the macOS (#309) and Windows (#330) paths
use. Tarball and distro installs are deliberately untouched — they keep
the named-package hint and the release page, because replacing a file a
package manager may own is not this code's call to make.

The installed artifact is one file, the path $APPIMAGE names, so the
install is the simplest of the three platforms: stage the download
beside the image (two renames only stay atomic on one filesystem),
verify, swap, relaunch, and restore the preserved previous image if the
new one does not survive its launch grace. What is Linux-shaped about
it is the mount: the image the GUI runs from is FUSE-mounted by the
AppImage runtime and torn down when the app exits, which is the moment
the installer starts working — so the GUI copies the helper out of the
mount into staging and runs the copy, the way the Windows path runs a
private copy because Setup replaces the installed one. The daemon is
left running throughout, as on macOS: nothing on Linux locks a running
executable's file, and the panes it serves are the reason the update
restarts only the GUI. The swap also carries the installed image's own
mode onto its replacement, so a 0700 image stays private and the
download's missing execute bit never reaches the installation.

Verification holds the issue's requirements with what an unsigned ELF
can offer: the bytes must match the release's checksums.txt, the file
must actually be a type-2 AppImage — a mis-published asset fails with a
name instead of at launch — and the image must state the version it
claims. That statement is new: bundle-appimage.sh stamps
X-AppImage-Version into the desktop entry, and the updater reads it
back with one --appimage-extract, answered by the runtime before any
application code and without FUSE. The same pass requires the new image
to bundle its own tty7-updater, because an image without one would
install fine and then be the last version that ever could.

release.yml and nightly.yml now build the updater on the Linux leg and
bundle it into the AppImage, and both check the packaged image for the
same facts the updater checks on a user's machine — helper present,
version stamped — so a packaging mistake fails the workflow instead of
the update. The first release carrying this can only bootstrap: images
already installed predate the helper and keep the manual hint, so the
first complete in-app update is the release after it.
2026-08-16 17:43:52 +08:00
l0ng-aiandl0ng-ai 6c26b35acc fix(control): bump the dialect to v6, and publish a tty7-server for macOS (#605)
* fix(control): bump the dialect to v6 so an out-of-date server says so

The control dialect has been renamed, extended and cut since it was
last numbered, all of it against CONTROL_VERSION 5: the machine tree
replaced WorkspaceList/Get/Put/Delete with WorkspaceTree, MachineGet
and the tab/pane verbs, GitStream arrived with its chunk and end
events, and ReplyOk::Attached and FileMeta went away.

A peer left behind by any of that still answers the hello, because the
number it answers with still matches. It is also still sitting at the
path the installer looks for, tty7-server-c5p5, so a client decides it
already has the server it needs. Then the first call reaches a variant
the peer has never heard of, the frame fails to decode, and the read
loop takes the whole link down with it. What the user sees is a remote
workspace that opens with no tabs and a git detail pane that never
fills, with nothing anywhere saying why.

Moving the number puts all three guards back: the hello is refused with
the message that names the old build, the remote binary is looked for
at c6p5 and installed rather than trusted, and a stale local daemon
gets the restart prompt it should have been getting all along.

Document the rule next to the constant while it is fresh: move it when
a variant is added or removed. The feature strings only cover what a
peer can safely ignore, and a request it cannot decode is not that.

* feat(remote): publish a tty7-server for macOS hosts

A remote workspace has been Linux-only for no reason anyone chose: the
installer derives the asset name from `uname -sm`, and the only names it
knew were the two musl builds. A Mac on the other end of an SSH profile
got "a remote tty7 workspace needs a Linux host" and stopped there.

Publish the two Apple slices alongside them and teach the installer to
ask for them. `Darwin arm64` and `Darwin x86_64` now map to
tty7-server-macos-aarch64 and tty7-server-macos-x86_64; everything past
that point already worked, because nothing under it was ever Linux-
specific — the install path is POSIX, the upload is SFTP, and the
dialect probe runs the binary before trusting it.

The machine names are matched per system rather than by architecture
alone. Linux says aarch64 on one distribution and arm64 on the next,
while a Mac only ever says arm64, so honouring Linux's spellings under
Darwin would be guessing at output no Mac produces.

Static linking is not the instrument on macOS — Apple ships no static
libSystem — so assert-macho.sh stands in for assert-static.sh with the
guarantee that actually matters: every dependency resolves under
/usr/lib or /System/Library, so nothing the destination Mac lacks can be
picked up from a build runner, and the binary carries the signature
arm64 refuses to run without.

Not signed or notarized beyond that, deliberately. The binary is never
downloaded by the Mac that runs it: the client fetches it, verifies it
against checksums.txt and writes it over SFTP, which sets no quarantine
attribute, so Gatekeeper is not in the path.

ASSET_X86_64 and ASSET_AARCH64 become ASSET_LINUX_*, which is what they
always meant and could not keep meaning next to a macOS pair.

* fix(ci): sign the x86_64 macOS server, and stop the guard flaking on it

Two faults the first green run hid from each other.

The linker ad-hoc signs the arm64 slice because Apple Silicon will not
execute anything unsigned, and leaves x86_64 bare. That is fine on an
Intel Mac, but the x86_64 server is also what an Apple Silicon box gets
when it asks through a Rosetta shell, and handing that machine an
unsigned binary is a guess about Rosetta nobody needs to make. Sign both
slices ad-hoc in the workflow — no identity, no secrets, nothing to do
with the notarized signing the GUI bundles get.

The guard that caught it was itself unreliable: `codesign -dv | grep -q`
under `pipefail` reports failure whenever grep wins the race, because -q
exits on the first match and the writer takes SIGPIPE. Small output means
the writer usually finishes first, which is why the arm64 job passed and
x86_64 failed on the same signed-or-not question. Capture into a variable
and match afterwards, the way the release workflow already does it.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 11:47:27 +08:00
l0ng-aiandl0ng-ai 8b08f51773 ci: drop the two Claude review workflows (#441)
Remove `claude-code-review.yml` and `claude-review-fork.yml`. Both are
advisory-only and never gated a merge, so the required checks on main
stay exactly `rustfmt` and the three `build & test (<target>)` jobs from
ci.yml.

Nothing else references them: the `claude-review` label and the
CLAUDE_CODE_OAUTH_TOKEN secret were used only by these two files.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 20:11:38 +08:00
l0ng-aiandl0ng-ai bc03e59b6f ci: keep the fork review to forks (#398)
The fork job never checked where the head branch lived. It was written
as the fork path and reads like one, but `pull_request_target` fires on
every pull request, so labelling one of ours landed there too --
silently, and with the weaker review: no plugin, no whole-repo context,
read-only tools. #389 got that instead of the review it should have had.

Also corrects the `labeled` comment in claude-code-review.yml, which
promised exactly the case that cannot work. A `pull_request` workflow is
read from the PR's merge ref, and GitHub recomputes that on a push and
not otherwise, so a PR whose last push predates the file has a merge ref
without it and no label can summon it. Pushing fixes it, and also
triggers synchronize by itself -- which is why the empty commit worked
and the label looked broken.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:26:29 +08:00
l0ng-aiandl0ng-ai 51ea055d2a ci: anchor fork review findings to lines, and fence the reads (#397)
Two changes to the same job.

Findings now go inline. The action ships an inline-comment MCP tool
that buffers rather than posts: the queue is sent after the session
ends, by the action's own code, past a classifier. That keeps the
property review.md was chosen for -- the reviewer writes the words, a
step that cannot be argued with performs the act -- while putting a
finding next to the line it is about. review.md stays for the summary,
which belongs to the change as a whole.

Reads are fenced. A comment body is posted verbatim, so any file the
reviewer can read it can publish, and the token is in this process's
environment; /proc is the short path between the two. Reads are already
confined to the working directory in the default permission mode, but
this job should not rest on a default. Deny is evaluated before allow,
so the rules hold whatever --allowedTools says.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:13:12 +08:00
l0ng-aiandl0ng-ai ea0a0f078c ci: keep reviewing a fork PR after the label goes on (#395)
The label was a one-shot trigger, so a contributor who addressed the
findings and pushed got no second look unless someone removed and
reapplied it. It now reads as a subscription: `synchronize` re-reviews
while the label is present, and taking it off stops that.

Two clauses rather than one label test, because `labeled` carries the
label that was applied and `synchronize` carries none -- testing only
the list would spend a review every time an unrelated label landed on a
subscribed PR.

This does not gate the code in the next push, and the header comment now
says so. The gate is spend and attention; the safety is the base-branch
workspace root, the absent Bash, and executing nothing.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:04:11 +08:00
l0ng-aiandl0ng-ai 59b26ce283 ci: stop the fork review prompt from reading as a checklist (#396)
The general instruction was one line -- logic errors, edge cases,
regressions -- followed by a numbered list of four, three of them
tty7-specific. A list anchors, and that one named none of what actually
breaks a terminal emulator: panic paths, unsafe, ordering, resources on
the failure path, an API contract that no longer holds for its new
callers.

Replacing it with a longer list would only move the boundary. So the
prompt now states the aim, offers examples while saying outright they
are not a checklist, and notes that the finding nobody listed is
usually the one worth having. The repo rules stay, demoted to what an
outside reader cannot know and explicitly skippable.

claude-code-review.yml is left alone: its rules are appended to the
code-review plugin, which brings its own methodology, so a general
clause there would compete rather than add.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:02:41 +08:00
l0ng-aiandl0ng-ai fb4e8f0860 ci: post the fork review from a step, not the action (#394)
track_progress only fires on opened, synchronize, ready_for_review and
reopened. The approval gate depends on `labeled`, which is none of
them, so the action refused the run outright rather than falling back
to the log.

The review is written to review.md and posted by a final step that runs
no model and reads one file. That adds Write to the allowlist, which
costs nothing: there is still no Bash, so still no curl, and the runner
is discarded after the comment goes out.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 22:51:22 +08:00
l0ng-aiandl0ng-ai d2a7089189 ci: give the fork review the PR's tree and a voice on the PR (#393)
Three changes, one shape. The run was failing at the app-token exchange
because the action checks the triggering actor's repository access and
a fork PR's author has none; `github_token` plus
`allowed_non_write_users` is the documented pair for
pull_request_target, and the label gate is what makes trusting that
actor a decision somebody made.

Findings now go on the pull request instead of the run log, which needs
`pull-requests: write`. That is affordable only because the tool
allowlist stays read-only: with no Bash there is no curl, so the worst
a successful injection buys is a silly comment.

The fork's tree comes back, one directory down. #392 removed it
entirely because a checkout at the workspace root is what Claude Code
reads as the project -- but the action's own security guide gives the
middle path, a subdirectory, which keeps the project files ours while
letting the review see whole files instead of hunks. `.claude/` and
friends are dropped from that tree and a top-level CLAUDE.md is renamed
rather than deleted, since a PR that edits it still deserves review.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 22:47:53 +08:00
l0ng-aiandl0ng-ai 2ddf2bad25 ci: review a fork's diff, never its tree (#392)
actions/checkout refuses a fork ref under pull_request_target without
allow-unsafe-pr-checkout, and the flag is not the fix. The working
directory is what Claude Code reads as the project, so checking out a
fork hands it that fork's CLAUDE.md as instructions and that fork's
.claude/settings.json hooks as commands -- neither of which the
--allowedTools list governs. Constraint 2 said nothing from the pull
request is executed; a checked-out tree could not honour it.

Check out the base branch instead and bring the contribution down as
diff text in one file. The reviewer reads the diff against trusted
sources rather than the merged tree, which is less context than the
same-repo path gets, and the right trade for code we do not control.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 22:42:43 +08:00
l0ng-aiandl0ng-ai 8652c7928a ci: review fork pull requests behind a label (#391)
A fork PR gets no secrets on `pull_request`, so claude-code-review.yml
skips it. `pull_request_target` is the only event that reaches the
diff with our token, and it puts that token in a job beside code we did
not write, so the fork path is a separate file under four constraints:
a label applied by someone with write access is the only trigger,
nothing from the PR is executed, the tool allowlist is read-only, and
the job holds no write permission to carry anything back out.

The same label also re-runs the ordinary review, which a PR opened
before that workflow existed otherwise has no way to start.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 22:37:45 +08:00
l0ng-aiandl0ng-ai 305baa8746 ci: review pull requests with Claude Code (#390)
Runs the code-review plugin on every PR open and push, on our own
Actions minutes rather than the managed Code Review service, and posts
the findings back onto the PR. Advisory only: the required checks on
main stay rustfmt and the three build & test jobs.

The append-system-prompt carries the four rules a general-purpose
reviewer cannot infer -- dialect bumps staying readable to an older
peer, src/ui paths going through Host, i18n keys landing in all three
locales -- and tells it not to repeat what rustfmt and clippy already
decide.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 22:18:43 +08:00
l0ng-ai f45dd34cab feat(update): separate Stable and Nightly release channels (#386)
The update channel becomes a property of the installation rather than
something derived from how version numbers happen to sort, so a Nightly
follows Nightly instead of being walked back onto Stable by an update it
never asked for. Stable reads /releases/latest, which excludes
prereleases; Nightly reads /releases/tags/nightly. Neither feed can hand
the other an update, so an installation only changes channel when the
user changes it in Settings.

The nightly release cannot state its version in its tag — `nightly` is
force-moved every night, so `tag_name` is the literal string. It now
publishes nightly.json beside the packages, falling back to parsing asset
filenames for builds that predate the manifest. Prereleases are ordered by
every numeric identifier in the stamp, and the stamp goes to the minute so
two builds in one day are distinguishable; a stable release still outranks
every dated build of its core version, which is how switching back to
Stable graduates instead of downgrading.

Switching channel invalidates what the old feed produced: the staged
package, the deferred prompt, and the transfer still in flight, which
would otherwise finish and stage a build from the channel the user just
left. Settings keeps one action on the update row rather than three —
the update dialog covers the rest, but it is a moment rather than a place,
and where the package cannot be installed for the user the release page is
the whole update path. Skipping a version is retired along with its state,
its Settings row, and its localization keys.

Also carries the staging work this was branched from: an update is fetched
and verified while the prompt is up, so installing it is a restart, and
declining one defers it instead of retiring it permanently.
2026-08-07 19:44:31 +08:00
dependabot[bot]andl0ng-ai 3235cd091c ci: bump actions/download-artifact from 7 to 8 (#325)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
2026-08-06 10:07:09 +08:00
603bca171e feat(updater): add windows updates and cross-platform nightly support (#330)
* feat(updater): add windows online updates

* feat(updater): support online updates for windows portable zip builds

f

* feat(updater): support online updates for nightly build

* fix(updater): strengthen post-download update verification

* feat(updater): support explicit stable and nightly channel switching

* fix(i18n): localize update settings ui

* fix(settings): prevent slider value labels from wrapping

* feat(updater): drop the nightly channel, refuse all-users Windows installs

Follow-up to the Windows updater work on this branch, applying maintainer
review.

Nightly is a build channel, not an update channel. The updater consults
`/releases/latest` again and nothing else, so it behaves on Windows exactly
as it already does on macOS: a Nightly build is offered the stable release
that supersedes it and graduates out of the prerelease, and no rolling
prerelease can become a source of code that gets executed on a user's
machine. Removed with it: the `UpdateChannel` enum and its version-string
inference, the `tags/nightly` query, the cross-channel version-ordering
bypass, the Settings → About channel row, the rolling-tag
`update-manifest.json` and the i18n keys that only served them.
`parse_version` and `is_update_available` are byte-identical to main again.

Nightly builds are untouched, and still carry tty7-updater plus the macOS
update archive — a Nightly user needs a working helper to reach the stable
release that replaces their build.

An all-users Windows installation is no longer updated in place. Running the
release Setup silently as the signed-in user cannot replace
`C:\Program Files\tty7`: Inno resolves `{autopf}` to `%LocalAppData%\Programs`
and installs a second copy beside the real one, or re-launches itself
elevated and puts a bare UAC prompt for an unsigned executable in `%TEMP%` in
front of a user whose GUI just vanished. tty7 declines both and points at the
release page. Detection reads Inno's own `HKLM` state for the frozen AppId and
independently probes whether the directory accepts writes, so a relocated or
pruned installation is caught too; the decision is a pure function with unit
tests, and it is re-checked before the download as well as during it.

Release and Nightly now verify the Windows packages they just built, mirroring
the macOS update-archive step: the install marker, tty7-updater.exe, the ZIP
layout the updater will accept and the PE versions it will demand. Every fact
the updater checks on the user's machine after downloading is checked here
instead, so a packaging mistake fails the build.

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: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:27:37 +08:00
ayamir a6754b28bc feat(update): install verified macOS releases in app 2026-08-02 22:25:10 +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
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-aiandl0ng-ai bed22d899e Keep workspaces whole: remote reopen/restart recovery, and cross-workspace restore guards (#257)
* feat(remote): keep a remote workspace whole across reopens and restarts

Reopening a remote workspace — or coming back to one whose `tty7-server`
had been replaced — landed on a screen of `tty7 — disconnected` panes with
their coding-agent conversations gone. Several independent holes added up
to that; this closes them together, and picks up the surrounding work the
same session produced.

**Telling a restarted server from a blinked link.** `ControlHelloOk` now
carries an `instance` minted once per server *process*. Nothing else in
the handshake changes across a restart — `build` and both dialect numbers
survive it — so a reconnect had no way to know its `pane_id`s were dead.
It does now: a different instance rebuilds the window from its layout
(same tabs and splits, fresh shells in the saved cwds) instead of
re-attaching to a process that is gone. An absent instance means *unknown*
and is never read as a restart.

**An attach can now fail.** `Attach` has no synchronous reply, so the
client returned `Ok` unconditionally and the daemon's `Error` frame was
read much later by the reader thread, which has no arm for it — the pane
then landed in the *link is down* state instead of falling back to a fresh
shell. The client now reads far enough into the reply to classify it on
the kind byte (the snapshot behind it can be megabytes) and hands those
bytes to the reader thread, so a successful attach loses none of its
replay. Local and remote attaches get different waits: the local one is on
the UI thread.

**The agent session survives to be resumed.** `TerminalView` raises
`AgentSessionChanged` when the pane's agent reports a new native session
id, so the layout on file catches up instead of waiting for the user to
happen to open a tab. A pane that is still connecting now carries its
agent through `PendingSpawn` — a save landing in that window used to write
`agent: null` over the record — and `land_pane` sends `--resume` when the
attach turned out to need a fresh shell.

**Ending sessions says so on file.** "End Sessions" kills the panes and
then drops their ids from the record, pushing the cleared layout to the
machine that owns it (design §10: the remote's copy wins, so a local-only
clear would be undone by the next open — the open this exists for).

**The new-tab dropdown lists the window's machine.** `Host::shells` and a
`Shells` control request (dialect v2) make the "+" menu a property of the
machine the window is bound to. A remote window filled from this
computer's `/etc/shells` offered `/bin/zsh` on a box whose zsh is
elsewhere, and every pick failed to spawn.

**An install reports its bytes.** The download and the SFTP upload each
report progress, relayed to the client over the routed connection as a
`RoutePrompt::InstallProgress`, and painted as a bar under the machine's
row in the switcher. ~8 MB across two hops behind the word "connecting…"
was indistinguishable from a hang.

**The installer compares dialects, not version strings.** `tty7-server
--protocol` prints what a binary speaks without starting it, so a connect
adopts an already-running server it can talk to rather than prompting
about a build difference and uploading 8 MB the machine did not need.

**Switcher.** A machine's `⋯` menu holds "New Workspace" (it was a row
under every machine, pushing the list a quarter of a card down) and a new
"Disconnect", which drops the connection and leaves the windows open and
read-only. The suspension lasts exactly as long as that machine has a
window on it.

Also drops three design/contract docs for the now-shipped remote-workspace
work.

* fix(session): stop one workspace's panes from being restored into another

A restart put a copy of one workspace's seven tabs — cwds, layout and
recorded agent sessions — in front of another workspace's own tabs, and
auto-resumed every one of those agents a second time: six `claude
--resume <id>` pairs running in parallel against the same conversations,
one set per window. The record-level corruption that seeded it is still
unattributed, but every mechanism that let it propagate, amplify, or go
unnoticed is closable, and this closes them.

**Panes now know their owner.** `Spawn` can carry the workspace the pane
is created for; the daemon stores it immutably and reports it in
`List`'s `PaneInfo.owner`. Restore refuses to re-attach a pane another
workspace owns (`pane_attachable`) — before this, a saved id landing on
somebody else's live pane attached silently, which is how one window
could pick up another's shells. The field rides a new `SPAWN_OWNED`
frame with a struct payload (the legacy spawn payloads are positional
tuples an old daemon cannot grow), gated on a new `pane-owner` feature
string: a client only sends it to a daemon that advertises it, so the
legacy kinds stay byte-for-byte what old daemons expect. A pane with no
recorded owner stays attachable by anyone — that is the pre-field
behavior, not a new risk.

**Saved pane ids are bound to the daemon process that issued them.**
`DaemonVersion` now carries an `instance` minted once per process (the
local twin of the control hello's), the GUI caches it at the
`ensure_running` handshake, and each local workspace records it as
`daemon_instance` beside its layout. Claiming a workspace whose ids came
from a different instance blanks them first: daemon pane ids restart
from 1, so after a reboot every saved id points at whatever unrelated
shell holds the number now, and the aliveness check cannot tell a
survivor from a squatter. A blank on either side means "cannot tell" and
never trips it. Unlike the duplicate-claim case below, this path keeps
the agent resume — the pane is genuinely gone with its daemon, and the
fresh shell resuming the conversation is the feature.

**A duplicate claim loses its agent resume along with its pane id.**
`dedupe_pane_ids` kept the loser's layout *and* its
`agent_session_id`, so the blanked leaves took restore's spawn-fresh
path and auto-typed `claude --resume` for conversations the winning
workspace's panes were still running — the doubling above. The winner
keeps the panes and the resume; the loser keeps only cwds.

**Cross-workspace saves are caught at the write.** Every terminal view
remembers the workspace whose window created it, and `save_session`
logs an error naming both ids if a window ever records a pane created
for a different workspace — the tripwire for the still-unattributed
seed corruption, so a recurrence is caught in the act instead of
reconstructed from `session.json` archaeology days later.

Wire compatibility both ways: `PaneInfo.owner`, `DaemonVersion.instance`
and `Workspace.daemon_instance` are `#[serde(default)]` struct fields
(old peers' JSON decodes, new fields are ignored by old readers), and
`SPAWN_OWNED` is feature-gated as above. `daemon_instance` is
client-owned in the design-§10 storage split — it names the local
daemon, and the field-census test pins the classification.

* fix(session): resume the agent when a local pane dies mid-restore

`session_to_pane` decided whether to send a coding agent's `--resume`
from `restore.is_none()` — i.e. from whether the pane looked alive when
the restore started. But `alive_panes_on` runs one `List` at the top of
the restore, while the attaches happen per leaf afterwards. A pane that
exited in between failed its attach, fell back to a fresh shell inside
`spawn_shell_terminal_in`, and then landed in the `restore.is_some()`
arm: an empty shell with its conversation dropped.

`ShellParts.restored` already answers this exactly, and the remote path
already reads it in `land_pane`. Carry it onto `TerminalView` so the
synchronous local path can read it too, and branch on that instead of
re-deriving the answer from a set that may be stale by the time it is
used.

No behaviour change on the paths that were already correct: a view that
was never restoring anything reports `restored: false`, which is the
same answer `restore.is_none()` gave them.

* fix(remote): check the server instance against the record, not just memory

A remote workspace's pane ids were only guarded against server restarts
by `RemoteLinks::instances`, an in-memory map. On the first connect after
the client starts, every machine is a first sighting, so `server_restarted`
answers false — and a `tty7-server` that was replaced while the client was
closed sails straight through. Its pane ids restart from 1, so the saved
ones now name unrelated shells, and the reconnect attaches to them: the
exact id-reuse failure the local side already guards against.

`Workspace::daemon_instance` was local-only for the stated reason that a
remote server's identity is tracked live per connection. That tracking is
correct but not sufficient — it cannot survive the client restart that
makes the question worth asking.

So the field now means the same thing on both sides: which process minted
the pane ids in this record. `WorkspaceStore::serving_instance` picks the
local daemon or the far machine's server depending on the workspace, and
`finish_attempt` compares it per workspace before deciding to re-attach or
rebuild. It stays client-owned: it records what *this* client last saw, so
two clients on one remote workspace each keep their own and neither may
overwrite the other's.

An unreachable machine still records nothing, which is what keeps a good
stamp from being erased with `None` — that would disarm the next check.

Also in these three files: the §N references to the deleted design docs,
cleaned up as part of the sweep in the following commit.

* docs: drop the references to the deleted design documents

The three documents this branch removed were cited ~280 times: `design
§10`, `contract §8`, `§17` and friends in comments, five references by
file path in code and manifests, five in CI workflows and one in the
release skill. Every one of them now points at nothing.

Rewritten rather than merely stripped, because most were not decoration:
"design §10 makes the remote's `workspaces.json` the authority" becomes a
statement in its own right, and the several that carried a Chinese phrase
from the document as their justification say the same thing in English
instead. Where the reference was purely parenthetical it is simply gone.

Not touched: `PRD §7.1`, `brief §8` and the like, which name documents
this branch did not remove and were already external before it, and the
`RFC 4648 §10` test-vector citation, which is a real specification.

The `host boundary` CI job loses `(§10.6)` from its name. It is not one of
the required checks, so branch protection is unaffected.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 19:15:19 +08:00
l0ng-ai 208454e202 feat(remote): remote workspaces — a window that is one machine
Split the framework-free half of tty7 into `tty7-core` and add a headless
`tty7-server` built on it, so a workspace's filesystem, git and session state
can live on another machine while the GUI stays where it is.

- `crates/tty7-core`: wire protocol, session daemon, PTY, native SSH engine and
  the domain model, with no gpui dependency. Module paths are unchanged.
- `crates/tty7-server`: the same daemon with no GUI attached, linked fully
  static against musl and pushed onto the remote box. One dependency, on
  purpose — a second one the GUI also needs belongs in core.
- `Host` trait + `HostId`/`HostRegistry`: every fs/git/watch call a workspace
  makes goes through the machine it belongs to. `LocalHost` answers on this
  box, `RemoteHost` over a routed control connection.
- `ui::host_ops`: the GUI's single door to a `Host`. Host calls block, so all
  of them run on the background executor with the result landed on the UI
  thread; de-duplication, staleness and error reporting live here rather than
  at each call site. Enforced by a CI grep.
- Connect flow: home page → pick a configured SSH host → the machine's own
  workspace list → a window bound to one workspace on it. Workspace switcher
  groups by machine, this computer included.
- CI: static musl builds of `tty7-server` for x86_64/aarch64 via
  cargo-zigbuild, a host-boundary grep, and version stamping factored out of
  the nightly workflow. Both new jobs are non-required so branch protection
  does not wedge open PRs.

Design and the interface contract it was built to are in
`docs/2026-07-27-remote-workspace-{design,impl-contract}.md`.
2026-07-28 10:59:46 +08:00
l0ng-ai 3c36632cbe fix(release): assemble the release as a draft once every platform is green
The four platform jobs each ran softprops/action-gh-release, so the first
one to finish published a release carrying only its own assets. That
release immediately became /releases/latest, which the in-app update check
polls — users were prompted to download a version whose assets were still
being built, and macOS users in particular could open the page minutes
before a .dmg existed. A permanently failed platform left the gap forever.

Build jobs now hand their bundles to a single draft-release job via
upload-artifact. It runs only after all four succeed, and assembles a
draft: drafts are invisible to /releases/latest, so nothing is advertised
until the release skill has verified the six assets, written the notes,
and published it by hand.

This mirrors the shape nightly.yml already used.
2026-07-25 16:33:37 +08:00
l0ng-ai a42ed82a14 ci(release): build the tagged commit with --locked too
The release workflow is a plain checkout of the tag — nothing rewrites
Cargo.toml there, so the lockfile guard the CI build just gained applies
just as well, and a release is the build you least want silently
re-resolving dependencies. Only nightly stays unlocked: it stamps
Cargo.toml's version, which makes the lock's own root entry stale by
design.
2026-07-23 17:03:44 +08:00
l0ng-ai 9c90044c05 chore(deps): resync Cargo.lock with Cargo.toml and lock it in CI
Two lockfile-only dependabot bumps (#139, #140) raised resvg to 0.47.0 and
sha2 to 0.11.0 in Cargo.lock without touching Cargo.toml, which asks for
`resvg = "0.45"` and `sha2 = "0.10"`. Under cargo's 0.x rules the minor
version is the major, so neither requirement accepts the locked version and
the lockfile has been self-contradictory ever since:

    $ cargo metadata --locked
    error: cannot update the lock file ... because --locked was passed

Nothing failed loudly — CI never passed `--locked` — so the cost landed on
contributors instead: every local cargo invocation rewrote the lock, leaving
a permanently dirty working tree to discard before each commit.

Resyncing drops the duplicates too. gpui-component already pulls resvg
0.45.1, so the tree no longer builds two copies each of resvg, usvg,
tiny-skia, tiny-skia-path, kurbo, svgtypes, roxmltree, imagesize and
polycool.

CI now builds and tests with `--locked` so the next such drift fails in the
PR rather than in a working tree. The release and nightly workflows keep
their unlocked builds on purpose: both stamp Cargo.toml's version and depend
on cargo refreshing the lock's root entry.
2026-07-23 16:26:21 +08:00
l0ng-ai e34da7a36e Merge pull request #138 from l0ng-ai/dependabot/github_actions/actions/upload-artifact-7
ci: bump actions/upload-artifact from 4 to 7
2026-07-21 12:24:18 +08:00
dependabot[bot] b9f6cbb78a ci: bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 09:29:18 +00:00
dependabot[bot] 0577f7d481 ci: bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 09:29:15 +00:00
l0ng-aiandl0ng-ai 17097ea207 fix(nightly): upload only packaged artifacts, not dist/ intermediates (#116)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-16 23:15:39 +08:00
l0ng-aiandl0ng-ai 8209fa1823 feat(nightly): unattended nightly build channel with prerelease-aware update check (#114)
* feat(nightly): unattended nightly build channel with prerelease-aware update check

* fix(nightly): upload assets before pruning stale ones; pin nightly-to-nightly no-prompt in tests

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-16 22:40:22 +08:00
ayamirandl0ng-ai e44855c3b6 feat(ssh): support Unix GSSAPI auth (#81)
* feat(ssh): support gssapi auth

* fix(ssh): pin the russh patch to an exact rev + fail on a stalled gssapi context

- [patch.crates-io] now pins rev 0d1d073 instead of tracking the fork's
  branch: russh is the credential-handling SSH protocol layer, and a
  moving branch would let `cargo update` silently pull unreviewed code.
  Documented the removal condition (upstream russh PR #737 releasing).
- gssapi_step: an incomplete context with no output token used to claim
  GssapiStep::Complete without a MIC, which servers reject with an opaque
  failure; return an error naming the stall instead.
- auth.rs module doc: include gssapi-with-mic in the Auto ordering.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-15 14:32:42 +08:00
l0ng-aiandl0ng-ai b5beba6d80 feat(release): ship a Linux AppImage alongside the tarball (#55)
* feat(release): ship a Linux AppImage alongside the tarball

The Linux release was a bare, dynamically-linked binary built on
ubuntu-latest, so it only reliably ran on Ubuntu — Fedora/Arch users hit
missing/mismatched runtime libs. Add an AppImage that bundles the
x11/wayland/xkb/fontconfig/freetype libs so it launches across distros.

- bundle-appimage.sh: linuxdeploy populates an AppDir + deps, completions
  go beside the binary (usr/bin/completions, matching signature.rs's
  current_exe lookup), appimagetool packs it. Runs FUSE-less on CI.
- release.yml: new "Package Linux AppImage" step after the tarball,
  libfuse2/file added to the Linux deps, *.AppImage added to the upload
  list. The AppImage step avoids `rm -rf dist` so the tarball survives.
- README (en + zh): recommend the AppImage, keep the tarball as the bare
  fallback.

Note: glibc is not bundled, so ubuntu-latest still sets the glibc floor.

* fix(release): downscale AppImage icon to a resolution linuxdeploy accepts

linuxdeploy rejected the 1024x1024 app-icon.png (its valid list tops out at
512). Resize to 256x256 with ImageMagick's convert (added to the Linux apt
deps) before handing the icon to linuxdeploy.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-13 10:43:26 +08:00
l0ng-ai d0cf53522f feat(release): ship a Windows Inno Setup installer alongside the portable zip
bundle-windows.ps1 now compiles windows-installer.iss (ISCC is preinstalled
on windows-latest) from the same staged payload as the zip, producing
tty7-<version>-windows-x86_64-setup.exe: per-user install by default with an
all-users option, Start Menu shortcut, Apps uninstall entry, optional desktop
icon. Release workflow uploads the new artifact; READMEs and CHANGELOG updated.
2026-07-07 16:14:44 +08:00
l0ng-ai 22e1ab1694 tty7: a GPU-rendered, daemon-backed terminal in pure Rust
tty7 is split into two Rust processes: a persistent daemon that owns the
shells and a GPU-rendered client that talks to it over a local socket.
Because the shells live in the daemon, quitting and reopening the app
leaves the session intact — detach and reattach, no tmux required.

- Persistent sessions — the daemon holds the PTYs and child processes, so
  closing a window or swapping in a new build never takes a shell down.
- Performance — an 11 MB `cat` completes in 95 ms and DOOM-fire renders at
  888 fps; the daemon drains the PTY at device speed off the render path.
- Shell-aware — new tabs and splits open in the current working directory;
  zsh, bash, fish, and PowerShell are set up automatically.
- Enhanced prompt — inline completion, syntax highlighting, history, and
  in-terminal search, with rich flag/subcommand signatures for common tools.
- Tabs, resizable splits, a command palette, click-to-open links, desktop
  notifications, eight themes, and CJK/IME input.

Native builds for macOS, Windows, and Linux.
Built on Zed's gpui and Alacritty's VT core.
2026-07-06 21:54:27 +08:00