Commit Graph
32 Commits
Author SHA1 Message Date
l0ng-ai 382a46ac29 refactor(server): retire the opaque workspace record store
Clients stopped sending WorkspaceList/Get/Put/Delete when the tree
migration landed, so the coexistence scaffolding comes out:

- core::workspace_store is deleted. Attachment and the data-directory
  resolution (TTY7_DATA_DIR, XDG fallback chain) move into
  core::machine, which was already their only consumer; Attachment
  loses its vestigial serde derives (it never crosses disk or wire).
- The control dialect drops the four record verbs, the ReplyOk::Json
  payload they answered with, and the WorkspaceChanged event. Their
  serde names (and the workspace-store capability bit) are recorded as
  burned rather than reserved by any mechanism — the dialect has no
  numbered slots to hold, so a comment at each site is the guard, plus
  the handshake test asserting the bit never reappears.
- host::server loses Services.workspaces, the verb arms, the
  per-connection store subscription and its WorkspaceChanged forwarder,
  and the store half of attach/detach/teardown. Attachment data now
  lives solely in the tree: a workspace the tree does not list records
  no data half (the registry's live handles still move, so takeover
  behaviour is unchanged), and it appears the moment the workspace
  does. Services::with_workspaces/and_machine collapse into
  with_machine; control_services becomes a single match.
- The attach/takeover tests move onto MachineStore wholesale, attaching
  to workspaces created in a real tree; the record-store round-trip and
  fan-out tests go (tests/machine_tree.rs has carried the tree
  equivalents since the verbs landed), and tests/workspace_store.rs is
  deleted with the serde_json dev-dependency that existed only for it.
  machine.rs gains the two guarantees the old suite held uniquely: an
  attachment dies with its workspace structurally, and the default path
  resolution ends at the documented file.
- The GUI's dead WorkspaceChanged arm and every stale doc reference go.
2026-07-30 00:35:49 +08:00
l0ng-ai 86c711b2ba refactor(ui): client persistence shrinks to pure window views
The client file stops carrying layout. session.json's Workspace — id,
name, a whole embedded Session, geometry, open, last_active, host —
becomes WindowView { id, window, open, last_active, host } in a fresh
views.json (no migration by design; an old session.json is simply
ignored, and its panes revive from the machine tree like any daemon
restart). Everything the embedded layout used to answer already moved
to the per-machine mirror, so this deletes the write half:

- WorkspaceStore::claim answers only the id; record shrinks to
  record_geometry. claimable_session / record_session — the
  reachability-gated layout cache — go entirely, and with them the
  one-time empty-tree import in finish_hydration: with no cached copy
  there is nothing to import, and the machine answering "no tabs" is
  the layout.
- The user-set name is purely the machine's fact now. rename /
  rename_locally leave the store; the chip and switcher renames fire
  WorkspaceRename directly (tree_sync::rename_workspace), the
  WorkspaceRenamed delta needs nothing from the window because the
  mirror already applied it, and WorkspaceCreate seeds no name.
- forget_pane_ids / blank_pane_ids and the layout-derived getters
  (display_name, dominant_repo, first_cwd, pane_count, pane_ids) are
  deleted with their tests — each had grown a mirror-side twin.
- switch_workspace always hydrates: with the tree as the only layout
  source, restore-off governs what launch comes back to, not what a
  deliberate switcher pick shows.

The retired opaque record store loses its one test that asserted its
file parses as a client Workspaces document — that coupling is the
thing this migration ends, and the store itself is next to go.
2026-07-30 00:00:37 +08:00
l0ng-ai e931323f30 fix(ui): close the review findings on the tree migration
Review fixes, worst first:

- Pane ids never alias across daemon restarts: the pane registry seeds
  its counter past everything the persisted tree references. A fresh
  process minting from 1 handed new shells ids that dead leaves still
  claimed — the tree marked the wrong pane live, revival stalled forever
  on 'already part of this machine's tree', and an attach by the stale
  id stole another workspace's stream. Ids are names now, not slots.
- An empty window only licenses WorkspaceRemove once it is *informed*:
  a window whose hydration has not answered is empty because it is
  waiting, and closing or swapping it mid-pull was deleting populated
  trees. Remote workspaces also hydrate regardless of the restore
  setting — their panes are running sessions, not a saved layout, and
  the restore-off swap used to open them empty-and-authoritative and
  close every tab on the machine.
