Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,62 @@ A fast, worktree-centric terminal manager for coding agents. One tab per repo (l

The CLI is `wtd`: a daemon per host owns the PTYs and git watches, and a local hub serves the browser UI and reaches every daemon over one protocol. See `architecture/` for the structure and its principles.

## Architecture

Three running components, all started from the one `wtd` binary:

| Component | Runs | Owns |
| --- | --- | --- |
| Daemon (`wtd daemon`) | One per host, independent of any page or hub | The PTYs and their screen mirrors, git worktree watches, and `~/.local/state/worktree-term/state.json`: each worktree's terminal tabs, splits, active tab and checkbox |
| Hub (`wtd hub`, started by `wtd ui`) | One on the machine you browse from | `~/.config/worktree-term/config.json` (repos, roots, presets, hosts); nothing else |
| Page | The browser | Rendering (xterm.js) and view preferences in `localStorage`: selected repo, selected worktree per repo, "checked only" filter, name search, sidebar width |

- **The browser talks only to the hub**: the hub serves the page and one WebSocket per page, on `127.0.0.1` only.
- **The daemon manages the terminals.** A terminal lives until it is closed or its worktree removed; closing the page or restarting the hub does not end it.
- **Tabs and splits are persisted by the daemon** of the host the worktree is on, so every page sees the same layout. View preferences stay in each browser.
- For each page, the hub holds one link per host: the daemon's owner-only unix socket locally, `ssh <alias> "$HOME/.local/bin/wtd" connect` remotely. `wtd connect` only bridges SSH's stdio to the remote daemon's socket, starting the daemon if needed.

### One machine

```mermaid
flowchart LR
subgraph machine["Your machine"]
page["Page (browser)<br/>xterm.js<br/>localStorage: selection, filters, sidebar width"]
hub["Hub<br/>config.json: repos, roots, presets, hosts"]
daemon["Daemon<br/>PTYs + screen mirrors, git watches<br/>state.json: tabs, splits, checkboxes"]
ptys["Shells and agents in PTYs"]
repos[("Repos and worktrees")]
page -- "WebSocket, 127.0.0.1:7417" --> hub
hub -- "unix socket run/HOST.sock" --> daemon
daemon --> ptys
daemon -. "watches" .-> repos
end
```

### Two machines over SSH

The browser and hub run on your laptop; the remote host `devbox` contributes only its daemon. A hub's repo tabs, including a remote host's, come from its own `config.json`, so a hub on `devbox` plays no part.

```mermaid
flowchart LR
subgraph laptop["laptop (where you browse)"]
page["Page (browser)<br/>localStorage: selection, filters, sidebar width"]
hub["Hub<br/>config.json: hosts: devbox + its repos"]
localDaemon["Daemon<br/>the laptop's terminals<br/>state.json"]
page -- "WebSocket, 127.0.0.1:7417" --> hub
hub -- "unix socket" --> localDaemon
end
subgraph devbox["devbox"]
connect["wtd connect<br/>stdio bridge"]
remoteDaemon["Daemon<br/>devbox's terminals, git watches<br/>state.json: tabs, splits, checkboxes"]
otherHub["Its own hub and config.json<br/>(not used by the laptop)"]
connect -- "unix socket" --> remoteDaemon
end
hub -- "ssh devbox wtd connect<br/>one control master per host;<br/>a Tailscale check's login link is shown on the page" --> connect
classDef unused stroke-dasharray: 4 4,color:#888;
class otherHub unused;
```

## Installation

Requires Linux and Node 20 or later. From a checkout, `pnpm build`, then:
Expand Down Expand Up @@ -49,7 +105,7 @@ wtd install-remote devbox --node /opt/node22/bin/node

or with "Install" on the page while the host is down. The remote gets the same layout as `wtd install-local`, without launcher or unit. Its shim runs the Node found at install time, so `"$HOME/.local/bin/wtd" connect` works over plain `ssh` with no dotfile changes.

Each browser session holds one `ssh <alias> "$HOME/.local/bin/wtd" connect` per remote host, multiplexed over one SSH control master per host (`ControlPersist=10m`); a lost link reconnects to the same daemon and keeps its terminals. A host that keeps failing shows as down with SSH's last error line. sshd's `MaxSessions` (default 10) caps the sessions of one master: each open page uses one per host, plus a brief one for each "Add repo" or "Remove repo". When a host's running daemon speaks an older daemon protocol than its installed `wtd` (whose protocol `wtd --version` prints; it does not ask the running daemon), the page offers "Restart daemon" (this machine) or "Reinstall & restart" (a remote host), naming the terminals that will be killed. `WTD_SSH` replaces the `ssh` program.
Each browser session holds one `ssh <alias> "$HOME/.local/bin/wtd" connect` per remote host, multiplexed over one SSH control master per host (`ControlPersist=10m`); a lost link reconnects to the same daemon and keeps its terminals. A host that keeps failing shows as down with SSH's last error line. A host reached over Tailscale SSH whose check has expired shows "needs Tailscale login" with an "Open login link" button; approving the check there connects every open page. sshd's `MaxSessions` (default 10) caps the sessions of one master: each open page uses one per host, plus a brief one for each "Add repo" or "Remove repo". When a host's running daemon speaks an older daemon protocol than its installed `wtd` (whose protocol `wtd --version` prints; it does not ask the running daemon), the page offers "Restart daemon" (this machine) or "Reinstall & restart" (a remote host), naming the terminals that will be killed. `WTD_SSH` replaces the `ssh` program.

## Keyboard

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-17
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## Context

See proposal.md – Why. Today `ProcessLink` (`src/hub/links`) only consults `StderrTail.reason()` once the SSH process ends, and `HostState` (`src/hub/router/hosts.ts`) can express only failure, not "blocked, here is how to unblock". The symptom is one instance of that structural gap: a pending remote link has no channel for live reports, so the fix adds one channel (one parser, one link event, one host event) rather than a UI special case.

Applicable principles: system 1 (no new model arrows — `hub.links → platform.dialer` exists), 5 (the URL is parsed and prefix-checked where it enters, and the wire decoder rejects anything else), 6, 8; hub 2 (only `links` touches the process), 4 and 5 (state stays per session, no buffering); protocol 2–4 (schema-only type, decoder rejects, browser version bump); web 2 and 7. No arc42 or model edits.

## Goals / Non-Goals

**Goals:** surface the check URL within 1 s; connect on approval over the same SSH process; one approval connects every open session.

**Non-Goals:** the install/reinstall SSH path (it may still hang until its timeout when no control master is authenticated); a handshake timeout (a `needs-login` link waits until Tailscale gives up, then the normal retry runs); opening the URL automatically.

## Decisions

- **Wire shape: new status `needs-login` plus a dedicated `login` field**, not the URL in `reason`. `reason` keeps meaning "cause of the latest failure"; the field's schema (`https://login.tailscale.com/` prefix, ≤ 1024 chars, non-null iff `needs-login`) lets the page put it in an `href` safely, since the decoder rejects anything else.
- **Parser in `platform/dialer`, next to `StderrTail`.** It is SSH-standard-error knowledge, pure, and reusable by the install path later. A line scanner on the same stream holds a partial line across chunks, capped at 4 KiB, inside the existing 1 MiB read limit.
- **Optional `login(url)` on `LinkEvents`.** Only `ProcessLink` calls it, only before the first frame, deduplicated per URL. Delivery is guarded exactly like frames: nothing after `closed` or `finished`, and `HostLink` drops callbacks from a link it no longer holds (Codex review finding 1).
- **Host events `login` and `redial`.** `login` → `needs-login`, keeps `failures` (so an expired check that then fails continues the down-count); `redial` → `connecting`, keeps `failures`; `hello` and `failed` clear `login`.
- **Sibling redial through a per-host-key notifier in the router**, beside the existing `HostCoordinator` map. On a greet, it tells every other session's `HostLink` for that key; only one in `needs-login` closes its link, cancels any retry timer, emits `redial` and connects at once. The redial reuses the now-authenticated control master (`ControlMaster=auto`, `ControlPersist=10m`).

## Risks / Trade-offs

- [Tailscale changes the banner wording] → the parser keys on `To authenticate, visit:` plus the URL prefix; a change degrades to today's behaviour (host stays `connecting`), never to a wrong link.
- [A dead link's late banner reinstating a stale URL] → lifetime guards above, with tests for banners after close, finish and replacement.
- [The two-session process test passing without redial, since the fake SSH gate is shared per alias] → the fixture gains per-invocation release: release only session A's original process, keep B's blocked, let later dials through, and assert B's redial and its original process's termination (Codex review finding 2).
- [Each extra page shows a different URL] → accepted; approving any one connects all via redial.

## Migration Plan

Browser protocol 8 → 9 only: the hub serves the page, so an old page reloads once per the existing stale-bundle rule. No daemon restart.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## Why

A remote host reached over Tailscale SSH whose tailnet rule uses `"action": "check"` blocks on an expired check: sshd prints a `https://login.tailscale.com/…` link on standard error and holds the connection until it is approved in a browser. The hub reads a remote link's standard error only after SSH ends, so the host sits in `connecting` forever with no reason shown and no way to find the link from the page (DEV-2106).

## What Changes

- A remote link reads SSH's standard error live while it waits for the daemon's first frame, and reports a Tailscale check login URL (`https://login.tailscale.com/` only) the moment SSH prints it.
- New host status `needs-login`, carrying that URL in a new host-entry field `login`; approving the check lets the waiting SSH proceed, so the host connects with no retry or user action in worktree-term.
- When one session's link to a host greets, every other session's link to that host that is `needs-login` is redialed at once, so one approval connects every open page.
- The page shows a `needs-login` host with a banner and an "Open login link" button opening the URL in a new tab; its repo tabs show `needs login`.
- **BREAKING** (browser link only): `BROWSER_PROTOCOL_VERSION` 8 → 9 for the new status and field; the daemon link is unchanged.

## Capabilities

### New Capabilities

_None._

### Modified Capabilities

- `protocol`: host entry gains status `needs-login` and the `login` field with its URL invariant; browser protocol version 9.
- `hub`: remote links report the Tailscale check URL live and the host becomes `needs-login`; sibling sessions redial when one link greets.
- `web`: banner and repo-tab status for a `needs-login` host, with the login link.

## Impact

- Code: `src/platform/dialer` (login-line parser and line scanner), `src/hub/links` (live stderr scan, `login` link event), `src/hub/router` (host state, session redial across sessions), `src/protocol` (host entry schema, version, goldens), `src/web/ui` (banner, tab status).
- Tests: unit (parser, scanner, host transitions, remote link, protocol, web hosts), process (`test/process/hub-router.test.ts` with a fake `WTD_SSH` gated per invocation), e2e (banner and link).
- Docs: README remote-host paragraph.
- Unchanged: the install/reinstall SSH path; no handshake timeout.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## ADDED Requirements

### Requirement: Tailscale SSH check login
While a remote link has received no frame from its daemon, the hub SHALL read its SSH process's standard error as it is written, line by line. A line containing `To authenticate, visit:` followed by a URL that, with control characters removed, starts with `https://login.tailscale.com/` and has at most 1024 characters SHALL make the host `needs-login` with that URL as `login`, within 1 s of being written, with `reason`, `daemonVersion` and `instance` null, keeping the link open and counting no failure. A line naming any other URL SHALL change nothing, as SHALL a repeat of the URL the host already shows. Standard error read after the link's first daemon frame, after the link was closed or ended, or after the link was replaced SHALL change no host. A `needs-login` host SHALL become `connected` or `outdated` when its link exchanges `hello`, and `reconnecting` or `down` when its link is lost, as for any other link, counting the consecutive failures from before it was `needs-login`; `login` SHALL be null in every status other than `needs-login`. A request for a `needs-login` host SHALL fail with `host-unavailable`. When a link of one session to a host exchanges `hello`, every other session's link to the same host that is `needs-login` SHALL be closed and at once replaced by a new link, that session's host becoming `connecting` with `login` null; links of other sessions in any other status SHALL be left alone.

#### Scenario: Login link surfaced
- **WHEN** the SSH process for host `box` writes `# Tailscale SSH requires an additional check.` and `# To authenticate, visit: https://login.tailscale.com/a/abc123` to standard error and then waits
- **THEN** within 1 s the session receives `box` as `needs-login` with `login` `https://login.tailscale.com/a/abc123` and a null `reason`, and no second SSH process is started

#### Scenario: Approval connects without retry
- **WHEN** a `needs-login` host's waiting SSH process proceeds and the daemon greets
- **THEN** the session receives the host as `connected` with a null `login`, over the same SSH process

#### Scenario: Other URL ignored
- **WHEN** the SSH process for a host writes `To authenticate, visit: https://login.tailscale.com.evil/a/x` to standard error and waits
- **THEN** the host stays `connecting`

#### Scenario: Expired check falls back to retrying
- **WHEN** a `needs-login` host's SSH process exits writing `Tailscale SSH check timed out` to standard error, and the next SSH process writes a new login URL
- **THEN** the session receives the host as `reconnecting` with that reason and a null `login`, then as `needs-login` with the new URL

#### Scenario: Banner from a closed link ignored
- **WHEN** a session's link is closed and its SSH process writes a login banner while it is being terminated
- **THEN** no host entry changes

#### Scenario: Request to a needs-login host
- **WHEN** a browser sends `host{host: 1, m: watchRepo}` while host 1 is `needs-login`
- **THEN** the session receives an `error` with code `host-unavailable`

#### Scenario: One approval connects every session
- **WHEN** sessions A and B each hold a `needs-login` link to `box` over their own SSH processes, a session C is `connected` to `box`, and only A's SSH process proceeds and greets
- **THEN** B's original SSH process is terminated, B receives `box` as `connecting` and then `connected` over a new SSH process, and C receives no change to `box`
Loading
Loading