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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ With `--systemd` it also writes `~/.config/systemd/user/worktree-term-daemon.ser

## Configuration

The hub reads `$XDG_CONFIG_HOME/worktree-term/config.json` (by default `~/.config/worktree-term/config.json`); every key is optional, and a new browser session picks up edits. `presets` lists the terminals a new tab or split can start, in order: `command` runs as `$SHELL -l -i -c <command>`, and `null` starts the plain login shell. Without `presets`, the only preset is a plain `shell`.
The hub reads `$XDG_CONFIG_HOME/worktree-term/config.json` (by default `~/.config/worktree-term/config.json`); every key is optional, and a new browser session picks up edits. `presets` lists the terminals a new tab or split can start, in order: `command` runs as `$SHELL -l -i -c <command>`, and `null` starts the plain login shell. Without `presets`, they are `shell` (the login shell), `claude` and `codex`. "+ Add preset" and the × beside each preset in the picker edit `presets` here, starting from those defaults; every open page shows the change.

`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.

Expand Down Expand Up @@ -49,7 +49,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 daemon speaks an older protocol, 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. 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 Expand Up @@ -80,6 +80,7 @@ Requires Node 22 (`.nvmrc`), pnpm, and the `la-*` architecture tools on `PATH` (
```sh
pnpm install
pnpm build # dist/wtd.mjs and dist/web
pnpm dev # the page through Vite with hot reload, at http://127.0.0.1:5173/login; needs a running hub
pnpm test # unit, process, architecture and e2e tiers
pnpm test:integration # opt-in real-SSH tests
pnpm lint # tsc -b and ESLint
Expand Down
2 changes: 1 addition & 1 deletion architecture/daemon.arc42.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ flowchart TD

1. `worktrees`, `terminals` and `state` never import each other; only `server` and `main` compose them. [enforced: arch_check:model-truth]
2. Its only listener is its owner-only unix socket. [enforced: test:test/process/daemon-server.test.ts]
3. A PTY ends only by `closeTerm`, process exit or `shutdown`, never by a client leaving. [enforced: test:test/process/daemon-terminals.test.ts]
3. A PTY ends only by `closeTerm`, `removeWorktree` of its worktree, process exit or `shutdown`, never by a client leaving. [enforced: test:test/process/daemon-terminals.test.ts] [enforced: test:test/process/daemon-worktrees.test.ts]
4. PTY output is never dropped; backpressure pauses the PTY. [enforced: test:src/daemon/terminals/flow.test.ts] [enforced: test:test/process/daemon-terminals.test.ts]
5. An attach yields a snapshot and then live output with no gap and no duplicate byte. [enforced: test:test/process/daemon-terminals.test.ts]
6. Worktree changes are observed by events, never by polling. [review]
Expand Down
6 changes: 3 additions & 3 deletions architecture/protocol.arc42.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Purpose & context

The wire contract between web, hub and daemons: message schemas, frame codecs, `PROTOCOL_VERSION`. Leaf node, used by all others.
The wire contract between web, hub and daemons: message schemas, frame codecs, and one version per link: `DAEMON_PROTOCOL_VERSION` (client ↔ daemon) and `BROWSER_PROTOCOL_VERSION` (browser ↔ hub). Leaf node, used by all others.

## Building blocks

Expand All @@ -13,8 +13,8 @@ Leaf; see view `system`.
1. Pure: no I/O, no Node or DOM APIs; imports only `zod`. [enforced: test:test/architecture/dependencies.test.ts]
2. Every wire type is a schema; static types are inferred from it, never written separately. [review]
3. Decoders reject malformed or unknown input; they never coerce or repair. [enforced: test:src/protocol/messages.test.ts]
4. Any wire change bumps `PROTOCOL_VERSION`; `hello`, `shutdown` and the frame header never change. [enforced: test:src/protocol/wire.golden.test.ts]
4. A wire change bumps the version of the link it touches, both for a shared constant; `hello`, `shutdown` and the frame header never change. [enforced: test:src/protocol/wire.golden.test.ts]

## Rationale

One contract shared by browser and Node code removes hand-kept agreement between ends. Frozen `hello` and `shutdown` let any hub detect, and restart, a daemon of any other version.
One contract shared by browser and Node code removes hand-kept agreement between ends. Frozen `hello` and `shutdown` let any hub detect, and restart, a daemon of any other version. The hub serves the page it talks to, so the browser link can change without a daemon restart; a separate version keeps such changes from marking daemons outdated.
11 changes: 11 additions & 0 deletions openspec/changes/archive/2026-10-08-focused-page-size/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## Context

Each page fits its shown terminals to their panes and, today, sends `resize` whenever its own fitted size changes. The daemon gives the PTY the last size it receives. Principles applied: web 2, 4, 7.

## Decisions

### D1 The page in focus owns the sizes
`document.hasFocus()` decides whether a fit is sent: a page in the background fits its xterm only. On `focus` the page forgets what it last sent and sends the size of every terminal it shows; the same happens for a host's terminals when its link connects again, since a size sent while disconnected was lost. Per terminal, the page remembers the size it sent since gaining the focus, so refits that change nothing send nothing. Alternative — the daemon picking the smallest size of all attached pages (as tmux does by default) — rejected: the window in use would get a narrower terminal than it has room for.

### D2 No page in focus
When no page has the focus (another application is in front), sizes stay as the last focused page set them.
21 changes: 21 additions & 0 deletions openspec/changes/archive/2026-10-08-focused-page-size/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## Why

With the UI open in several windows, a terminal's PTY takes the size from whichever window fitted it last, even one in the background, so the window being used shows lines wrapped for another window's width.

## What Changes

- Web: only the page that has the focus sends `resize`; a page in the background still fits its own terminals but leaves the PTYs alone.
- Web: a page that gains the focus, or reconnects, sends the sizes of the terminals it shows, taking them over from the page that set them last.

## Capabilities

### New Capabilities

### Modified Capabilities
- `web`: worktree switching sends `resize` only from the page in focus; terminal size with several pages.

## Impact

- Code: `src/web/terminals/manager.ts`.
- Architecture, wire and dependencies: no change; no restart beyond reloading the page.
- Tests: `test/e2e/terminals.spec.ts` (two pages with stubbed focus, PTY size read with `stty size`; fails without the change).
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## MODIFIED Requirements

### Requirement: Worktree switching
Selecting a worktree SHALL show every pane of its active tab and hide the previous ones without waiting for any reply, fit each shown terminal to its pane, send `setVisible` with the host's shown terminals, and, while the page has the focus, send `resize` for each shown terminal whose size differs from the last one the page sent. It SHALL send no request that has a `req`. Resizing the window SHALL fit the shown terminals and, while the page has the focus, resize them.

#### Scenario: Switch sends no request
- **WHEN** the user switches between two worktrees whose terminals were already viewed
- **THEN** the page sends only `setVisible` and `resize` messages

#### Scenario: Split tab shown whole
- **WHEN** the user switches to a worktree whose active tab holds three panes
- **THEN** all three terminals are shown, each fitted to its pane, and `setVisible` lists all three

## ADDED Requirements

### Requirement: Terminal size with several pages
Only a page that has the focus SHALL send `resize`. A page that gains the focus SHALL send `resize` with the size of every terminal it shows, and so SHALL a page for the shown terminals of a host whose link connects again; afterwards it SHALL send a terminal's size only when it differs from the last one it sent. A page without the focus SHALL fit its terminals to its own panes without sending `resize`.

#### Scenario: Background page leaves the size alone
- **WHEN** a second page opens in the background with a smaller window and shows a terminal of the focused page
- **THEN** the PTY keeps the focused page's size

#### Scenario: Focus takes the size over
- **WHEN** the second page gains the focus
- **THEN** the PTY takes the second page's size, and resizing the first page's window while it is in the background does not change it

#### Scenario: Refocus takes it back
- **WHEN** the first page gains the focus again
- **THEN** the PTY takes the first page's current size
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## 1. Web

- [x] 1.1 `TerminalManager`: send `resize` only while the page has the focus, once per size per terminal; on `focus` and on a host reconnecting, send the shown terminals' sizes (design D1)
- [x] 1.2 e2e: two pages with stubbed focus; a background page opening and resizing leaves the PTY size alone, the focused page takes it over, refocusing takes it back; verify it fails without 1.1

## 2. Final gates

- [x] 2.1 `pnpm test` green (incl. Playwright); `pnpm lint`, `la-typecheck`, `la-arch-check` exit 0; `openspec validate focused-page-size --strict` passes
14 changes: 14 additions & 0 deletions openspec/changes/archive/2026-10-08-preset-order/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## Context

Builds on the unarchived changes `sidebar-presets-browser-protocol` (preset edits, two link versions) and `worktree-status-and-deletion` (versions 6 and 7); their MODIFIED and ADDED text is the base here. Principles applied: protocol 3, 4; hub 5; web 2, 7.

## Decisions

### D1 Move one preset by name
`movePreset{name, to}` names the preset and its new position rather than sending the whole order: an add or remove from another page at the same time is kept, and a stale page cannot reorder presets it never saw. `to` beyond the end means last; an unknown name or a move in place changes nothing and sends no `presets`. Edits stay serialized in the configuration editor.

### D2 Native drag and drop, plus keys
Rows use HTML drag and drop; the gap under the pointer (upper or lower half of a row) is the drop slot, shown as an accent line, and `dropPosition` turns slot and origin into the target position. Alt+Up/Down moves the highlighted preset and keeps it highlighted; the list refocuses it once the new order arrives, since rows are keyed by name and the focus would otherwise stay on whichever preset now holds the old position. Until that order arrives (or the move fails) further keyboard moves are ignored: the list still shows the old order, so a second move would pick the wrong preset.

### D3 Browser version only
Only the browser-link catalogue changes, so only `BROWSER_PROTOCOL_VERSION` rises (to 8): the reason for the version split.
29 changes: 29 additions & 0 deletions openspec/changes/archive/2026-10-08-preset-order/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## Why

Presets can be added and removed from the page, but their order — which decides the digit that picks each one — can only be changed by editing `config.json`.

## What Changes

- **BREAKING** (browser wire only): `BROWSER_PROTOCOL_VERSION` 8 — browser-to-hub `movePreset{req, name, to}`. `DAEMON_PROTOCOL_VERSION` stays 6: replacing the hub needs no daemon restart.
- `hub.config` / `hub.router`: a preset edit that moves the named preset to position `to` (the last position when beyond it), answered and propagated like the other preset edits.
- Web: preset rows in the picker and in a worktree's choices can be dragged to a new position, with a drop marker; Alt+Up/Down moves the highlighted preset, which stays highlighted.
- CLI: the version line shows browser protocol 8.

## Capabilities

### New Capabilities

### Modified Capabilities
- `protocol`: browser version 8; `movePreset` in the browser-link catalogue; constants.
- `hub`: moving presets.
- `web`: reordering presets.
- `cli`: version line.

## Impact

- Code: `src/protocol/**`, `src/hub/config/editor.ts`, `src/hub/router/{index,session}.ts`, `src/web/client/connection.ts`, `src/web/ui/{app.tsx,picker.ts,view.ts}`, `src/web/main/styles.css`.
- Architecture: no change.
- Wire: `wire.golden.json` re-blessed for `{daemon: 6, browser: 8}`; daemon-link schemas unchanged (checked by the guard).
- Upgrade: restart the hub only.
- Tests: protocol, editor and `dropPosition` unit tests; `test/process/hub-router.test.ts`; `test/e2e/picker.spec.ts` (drag in the choices, Alt+Up in the picker).
- Builds on the changes `sidebar-presets-browser-protocol` and `worktree-status-and-deletion`; archive them first.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## MODIFIED Requirements

### Requirement: Version output
`wtd --version` SHALL print `wtd <package version> (daemon protocol <DAEMON_PROTOCOL_VERSION>, browser protocol <BROWSER_PROTOCOL_VERSION>)` and a newline to stdout and exit 0.

#### Scenario: Version
- **WHEN** the user runs `wtd --version`
- **THEN** stdout is `wtd <package version> (daemon protocol 6, browser protocol 8)` followed by a newline, stderr is empty, and the exit code is 0
32 changes: 32 additions & 0 deletions openspec/changes/archive/2026-10-08-preset-order/specs/hub/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## MODIFIED Requirements

### Requirement: Editing presets
`addPreset{req, preset}` SHALL append the preset to the presets, `removePreset{req, name}` SHALL remove the preset of exactly that name and `movePreset{req, name, to}` SHALL move the preset of exactly that name to position `to`, or to the last position when `to` is beyond it, each as a configuration edit. The requester SHALL receive `done`, and only then every open session `presets`. `addPreset` SHALL be refused with `error{req, host: null, code: internal}` naming the reason when a preset of that name exists or there would be more than 64 presets; `removePreset` SHALL be refused that way when it would remove the last preset. A `removePreset` or `movePreset` naming no preset, and a `movePreset` to the preset's own position, SHALL answer `done`, leave the file unchanged and send no `presets`.

#### Scenario: Preset added
- **WHEN** a session adds the preset `htop` (command `htop`) while `config.json` lists `shell` and two sessions are open
- **THEN** the requester receives `done`, both sessions then receive `presets` listing `shell` and `htop`, and `config.json` lists both

#### Scenario: Preset removed from the defaults
- **WHEN** `config.json` has no `presets` and a session removes `claude`
- **THEN** `config.json` lists `shell` and `codex`, and every open session receives those presets

#### Scenario: Taken name refused
- **WHEN** a session adds a preset named `shell` while `shell` exists
- **THEN** it receives `error` with code `internal` saying a preset named `shell` exists, and the file is unchanged

#### Scenario: Last preset kept
- **WHEN** `config.json` lists only `shell` and a session removes it
- **THEN** it receives `error` with code `internal` saying the last preset cannot be removed, and the file is unchanged

#### Scenario: Unknown name
- **WHEN** a session removes a preset named `nope` that does not exist
- **THEN** it receives `done`, no session receives `presets`, and the file is unchanged

#### Scenario: Preset moved
- **WHEN** `config.json` lists `shell`, `claude` and `htop`, two sessions are open and one moves `htop` to position 0
- **THEN** the requester receives `done`, both sessions then receive `presets` listing `htop`, `shell` and `claude`, and `config.json` lists them in that order

#### Scenario: Move beyond the end
- **WHEN** a session moves the first of three presets to position 63
- **THEN** it becomes the last preset
Loading
Loading