- Tabs whose panes are all still spawning are *held*, not pruned: they
  are invisible in the desired tree without being absent, and the Full
  diff was closing them (spending the records the landing spawns'
  PaneReplace needed) on every remote revival.
- A preempted window stays passive under deltas: applying the usurper's
  TabCreated/TabRestructured attached to their fresh panes and stole the
  streams they were typing into. The mirror is dropped instead; taking
  the workspace back re-pulls it whole.
- Delta TabClosed tracks the active tab by identity (closing a tab to
  the left no longer shifts focus and pushes the wrong active tab back).
- The hydrate/resync path drops the op queue like desync does, so ops
  computed against an abandoned mirror cannot drain after the snapshot.
- A rebuilt remote tab no longer matches a native-SSH leaf's *local*
  pane id against remote ids; delta-applied ratios clamp to the GUI
  band; async completions use get_mut so a forgotten window's sync state
  is not resurrected.
2026-07-29 23:24:25 +08:00
l0ng-ai 6bbf533d7e refactor(core): retire the client-side pane-identity defenses
The machine tree made this whole family unnecessary, so it goes rather
than lingers: daemon_instance stamps (a restarted daemon's tree says
live=false about every pane — a fact, where the stamp was a heuristic),
forget_stale_pane_ids on both layers, dedupe_pane_ids (the daemon
refuses a pane appearing twice in its tree, so there is no duplicate to
mop up client-side), the claim/record instance plumbing, and the
whole-record halves of the storage split (to_remote_json,
apply_remote_json, REMOTE_OWNED_FIELDS, CLIENT_OWNED_FIELDS, and the
store's apply_remote / remote_payload), together with their tests.

forget_pane_ids stays for now: it clears the client's cached copy, which
still serves as the one-time import fallback until the view file slims
down to pure view state.
2026-07-29 22:59:02 +08:00
l0ng-ai 31e690cde7 feat(daemon): report panes the machine tree no longer references
With the tree now populated by clients' semantic operations, the daemon
can finally see panes nothing references. A periodic sweep reports them —
log-only, deliberately: an unreferenced pane is not proof of a leak (a
native-SSH pane opened inside a remote workspace's window runs in this
daemon while belonging to the other machine's tree), and reclaiming one
wrongly kills a session the user is looking at. The sweep's interval
doubles as a grace period: a pane is reported only after being
unreferenced across two consecutive looks, so an adoption still in
flight is never flagged. Reclamation can be layered on once the log has
shown the false-positive rate is zero.
2026-07-29 22:40:06 +08:00
l0ng-ai 71a74ba936 feat(ui): live windows apply the machine's incremental layout deltas
The pump's event drain now lands ControlEvent::Layout instead of debug-
logging it: each delta advances this client's mirror (by the same
surgery the server ran) and then the live window showing the workspace —
renames, regrouping, moves, active-tab changes and ratio drags in place;
TabCreated by building the tab and attaching its (writer-spawned, so
live) panes; TabRestructured by rebuilding the one tab while reusing the
views of panes the window already shows, because re-attaching a pane
this window holds would steal its own stream. Origin exclusion means
every delta arriving is another client's edit, and applying it to window
and mirror in one step leaves the next local diff with nothing to echo.

A delta that will not apply cleanly — a tab the mirror never heard of, a
drifted window — falls back to re-pulling the workspace and rebuilding
the window from the authoritative tree, the same single recovery path
every other failure already uses.
2026-07-29 22:37:43 +08:00
l0ng-ai 779a32805d feat(ui): local windows restore by asking the daemon's tree
The read path: opening a known local workspace no longer rebuilds from
session.json synchronously. The window opens empty and a background pull
(MachineGet — the workspace's structure joined with the pane registry,
which is where the revival facts live) rebuilds it the moment the daemon
answers; against the local daemon that is milliseconds, so the empty
state is effectively one frame — the same shape a remote workspace's
connect-driven rebuild has always had.

The lowering from tree to window is the revival decision: a leaf whose
pane record says live re-attaches by id, a dead one lowers to an id-less
leaf carrying the record's cwd, SSH spec and agent resume — the exact
shape that makes the existing builder spawn a successor and type the
agent's --resume. The save that follows diffs the successor against the
mirror and sends PaneReplace, spending the old record; revival needed no
op code of its own.

Restored tabs keep their daemon tab ids (SessionTab grows a never-
persisted tree_id), so the first save addresses the daemon's tabs instead
of churning them. A tree with nothing for the workspace falls back once
to the client's cached layout, whose adoption re-populates the tree
through the ordinary diff — the whole of the best-effort import.
2026-07-29 22:30:49 +08:00
l0ng-ai 1e605afb55 feat(ui): windows speak semantic tree operations for every structural change
The write path of the client migration: each window now keeps a mirror of
what the daemon's tree holds for its workspace, and save_session — the
funnel every structural change already passes through — diffs the window
against that mirror and sends the recovered operations (TabCreate,
PaneSplit, PaneClose, PaneReplace, TabMove, ratio and label ops) over the
workspace's control link: the LocalLink for this machine, the machine's
RemoteConnections entry otherwise. Consecutive saves differ by exactly one
user action, so the diff recovers that action rather than re-shipping the
layout; changes no single op expresses rebuild the affected tab whole,
matching the delta contract's own granularity.

The mirror advances by running the server's own tree surgery (PaneNode's
split/remove/replace are public now), and any disagreement — a refused op,
a dropped link — resolves by one shared recovery path: drop the queue,
re-pull WorkspaceTree, re-diff. Fresh spawns are invisible until their
pane id lands; land_pane's save is when their create goes out. GUI tabs
carry a client-minted TabId, and a primed mirror re-points tabs it
recognizes by their panes, so a rebuilt window adopts the daemon's tabs
instead of churning them.

