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
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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 |
| Hub (`wtd hub`, started by `wtd ui`) | One on the machine you browse from | `~/.config/worktree-term/config.json` (repos, roots, presets, hosts and their hub ports); 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.
Expand All @@ -38,7 +38,7 @@ flowchart LR

### 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.
The browser and hub run on your laptop; for the laptop's page, the remote host `devbox` contributes only its daemon. A hub's repo tabs, including a remote host's, come from its own `config.json`. Picking `devbox` in the Hubs menu shows `devbox`'s own hub instead, with its own `config.json`: the laptop's hub forwards a loopback port of its own for `devbox` to that hub, one `ssh -W` per connection.

```mermaid
flowchart LR
Expand All @@ -52,12 +52,13 @@ flowchart LR
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)"]
remoteHub["Its own hub<br/>config.json: devbox's repos"]
connect -- "unix socket" --> remoteDaemon
remoteHub -- "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;
page -. "Hubs menu: 127.0.0.1:7418,<br/>devbox's port on the laptop" .-> hub
hub -. "ssh -W 127.0.0.1:<devbox hub port> devbox<br/>per connection, over the same master" .-> remoteHub
```

## Installation
Expand All @@ -81,6 +82,8 @@ The hub reads `$XDG_CONFIG_HOME/worktree-term/config.json` (by default `~/.confi

`repos` and `roots` belong to this machine: `repos` are the repo tabs (absolute or `~/…`), `roots` are where "Add repo" looks for repositories, 3 levels deep (default `["~"]`). `hosts` lists remote hosts, each shown after this machine's repos: `name` labels its tabs (`name:repo`), `ssh` is the SSH alias, `repos` must be absolute paths on that host, and its `roots` (`~` meaning the remote home) default to `["~"]`. "Add repo" and "Remove repo" on the page edit `repos` here, keeping everything else as written.

A host's `port` is the loopback port on this machine through which its own hub is shown (see [Remote hubs](#remote-hubs)). The hub writes it the first time the host's hub is opened: one above the highest of `port` and every host's `port`. Once present it is never changed by the hub, and it keeps the host's page on the same origin, so that page's preferences survive. You may set it by hand; it must differ from `port` and from every other host's.

```json
{
"port": 7417,
Expand All @@ -90,7 +93,7 @@ The hub reads `$XDG_CONFIG_HOME/worktree-term/config.json` (by default `~/.confi
{ "name": "claude", "command": "claude" },
{ "name": "shell", "command": null }
],
"hosts": [{ "name": "devbox", "ssh": "devbox", "repos": ["/home/me/src/api"], "roots": ["~/src"] }]
"hosts": [{ "name": "devbox", "ssh": "devbox", "port": 7418, "repos": ["/home/me/src/api"], "roots": ["~/src"] }]
}
```

Expand All @@ -107,6 +110,22 @@ or with "Install" on the page while the host is down. The remote gets the same l

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.

## Remote hubs

The "Hubs" menu in the page header, shown while `hosts` lists a remote host, lists "This machine" and every remote host. Picking a host shows that host's own hub in the same tab: its own repos, worktrees and terminals, as if you were browsing on it, and "Add repo" and "Remove repo" there edit its `config.json`. Nothing needs configuring beyond the `hosts` entry. Picking "This machine" goes back. `wtd ui <host>` opens a window that does the same:

```sh
wtd ui devbox
```

To open it, this machine's hub runs `wtd ui --print-code` on the host over its SSH control master. That makes a hub of the host's version serve there and prints one line holding its port and a one-time code, instead of opening a browser:

```sh
wtd ui --print-code # {"port":7417,"code":"…"}
```

This machine's hub then listens on the host's `port` on `127.0.0.1` and relays each connection to the remote hub with `ssh -W`, so sshd on the host must allow it (`AllowTcpForwarding yes`, the default). The header shows "Opening <host>…" meanwhile, an "Open login link" button while a Tailscale check waits, and the error otherwise. A host whose `wtd` is missing or too old offers "Reinstall on <host>", which installs this machine's `wtd` there without restarting its daemon. Closing the page ends its forwards; the remote terminals keep running. After this machine's hub restarts, pick the host again.

## Keyboard

| Keys | Action |
Expand Down
4 changes: 2 additions & 2 deletions architecture/hub.arc42.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Purpose & context

Local-only gateway. Serves the web bundle and one WebSocket per browser session; per session, holds one link per configured host (local socket or `ssh <host> wtd connect`). Owns `config.json`.
Local-only gateway. Serves the web bundle and one WebSocket per browser session; per session, holds one link per configured host (local socket or `ssh <host> wtd connect`). Owns `config.json`. Forwards a stable loopback port per remote host to that host's hub.

## Building blocks

Expand Down Expand Up @@ -48,7 +48,7 @@ flowchart TD

1. Reaches every daemon, local or remote, only through a link; never imports daemon code. [enforced: arch_check:model-truth]
2. Only `server` faces the browser; only `links` opens daemon connections or spawns processes. [enforced: arch_check:model-truth] [enforced: test:test/architecture/dependencies.test.ts]
3. Binds `127.0.0.1` only; rejects any WebSocket upgrade without the token and matching `Origin` and `Host`. [enforced: test:test/process/hub-server-http.test.ts] [enforced: test:test/process/hub-server-websocket.test.ts]
3. Binds `127.0.0.1` only; rejects any WebSocket upgrade without the token or a valid one-time code, a `Host` of `127.0.0.1:<port>`, and an `Origin` of that same host. [enforced: test:test/process/hub-server-http.test.ts] [enforced: test:test/process/hub-server-websocket.test.ts]
4. Addresses daemon entities as (host, id) and never rewrites daemon ids. [enforced: test:test/process/hub-router-routing.test.ts]
5. Relays terminal bytes without buffering beyond frames in flight; state other than config lives in daemons. [enforced: test:test/process/hub-router-routing.test.ts]

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

See proposal.md – Why. The issue proposed a separate Chrome `--app` window per remote hub and an `ssh -L` forward; the interview replaced both (decisions below). DEV-2106 is merged into this branch: its `tailscaleLoginUrl`, `LineScanner`, `StderrTail` and `needs-login` banner are reused.

Applicable principles:
- **system** 1 (no new model arrows: `main → server/links/router/config` and `router → links/config` already exist), 3 (`node:net` newly allowed in `hub.server`), 5 (the print-code line, both fragments and `hubOpened.url` are schemas parsed where they enter), 6, 7 (every listener on `127.0.0.1`), 8 (unit, process, e2e, opt-in integration).
- **hub** 1, 2 (the tunnel listener faces the browser, so it lives in `server`; every SSH spawn lives in `links`), 3 (edited, approved wording below), 4, 5 (the relay holds only stream buffers).
- **protocol** 2–4 (browser version 9 → 10; frozen shapes for the cross-version print-code line and fragments).
- **web** 1, 2 (fragments decoded only in `client`), 7 (a page talks only to the hub that served it).

Approved arc42 edits:
- hub.arc42.md principle 3: `3. Binds \`127.0.0.1\` only; rejects any WebSocket upgrade without the token, a \`Host\` of \`127.0.0.1:<port>\`, and an \`Origin\` of that same host. [enforced: test:test/process/hub-server.test.ts]`
- hub.arc42.md Purpose & context, appended: `Forwards a stable loopback port per remote host to that host's hub.`

## Goals / Non-Goals

**Goals:**
- See any configured remote host's own hub (repos, worktrees, terminals) in the current tab, with nothing configured beyond the `hosts` entry.
- Get back to the laptop's hub with one pick.
- Each host keeps a stable origin, so its `localStorage` preferences survive.
- Opening never kills remote terminals.

**Non-Goals:**
- Showing several hubs in one page (use tabs).
- Opening hubs from a remote page directly; it goes through home.
- Reconnecting a remote page after the local hub restarts (reopen from the menu).
- Multi-hop: a remote hub's own `hosts`.

## Decisions

- **D1 — Same tab, cross-origin navigation (user's change).** The page cannot embed another hub, because of CSP `connect-src 'self'` and the Origin rule, and proxying was rejected in the issue. So the tab navigates to `http://127.0.0.1:<host port>/`. Session storage is per (tab, origin), so the home token survives a round trip in the same tab.
- **D2 — Every opening goes through home.** Only the home page sends `openHub`. A remote page's menu links to `home/` or `home/#hub=<name>`. `wtd ui <host>` opens `home/#code=…&hub=<host>`. One code path; errors and the login link always render where DEV-2106's UI lives; the home token is always present.
- **D3 — Hub-owned listener plus `ssh -W` per connection** rather than `ssh -L`. The hub sees every connection, so the tunnel's lifetime equals the page's connections, and no `-L`-through-mux semantics are relied on. OpenSSH multiplexes stdio forwarding (`-W`) over the control master.
- **D4 — `port` in `config.json`** (user's choice: one file shared by people and the machine). Assignment is max(hub port, host ports) + 1, wrapping to the lowest free port from 1024, and is done inside the `ConfigEditor` queue, recomputed on every retry. A present `port` is never touched. max+1 avoids reusing a removed host's port, which would otherwise hand a surviving tab's stored token for one remote hub to another. The residual case (removing the highest-port host, then adding a host while an old tab lives) is accepted.
- **D5 — Remote Host/Origin rule: any loopback port, same-origin.** `Host` must be `127.0.0.1:<port>`; `Origin` must equal `http://` + Host. DNS rebinding still fails (IP literal), and other loopback origins still fail (Origin ≠ Host). Rejected: binding a code to a forwarded port (stateful, breaks reloads after the remote hub restarts).
- **D6 — Header status line** instead of state inside the dropdown: approval happens in another tab and must stay visible.
- **D7 — `installHub` installs only.** `reinstallDaemon` would replace a daemon of another version and kill terminals. `HostCoordinator` becomes a FIFO queue of runs with explicit sharing (Codex finding 5): install shares a running or queued install or reinstall; restart shares a running or queued restart or reinstall; reinstall shares a reinstall; everything else waits its turn.
- **D8 — Frozen cross-version shapes.** The print-code line and both fragments pass between `wtd` installations of any versions, so they go in `frozen.golden.json` rather than under a link version.
- **D9 — Browser-only hub error codes** (Codex finding 4). `errorCode` is shared with the daemon link, so `hub-outdated` and `hub-failed` go in a `hubErrorCode` used only by the hub-to-browser `error`; the daemon schema is unchanged.
- **D10 — Element placement (no new arrows).**
- `links`: `HubOpener` runs print-code over SSH, with a live stderr scan, a 30 s budget suspended by a login URL, a 4 KiB stdout cap and kill on cancel. `forward(alias, remotePort, socket)` spawns `ssh -W` and pipes both ways.
- `server`: `TunnelListeners`, one per port. Its mutations are serialised; it binds, repoints, retires and closes.
- `router`: `openHub` / `installHub`. It declares a `HubTunnels` interface (`ensure(host, port, remotePort)`, `probe(port)`) which `main` implements from `server` and `links`.
- `main`: owns the latest-config view the listeners use to authorise each accepted connection.
- **D11 — Fresh config per open** (Codex finding 3). `openHub` and `installHub` resolve the host by its snapshot name against a fresh read of `config.json`; the latest valid read also refreshes the listeners' authorisation map, so a connection to a removed or remapped host is closed without SSH.
- **D12 — One opening per session** (Codex finding 6). A new `openHub` cancels the previous one: its SSH process is killed, and a cancellation token is checked after every await, so an ended opening never binds or replies. This also keeps the 16-code `CodeStore` on the remote safe from piling up.
- **D13 — Probe before navigating.** `GET /` through the new listener must return 200 within 10 s. This catches a refused forward (`AllowTcpForwarding no`, Tailscale ACLs) on the home page, where its stderr line can be shown.
- **D14 — Outdated detection by exit status.** 2 (usage error: no `--print-code`) or 127 (no `wtd`), or exit 0 with output that does not decode, means outdated. ssh's own failures exit 255 and are reported as `hub-failed` with the last stderr line.

## Risks / Trade-offs

- [`ssh -W` per connection costs one mux client per HTTP or WS connection] → Chrome keeps a handful per page; each is cheap over the master.
- [The control master is gone and Tailscale's check blocks a later `-W`] → that connection stalls until approval; the page's normal reconnect state shows. Reopening from home surfaces the login link.
- [Fake SSH can't prove real OpenSSH mux behaviour] → the fake learns `-W` for the default tiers, and an opt-in `test/integration` real-SSH test covers an existing and an absent master, a refused forward, loss of the master and child cleanup (Codex finding 7).
- [A hand-edited `port` on a live listener] → the next open rebinds on the new port and retires the old listener and its forwards.
- [A remote hub of another version is replaced by `--print-code`, closing other viewers' sessions there] → the same as `wtd ui` today; accepted.

## Migration Plan

Browser protocol 9 → 10 only: an open page reloads once under the stale-bundle rule. The daemon is untouched. `config.json` gains `port` entries lazily. Older remote `wtd` installs are detected and offered "Reinstall on <host>".
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## Why

From the laptop, the page shows remote hosts' repos only as they are listed in the laptop's own `config.json`; there is no way to see a remote host as if sitting at it — its own hub, repos, worktrees and terminals — without copying its repo lists (DEV-2107). A manual `ssh -L` fails the hub's Host check and has no way to deliver a one-time code.

## What Changes

- New remote command `wtd ui --print-code`: makes a hub of this version serve and prints `{"port","code"}` instead of opening a browser. Its output shape is frozen.
- `wtd ui <host>` opens the home page in a new window and switches it to that host's hub.
- A "Hubs" menu in the page header lists "This machine" and every configured remote host; picking one navigates the **current tab** to that hub (agreed change from the issue's separate window). A remote page's menu routes back through the home page.
- The local hub opens a remote hub on request (`openHub`): runs `wtd ui --print-code` over the host's SSH control master, surfaces a Tailscale check login link while waiting, binds a stable loopback port for the host and forwards each connection to the remote hub with `ssh -W`, probes the forward, and returns the URL to navigate to.
- Each `hosts` entry gains an optional `port`, assigned once by the hub and written into `config.json`; it keeps the host's origin, and so its `localStorage` preferences, stable.
- A header status line shows opening progress, the login link, and errors; "Reinstall on <host>" installs a newer `wtd` without restarting the daemon (`installHub`), then reopens.
- The hub accepts `Host` `127.0.0.1:<any port>` with a same-origin `Origin`, so a forwarded port reaches it; token and code requirements are unchanged.
- **BREAKING** (browser link only): `BROWSER_PROTOCOL_VERSION` 9 → 10 for `openHub`, `installHub`, `hubLogin`, `hubOpened` and the hub error codes `hub-outdated` and `hub-failed`; the daemon link is unchanged.

## Capabilities

### New Capabilities

_None._

### Modified Capabilities

- `cli`: `wtd ui [<host>] [--print-code]`; usage text; version output names browser protocol 10.
- `hub`: Host/Origin rule; per-host `port` in config and its assignment; opening a remote hub; tunnel listener and forwards; install-only operation.
- `protocol`: browser protocol 10 messages and hub error codes; frozen `--print-code` output and page fragment shapes.
- `web`: Hubs menu, status line, fragment handling, remote page title.

## Impact

- Code: `src/protocol` (messages, values, frozen and wire goldens), `src/cli/run.ts`, `src/hub/main` (`openUi`, print-code, wiring), `src/hub/server` (Host/Origin rule, tunnel listener), `src/hub/links` (print-code runner, `ssh -W` forward), `src/hub/router` (openHub, installHub, coordinator queue), `src/hub/config` (`port` schema, assignment edit), `src/web/client` (fragment), `src/web/ui` (menu, status line, title).
- Architecture: hub.arc42 principle 3 and Purpose (approved wording); `node:net` allowed in `hub.server`; no new model arrows.
- Tests: unit, process (fake SSH learns `-W`), e2e, opt-in real-SSH integration test.
- Docs: README.
- Depends on DEV-2106 (merged into this branch).
Loading
Loading