Workspace-level facts ride along: focus touches, renames, and deletions
now reach the machine's tree too, and the divider drag finally persists
the ratio it lands on (it previously reached disk only as a passenger on
the next structural change).

session.json is still written in parallel; it retires with the read-path
migration.
2026-07-29 22:23:13 +08:00
l0ng-ai 7c3e42999f feat(control): let clients mint workspace and tab identities on create
A window names its workspace — in the registry, the view file, and any
operation it queues — before its first round trip completes, and the same
holds for a tab the moment the user opens it. Making the daemon the only
minter would force every client to hold its edits until a reply carried
the real id back. Ids are uuids, so a client-minted one is as unique as a
daemon-minted one; WorkspaceCreate and TabCreate now carry an optional
client id, keep it when it is free, and refuse a duplicate rather than
adopt it. Absent (older callers, tests) the daemon mints as before.
2026-07-29 22:08:45 +08:00
l0ng-ai 324e1d1543 fix(core): review hardening for the machine-tree foundation
Findings from a correctness review of the new daemon-owned tree,
applied together:

- A dead pane can no longer be resurrected in the tree by its own last
  output. On Windows the exit monitor reports the death while the
  reader is still draining ConPTY's buffered bytes, and the death
  report is latched; the reader's 'output is proof of life' publish now
  asserts liveness only while the pane state still says alive.
- Delta delivery is ordered. Mutations were serialized by the state
  lock but delivered after releasing it, so one writer's deltas could
  overtake another's and leave every mirroring client on the losing
  state with no cue to re-pull. A notify-order mutex now spans each
  mutation and its own fan-out; cheap, because subscriber callbacks are
  enqueue-only by contract.
- Implicit active-tab changes broadcast. tab_create's activation and
  the close paths' heal now emit ActiveTabChanged, so a client applying
  deltas never re-implements the server's heal rule; the one
  inexpressible case (no tabs) needs no delta because it is a fact,
  not surgery.
- The coarse agent status no longer drives disk writes: it flips per
  hook event and is display-only, so it is outside the changed-facts
  gate and merely rides along when a load-bearing fact changes.
- control_services reports which stores it serves on stderr again —
  tty7-server configures no log sink, and 'no machine tree' was
  invisible exactly where it matters, on a headless box.
- The local link's first connect attempt is immediate instead of one
  backoff step late; the observation-slot test withdraws its store so
  it cannot swallow later tests' observations; and locked()'s poison
  rationale now says what is actually guaranteed.
2026-07-29 21:49:22 +08:00
l0ng-ai 0d58eef4bc feat(control): attachment and takeover ride the machine tree too
WorkspaceAttach / WorkspaceDetach (and the hello-names-a-workspace
shorthand) now record their data half on whichever workspace stores the
server carries: the retired record store, the machine tree, or — on a
full daemon while clients migrate — both, since they describe the same
workspace. The behavioural contract is untouched and now survives the
record store's retirement: newcomer always wins, the displaced session
is pushed Preempted (and closed only when its link was dedicated), and
a preempted session's tidy-up detach cannot evict the usurper — the
token check lives in the tree's runtime-only attachment exactly as it
did in the store's. A server carrying neither store answers the same
refusal a store-less server always has.

WorkspaceId gains FromStr (the inverse of its Display) because the
attach verbs predate the typed tree and carry the id as a string. The
end-to-end test drives a takeover on a server serving the tree and no
record store at all, asserting the tree's own attachment record moves
with it.
2026-07-29 21:21:48 +08:00
l0ng-ai b48bb0ada1 feat(daemon): pane facts flow from the pane server into the machine tree
The tree's pane records are only worth reviving from if they hold what
the machine itself observed, so the pane server now publishes into the
MachineStore the daemon serves: the reader thread reports OSC 7 / probed
cwd changes and the sniffer's agent facts (identity, native session id,
launch argv, coarse status) after each chunk that changed them, and
DeathReporter::report flips the record to live == false however the
death was noticed — that flag is the client-visible 'awaiting revival'
state, and it now comes from the process that owns the PTYs on the very
event, not only from the next restart.

The store rides a process-wide slot (installed by control_services,
same shape as the control event observer) so the three pane-spawn paths
need not thread it through; without one installed, observing is a
no-op, which keeps unit tests and tree-less servers quiet. Facts are
published outside the pane state lock and only on a real change, so the
reader's hot path pays two clones and a compare. AgentFacts.status
tightens from a free string to the existing AgentStatus enum while no
wire client depends on it.
2026-07-29 21:10:57 +08:00
l0ng-ai 57d72c91ad feat(control): machine-tree verbs and incremental Layout deltas
Teach the control dialect the semantic operations the machine tree
serves: MachineGet / WorkspaceTree pulls, WorkspaceCreate / Rename /
Remove / Touch / SetActiveTab, TabCreate / Close / Rename / Move /
SetGroup, and PaneSplit / Close / SetRatio / Move / Replace. Replies
carry the daemon's own tree types (a created workspace or tab comes
back whole; close operations answer the pane ids that left the tree so
the caller can kill their PTYs), and every operation broadcasts a
ControlEvent::Layout delta to every connection but the writer's — the
same origin-exclusion mechanism the record store uses, one delta at a
time instead of whole-record last-writer-wins.

The server advertises a new 'machine-tree' capability bit only when it
actually carries a MachineStore; both daemons now do, alongside the
retired opaque record store, which keeps serving unchanged while
clients migrate. Delta fan-out rides its own bounded queue and
forwarder thread per connection, so a peer that stopped reading stalls
nobody's edit; the drop-on-overflow tradeoff is documented against the
keepalive that reaps such a peer and the full pull every reconnect
starts with.

The request/reply/event enums lose their Eq derive: split ratios are
f32. End-to-end tests drive the shipped tty7-server binary over real
pipes: capability advertisement, tree ops landing in the server's own
file, dead-pane revival across a real process restart, and delta
delivery between two live clients.
2026-07-29 21:01:58 +08:00
l0ng-ai 15c18356f0 feat(core): daemon-owned machine tree with semantic operations
Add core::machine: the workspace/tab/pane tree a machine's daemon owns
outright, replacing the client-owned-schema model of the opaque record
store. Leaves hold a pane id and nothing else; every fact about a pane
(cwd from OSC 7, title, ssh spec, agent identity) lives once in the
pane registry, which is what makes revival sound: a reopened store
force-clears every live flag, so after a daemon restart the tree itself
says every leaf is awaiting revival — no client-side instance stamps or
id-reuse heuristics required.

Operations (workspace create/rename/delete/touch/set-active-tab, tab
create/close/rename/move/regroup, pane split/close/set-ratio/move/
replace) validate against the held tree, persist atomically, roll back
on a failed write, and broadcast incremental LayoutDelta events with
origin exclusion so a writer never hears its own echo. Persisted to
machine.json beside the old store's file, serde with #[serde(default)]
throughout so the daemon can keep evolving the schema, corrupt files
quarantined instead of overwritten.
2026-07-29 20:55:16 +08:00
l0ng-ai bbeeec51d7 refactor(daemon): share one run_daemon between tty7 and tty7-server
Extract the control-listener-plus-pane-server startup from tty7-server
into tty7_core::daemon::server::run_daemon, and point both binaries at
it. The local daemon now serves the control dialect exactly like a
remote one: one machine = one daemon, whichever binary happens to be
running it.

The bound control socket (and a bind failure) is still reported on
stderr with the historical 'tty7-server:' prefix — a headless server's
log file is off by default, and the remote_router test reads that exact
line back to prove the client derivation and the server bind agree.
2026-07-29 20:54:01 +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-aiandl0ng-ai 9ca3319239 feat(agents): fork an agent session and copy its session id (#241)
* feat(agents): fork an agent session, and copy its session id

A coding-agent conversation is a single thread: to try a risky direction you
either lose the one that got you there, or you don't try it. Every agent tty7
resumes already knows how to branch — `codex fork <id>`, `claude --resume <id>
--fork-session`, `opencode --session <id> --fork`, `grok --resume <id>
--fork-session` — but nothing in tty7 reached them, so the capability was
invisible from the terminal that already knows every pane's session id.

Fork is a per-agent capability beside the existing resume table
(`CLIAgent::fork_command`), not a Codex special case: it is the same `match
self` shape, it reuses the same id validation and the same launch-flag replay,
and four installed agents qualify today. Every command was checked against that
CLI's own `--help`; agents with no fork tty7 could verify return `None` and are
never offered the action, since a guessed flag shape would only ever produce a
usage error in the pane.

Flag replay needed one correctness fix to survive this. A forked pane's own
argv *is* a fork command, so relaunching it would replay the stale subcommand
and id (`codex fork <old>` → an old id as a positional prompt) or double the
modifier (`--fork-session --fork-session`). `codex fork` now sheds its
subcommand exactly as `codex resume` did, and `--fork-session` / `--fork` join
their agents' stale session-targeting lists. That also settles restore: a forked
pane restores through `resume_command`, which now drops the fork flag — a
restart continues the fork rather than branching it again.

Placement follows where the user asked from. A pane-level ask is spatial, so
the pane right-click menu offers Split Right / Left / Down / Up (pane splits
gained a `before` slot for the Left/Up half, which the tree had no way to
express). A tab-level ask isn't, so the tab context menu — inherited verbatim by
the sidebar rows, which is where the request came from — opens the fork in a new
tab with no placement question. The bare action behind the palette, the File
menu and Settings → Keybindings takes the tab-level meaning.

The three ways a fork can't run all surface rather than no-op: no session id
yet (hooks not installed) and a remote pane (the command would shell the *local*
agent) render the row disabled instead of hiding it, so the capability stays
discoverable, and the action paths that have no row to grey out say so in a
notification. Forking mid-turn is allowed but announced — agents fork from the
persisted transcript, so the turn in flight is absent from the copy — and the
parent is untouched either way.

Copy Session ID sits beside Copy Working Directory. Codex has no
copy-or-duplicate subcommand, so "copy the session" is the id: paste it into
`codex resume`, a bug report, or another tool.

Deliberately not built: any reading or writing of an agent's own session files.
tty7's exposure stays the public CLI contract plus the hook payload's session
id, so a change to Codex's rollout format or its version-numbered SQLite index
costs at most a visible shell error. Forked tabs also look exactly like their
parent, by decision — "Rename Tab" is the answer.

Closes #211

* no-mistakes(review): perf(terminal): compute fork menu enablement at menu-open time

* no-mistakes(document): docs: correct fork action surfaces, label, and remote limits

* fix(agents): label forking the same for every agent

The fork row said "Branch Session" on Claude Code and "Fork Session"
everywhere else, on the strength of a source comment claiming "Claude Code
calls it branching". It does not. `claude --help` documents the flag as
`--fork-session`, described as "When resuming, create a new session ID instead
of reusing the original"; the only occurrences of "branch" in its help are an
unrelated git-branch review option. The claim came from otty's own UI wording,
which I mistook for Claude's vocabulary and then wrote into the source as
fact — so the comment goes with the special case rather than being left behind
as a false statement about someone else's tool.

The split was also inconsistent with itself: Grok takes the identical
`--fork-session` flag and was already labelled "Fork Session". Every agent that
has the capability calls it forking — `codex fork`, `--fork-session` on Claude
Code and Grok, `--fork` on OpenCode — so one wording covers all four.

`fork_label` keeps returning `Option<&'static str>`: it is still the UI's single
capability gate (`None` = no verified fork command, no row offered), and
per-agent wording stays expressible should one ever genuinely diverge.

Generated commands are untouched — the existing table test still pins
`claude --resume <id> --fork-session` and the other three verbatim.

Also drops the two doc sentences that promised the per-agent label, and the
stale "Branch Session" mentions left in comments; no occurrence survives
anywhere in the tree.

* no-mistakes(review): fix(agents): fork the pane the tab menu row named

* no-mistakes(document): rewrap fork menu comment after label unification

* fix(agents): repoint Pi's token-gate comment after the rebase

Rebasing #211 onto #240 moved the session-id token gate out of
resume_command and into the shared session_command_flags helper, so
Pi's comment pointing at "the token gate above" no longer names
anything. Comment only; the gate itself is unchanged.

* no-mistakes(document): correct fork placement rationale in menus and changelog

* chore: untrack AGENTS.md per gitignore dev-tool convention

tty7 keeps agent-memory files out of the repo: `/CLAUDE.md` is already
ignored, and on disk it is a symlink to `AGENTS.md`, so tracking the
target defeated the convention. Ignore `/AGENTS.md` alongside it and drop
the tracked copy; the file stays on disk, where the notes belong.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 00:23:49 +08:00
l0ng-ai a88a8b6515 test(host): bound watch coalescing by elapsed windows, not a constant
The burst case asserted at most 4 batches for 50 writes, which really
asserts the machine finishes 50 writes inside 400ms. A loaded Linux
runner spread the burst over ~1.4s, so the coalescer correctly emitted
one batch per 100ms window and the test went red on main.

Measure how long the burst actually took and allow ceil(burst /
WATCH_COALESCE_WINDOW) + 2 batches. A host that stops coalescing still
fails: dropping the window to 0ms turns the case red.
2026-07-28 23:42:39 +08:00
l0ng-ai c3cfeb5daa Merge remote-tracking branch 'origin/main' into feat/ssh-settings-redesign 2026-07-28 22:56:14 +08:00
l0ng-ai 816a45cc17 fix(ci): build the unrepresentable-path case on Windows too
The `to_frame` guard reached for a Latin-1 filename through
`os::unix::ffi`, in a test module that is not Unix-only. It now asks
each platform for a path it accepts and `str` cannot hold — Latin-1
bytes on Unix, an unpaired surrogate on Windows — which compiles there
and, more to the point, actually asserts the refusal on both.
2026-07-28 22:39:16 +08:00
l0ng-ai 6f9324727c Merge remote-tracking branch 'origin/main' into feat/remote-workspace
The Pi extension conflicted: main gave it the session-id forwarding
doc, this branch had already moved it from `current_exe` to the target
machine's binary. Both kept.

`a_detached_result_lands_after_its_view_is_dropped` now pumps to a
deadline instead of parking once — a `Host` call runs on `HostOps`' own
pool rather than gpui's executor, so there is nothing for
`run_until_parked` to wait on until the answer has already crossed
back.
2026-07-28 22:24:03 +08:00
l0ng-ai 4486ee6849 fix(control): spawn on backlog, and hold a watch until its id is out
Two scheduling bugs on the server side of a connection.

The pool asked `idle == 0` before spawning, but `idle` counts a worker
from before it parks until after it has re-acquired the lock on its way
out — so through the whole wake-up window a worker already handed a job
still looked free, and the second `notify_one` in that window went to a
thread that had left the wait set. A client pipelining k+1 frames onto k
parked workers left the last one queued behind a `git status`. The rule
is now "more queued than parked", which counts both sides of the window
and cancels it out.

`WatchOpen` started its forwarder before the reply carrying the watch id
was written, and both go through the same sink. A directory that changed
in that instant could push a batch the client dropped — it files the id
only once `call` returns — and since the tree relists only on a watch
event, that change stayed invisible. The forwarder is now parked and
started by `finish`, and dropped outright when no reply went out, so a
cancelled `WatchOpen` no longer leaves an OS watch behind either.
2026-07-28 22:00:36 +08:00
l0ng-ai 0ec8e050ad fix(workspaces): make a handover atomic and stop two stores clobbering one file
The takeover moves two things — the `WorkspaceStore`'s record and the
server's `AttachRegistry` handle — and each was internally locked, which
is not the same as the pair moving together. Two clients attaching one
workspace at the same instant could each win a different table, after
which the store named a session the registry had already evicted and no
`detach` could clear it: the workspace reported a takeover against a
client that had disconnected hours ago. Both moves now happen under one
handover lock, dropped before the displaced client is written to so a
peer that has stopped reading still cannot hold up the next attach.

`WorkspaceDelete` had the same split with no race needed at all: the
store dropped its attachment and the registry kept its handle, so the
next client to attach that id evicted a session nobody displaced — and,
that entry being dedicated, closed its whole link.

Two `tty7-server --stdio` sessions arriving while no daemon was up each
served in-process, each with its own store over the one file. `persist`
writes the whole document, so the second to save silently dropped the
first's changes, and their separate registries made takeover a no-op
between them. The probe path now starts the daemon and bridges to it —
the rule `bridge_panes` already follows one dialect over — and the store
re-reads when the file has moved underneath it, which covers the cases
where two writers are deliberate.

`MAX_RECORD_BYTES` and `MAX_WORKSPACES` did not bound their product:
seventeen maximal records put the array past `MAX_FRAME`, after which
every `WorkspaceList` was unencodable and every client showed an empty
list. The document is now bounded at the save, and only when growing, so
an over-large file can still be deleted back under the limit.
2026-07-28 21:54:23 +08:00
l0ng-ai ba5b29818d fix(control): stop a reply that cannot be encoded from reading as a hang
Six findings from review, all in the same seam — what happens when a
message cannot go on the wire, and who is allowed to conclude the link
is dead from that.

- `Sink`/`ControlClient::send` encode before writing, so 'this message
  cannot be serialized' and 'this link failed' are distinguishable. Only
  the second can have put bytes out, and only the second is grounds for
  marking the connection dead — the client used to go `Reconnecting`
  over an oversize `WriteFile` the server never saw.
- `Conn::finish` answers an unencodable reply with the error instead of
  dropping it. Dropping left the client waiting out the request's whole
  deadline for a reply that was never coming.
- `Search` drops the hits whose paths are not UTF-8 rather than letting
  one Latin-1 filename make the whole reply unencodable. `SearchHit` is
  the only `PathBuf` on this wire; serde refuses such a path outright.
- The control socket is bound under a tightened umask. `bind` creates
  the node at `0777 & ~umask` and the `chmod` was a window — under
  `umask 002` a group-connectable one, onto unauthenticated `ReadFile`.
- Binding no longer re-permissions a directory it did not create. With
  `$TTY7_CONTROL_SOCK` or the hashed fallback the parent can be `/tmp`,
  and 0700 there locks every other user out of it.
- A blob is filed under the `pending` lock, so a caller timing out in
  the gap cannot leave a whole file's contents in the side table for the
  life of the connection.
2026-07-28 21:41:42 +08:00
l0ng-ai ff76c01ac7 fix(ci): pick mtime nanoseconds a Windows SystemTime can hold
A Windows `SystemTime` is a FILETIME, whose tick is 100ns, so
`UNIX_EPOCH + Duration::new(_, 123_456_789)` came back as
`123_456_700` and the assertion failed on a rounding this conversion
never saw. Every nanosecond figure in the case is now a multiple of 100,
which still exercises the full nanos field.
2026-07-28 19:57:26 +08:00
l0ng-ai 26f3a73f58 fix(ci): gate the tty7-server test suites that need --stdio on Unix
`--stdio` is refused on Windows by design, and the control socket it
probes for is Unix-domain, so `stdio_conformance` and `workspace_store`
join `remote_router`/`routed_pane` in carrying a file-level `cfg(unix)`.

`cli.rs` keeps its argument-handling cases everywhere — `--version`,
`--help`, `agent-hook` and the usage error say nothing about transports
— and gates only the bridge and probe cases, which spawn a `--stdio`
child or stand up a listener.
2026-07-28 19:39:19 +08:00
l0ng-ai 0fba41a0e5 fix(ci): keep the control-server tests off Windows
Every test in `host::server`'s test module talks over a `UnixStream`
pair, so the module needs the same `unix` gate the `tty7-server`
integration tests already carry — without it the Windows leg of
`build & test` fails to compile `tty7-core`'s lib test with 19 errors.

The three pool tests are plain threads and channels, so they move to
their own module rather than being gated away from a platform they
work on.
2026-07-28 19:26:43 +08:00
l0ng-ai c951318f93 fix(tests): stop the keybinding tests from wiping the real user config
The gpui keybinding tests in `ui::app` never pinned a config dir, so every
`update_config` they run ends in `Config::save()` — a *full* overwrite — against
the developer's real `~/.config/tty7/config.json`. A plain `cargo test` reset the
whole live config to `Config::default()` plus the shortcut the test recorded;
the user-visible symptom was a rebind reappearing days after it was reset.

Guard it at the type level rather than trusting each test to remember: a
`#[cfg(test)]` `Config::save`/`load` on the GUI newtype panics when the resolved
config dir is the platform's real one. An inherent method wins over the `Deref`
to `CoreConfig`, so under `cargo test` every call site routes through the guard
and otherwise straight to core — no call site opts in. `default_config_dir` is
pub now so the guard compares against it instead of re-deriving the layout.

`set_config_dir` is first-call-wins and process-wide, which means every test has
to pin the *same* path: a test pinning a name of its own silently redirects
whichever test loses the race. Nine hand-copied `tty7-covtest-<pid>` literals now
go through one `pin_test_config_dir()`. f0cfb47 carried an earlier cut of this
fix that pinned `tty7-kbtest`, which is exactly that failure — it broke
`submitted_command_backfills_its_exit_code`, green again here.
2026-07-28 18:47:02 +08:00
l0ng-ai c469e10312 Merge remote-tracking branch 'origin/main' into feat/remote-workspace
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	src/core/config.rs
#	src/ui/pane.rs
2026-07-28 18:03:55 +08:00
l0ng-ai 5f77c40f96 feat(agents): install agent hooks onto the machine that runs the agent
Hook installation was written for one machine — this one — and a remote
workspace runs its agents on the far side of the connection, where none of
that is true: a different `$HOME`, a different filesystem separator, and a
`tty7` binary at a path this client published rather than the one it is
running from.

`HookTarget` is that machine, borrowed for the length of one background
task: `local` resolves our own home and binary, `remote` takes the home a
handshake reported and the `tty7-server-<version>` this client installed
there. Every path the installer builds now goes through it, via `Host::join`
rather than `PathBuf::join` — a Windows client installing onto a Linux box
was writing `/home/me\.claude`. The three things that are only true locally
(our own environment variables, atomic writes, running the `codex` CLI) are
gated on `is_local` instead of assumed. Settings grows a machine picker so
the page states which one it is acting on.

Also in this commit, three unrelated UI fixes:

- The settings sidebar's search placeholder sat 6px right of every nav
  label under it — a `small` (14px) magnifier where the rows use 16, a 4px
  gap where they use 8, and an `Input` that adds `input_px` (12px at the
  default size) whether or not it draws a box. All three corrected, so the
  placeholder starts on the rows' 32px text column.

- The "'X' is still running — reopen it from the workspace menu" toast is
  gone, along with the `workspace_detach_hint_seen` flag that existed only
  to show it once. Detaching a workspace is what ⌘W has always done here
  and the Window menu already lists what came off screen; a one-time
  lecture on top of that is noise. Old configs carrying the key still load
  — `Config` doesn't deny unknown fields.
2026-07-28 17:08:21 +08:00
l0ng-ai 68683647ee feat(remote): build remote panes off the UI thread, and redraw the switcher
Opening, restoring, closing or listing a remote pane each ran a routed,
blocking connection straight from a gpui input callback, freezing the whole
window for the length of an SSH setup. Pane tree leaves become a PaneSlot that
can be Ready or Connecting, so the slot lands in the layout immediately and the
wait happens in it. connect_routed now carries the same debug UI-thread guard
the Host trait uses, so the class of bug fails loudly instead of shipping.

The workspace switcher is redrawn on one alignment axis with one icon size, a
guide rail tying a remote machine's rows to it, and the command palette's card
geometry. Launch restores only the workspace the user was last in.
2026-07-28 16:32:29 +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