From 62ae710a07eef7e1ce5f1ca5e7d80a55ec61a38c Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Fri, 23 Oct 2026 15:16:11 +0200 Subject: [PATCH 01/11] Add wtd-dev-restart and frame the confirmation dialog in the active tab's blue --- README.md | 21 ++++++++ scripts/wtd-dev-restart | 113 +++++++++++++++++++++++++++++++++++++++ src/web/main/styles.css | 13 +++++ test/e2e/sidebar.spec.ts | 3 ++ 4 files changed, 150 insertions(+) create mode 100755 scripts/wtd-dev-restart diff --git a/README.md b/README.md index 55d6445..9940f87 100644 --- a/README.md +++ b/README.md @@ -86,3 +86,24 @@ pnpm test:integration # opt-in real-SSH tests pnpm lint # tsc -b and ESLint pnpm format # Prettier ``` + +`scripts/wtd-dev-restart` runs the app from the checkout: it rebuilds, restarts the hub and the daemon, starts Vite and opens the page in a Chrome app window. Restarting the daemon kills its terminals; `--keep-daemon` restarts only the hub and Vite. To run it from anywhere, link it onto `PATH`: + +```sh +ln -s "$PWD/scripts/wtd-dev-restart" ~/.local/bin/wtd-dev-restart +``` + +The dev window gets its own taskbar icon only from a launcher that claims its window class. Chrome names an app window's class after the opened URL's host and path, without the port: `wtd ui` opens `/`, so its window is `chrome-127.0.0.1__-Default`, the class the installed launcher claims, while the script opens `/login`, so its window is `chrome-127.0.0.1__login-Default`. Without a launcher claiming that class, the window is grouped under Chrome. Save this as `~/.local/share/applications/worktree-term-dev.desktop`, with your checkout's path: + +```ini +[Desktop Entry] +Type=Application +Name=worktree-term (dev) +Exec=/home/you/.local/bin/wtd-dev-restart --keep-daemon +Icon=/path/to/worktree-term/src/web/public/icon.svg +Terminal=false +StartupWMClass=chrome-127.0.0.1__login-Default +Categories=Development; +``` + +The window opened next gets the icon, which can then be pinned; clicking it when no window is open runs the script and keeps the terminals. diff --git a/scripts/wtd-dev-restart b/scripts/wtd-dev-restart new file mode 100755 index 0000000..b88854f --- /dev/null +++ b/scripts/wtd-dev-restart @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Restarts worktree-term from its checkout for development: rebuilds, restarts the hub and the daemon, +# serves the page through Vite with hot reload, and opens it. Restarting the daemon kills its terminals. +set -euo pipefail + +# The checkout this script lives in, also when run through a symlink. +REPO="${WTD_REPO:-$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")}" +STATE="${XDG_STATE_HOME:-$HOME/.local/state}/worktree-term" +VITE_PORT="${WTD_VITE_PORT:-5173}" +BROWSER="${WTD_BROWSER:-google-chrome}" + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac +done + +log() { printf 'wtd-dev-restart: %s\n' "$*"; } + +# Terminals started under this hub must not look like children of a Claude Code session. +for var in $(compgen -e); do + case $var in CLAUDE*) unset "$var" ;; esac +done + +# SIGTERMs pid $1 (named $2) and waits up to 10 s for it to exit. +stop_pid() { + local pid=$1 what=$2 + kill -0 "$pid" 2>/dev/null || return 0 + log "stopping the $what (pid $pid)" + kill -TERM "$pid" + for _ in $(seq 100); do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 0.1 + done + echo "wtd-dev-restart: the $what (pid $pid) did not stop" >&2 + exit 1 +} + +# Waits up to $2 tenths of a second for something to listen on TCP port $1. +wait_port() { + for _ in $(seq "$2"); do + ss -Hltn "sport = :$1" | grep -q . && return 0 + sleep 0.1 + done + return 1 +} + +hub_pid() { [[ -f $STATE/run/hub.json ]] && sed -n 's/.*"pid":\([0-9]*\).*/\1/p' "$STATE/run/hub.json" || true; } +hub_port() { [[ -f $STATE/run/hub.json ]] && sed -n 's/.*"port":\([0-9]*\).*/\1/p' "$STATE/run/hub.json" || true; } + +cd "$REPO" +mkdir -p "$STATE" +log "building (log: $STATE/build.log)" +if ! pnpm build >"$STATE/build.log" 2>&1; then + cat "$STATE/build.log" >&2 + exit 1 +fi + +old_hub=$(hub_pid) +[[ -n $old_hub ]] && stop_pid "$old_hub" hub + +if ((!keep_daemon)); then + # Daemons of this user's state directory, not the test daemons under /tmp. + for pid in $(ss -Hxlp | grep -F "$STATE/run/" | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u || true); do + stop_pid "$pid" daemon + done +fi + +vite_pid=$(ss -Hltnp "sport = :$VITE_PORT" | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2 || true) +[[ -n $vite_pid ]] && stop_pid "$vite_pid" "Vite dev server" + +log "starting the hub (log: $STATE/hub.log)" +setsid node dist/wtd.mjs hub >>"$STATE/hub.log" 2>&1 /dev/null && wait_port "$(hub_port)" 1; then break; fi + sleep 0.1 +done +pid=$(hub_pid) +if [[ -z $pid || $pid == "$old_hub" ]]; then + echo "wtd-dev-restart: the hub did not start; see $STATE/hub.log" >&2 + exit 1 +fi + +log "starting Vite (log: $STATE/vite.log)" +setsid pnpm dev >"$STATE/vite.log" 2>&1 &2 + exit 1 +fi + +url="http://127.0.0.1:$VITE_PORT/login" +if ((open_browser)); then + setsid "$BROWSER" --app="$url" >/dev/null 2>&1 { `Running terminals will be closed: probe ${String(ticker)}.`, ]); await expect(dialog.locator(byTestId(TID.confirmNote))).toHaveText('Its branch is kept.'); + const activeBlue = await page.locator(`${byTestId(TID.repoTab)}.active`).evaluate((tab) => getComputedStyle(tab).backgroundColor); + await expect(dialog).toHaveCSS('border-top-color', activeBlue); + await expect(dialog).toHaveCSS('border-top-width', '2px'); await dialog.locator(byTestId(TID.confirmCancel)).click(); await expect(dialog).toHaveCount(0); expect(existsSync(feat)).toBe(true); From 3060414ecfc34b6b0109f711ced05faa871f2b9c Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Fri, 23 Oct 2026 15:29:46 +0200 Subject: [PATCH 02/11] Drop the blue confirmation frame that the red deletion frame superseded --- src/web/main/styles.css | 13 ------------- test/e2e/sidebar.spec.ts | 3 --- 2 files changed, 16 deletions(-) diff --git a/src/web/main/styles.css b/src/web/main/styles.css index 9a05b60..4cab22b 100644 --- a/src/web/main/styles.css +++ b/src/web/main/styles.css @@ -557,19 +557,6 @@ dialog.add-repo-dialog { color: var(--muted); } -/* A confirmation asks before something is lost, so it stands out in the active tabs' blue. */ -.confirm-dialog { - border: 2px solid var(--accent); - box-shadow: - 0 0 0 4px rgb(76 141 255 / 25%), - 0 12px 36px rgb(0 0 0 / 70%); -} - -.confirm-dialog .title { - color: var(--text); - font-weight: 600; -} - .preset-list { display: flex; flex-direction: column; diff --git a/test/e2e/sidebar.spec.ts b/test/e2e/sidebar.spec.ts index 1743e12..bc9b246 100644 --- a/test/e2e/sidebar.spec.ts +++ b/test/e2e/sidebar.spec.ts @@ -459,9 +459,6 @@ test.describe('worktree status and deletion', () => { `Running terminals will be closed: probe ${String(ticker)}.`, ]); await expect(dialog.locator(byTestId(TID.confirmNote))).toHaveText('Its branch is kept.'); - const activeBlue = await page.locator(`${byTestId(TID.repoTab)}.active`).evaluate((tab) => getComputedStyle(tab).backgroundColor); - await expect(dialog).toHaveCSS('border-top-color', activeBlue); - await expect(dialog).toHaveCSS('border-top-width', '2px'); await dialog.locator(byTestId(TID.confirmCancel)).click(); await expect(dialog).toHaveCount(0); expect(existsSync(feat)).toBe(true); From 5d3079274834f28653d9e553b01f0c059b76d457 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sat, 24 Oct 2026 13:10:06 +0200 Subject: [PATCH 03/11] Plan: a terminal whose process exits cleanly closes its tab --- .../.openspec.yaml | 2 + .../design.md | 47 ++++++ .../proposal.md | 31 ++++ .../specs/daemon/spec.md | 142 ++++++++++++++++++ .../specs/hub/spec.md | 24 +++ .../specs/protocol/spec.md | 28 ++++ .../specs/web/spec.md | 89 +++++++++++ .../tasks.md | 27 ++++ 8 files changed, 390 insertions(+) create mode 100644 openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/.openspec.yaml create mode 100644 openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/design.md create mode 100644 openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/proposal.md create mode 100644 openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/daemon/spec.md create mode 100644 openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/hub/spec.md create mode 100644 openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/protocol/spec.md create mode 100644 openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/web/spec.md create mode 100644 openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/.openspec.yaml b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/design.md b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/design.md new file mode 100644 index 0000000..509da5f --- /dev/null +++ b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/design.md @@ -0,0 +1,47 @@ +## Context + +See proposal.md for motivation. In `src/daemon/server/daemon.ts`, "ending a terminal" is written out three times by hand: `closeTerm` (registry removal, layout prune and write, `termClosed`, state prune), `removeWorktree` (registry removal, `termClosed`, state prune — no layout prune) and `shutdown` (registry removal only, via `closeTerminal`). `TerminalProcess` (`src/daemon/terminals/terminal.ts`) reports an exit through one `exited(code, signal)` event after the mirror has parsed every output byte, and sets `unseen` on any exit while hidden. + +Architecture principles that apply: daemon 1 (only `server` composes `terminals` and `state`), 3 (a PTY ends only by `closeTerm`, `removeWorktree`, process exit or `shutdown` — a clean exit is a process exit, so no arc42 edit), 4 and 5 (output is never dropped; the last byte precedes the end event); web 3 and 4 (the terminal object is disposed only when its terminal closes); protocol 4 (version bumps); system 6 (errors are reported) and 8 (tests in every tier). No model, import arrow or `index.yaml` change. + +## Goals / Non-Goals + +**Goals:** +- One daemon code path ends a terminal, whatever the cause; the clean exit is its fourth caller, not a fourth copy. +- After this change a daemon never keeps a cleanly exited terminal, and the web relies on that. + +**Non-Goals:** +- Telling the user that a hidden terminal exited cleanly (it simply disappears). +- Any change to the red worktree-deletion dialog border. + +## Decisions + +### D1 — One `retire` path in `daemon/server` +`retire(terms)` takes a set of terminals: removes them from the registry, ends each process (`TerminalProcess.close()`, which only disposes the mirror once reaped), then, once per affected worktree, prunes the layout against the live registry, persists it and broadcasts `layoutChanged` if it changed, then broadcasts `termClosed` for each terminal and prunes the repo's state. A failed layout write is reported via `options.report` and `termClosed` still follows; the caller learns of the failure (so `closeTerm` can still answer `internal`). `closeTerm` retires one terminal, `removeWorktree` retires the worktree's terminals in one call (one layout write, emptying the layout — Codex suggested skipping the layout for removal; rejected, since the write only empties a layout the state prune drops anyway, and a skip mode would reintroduce two paths), and the clean exit retires one. `shutdown` keeps only the process-ending part, since it ends every connection. +- Alternative: a fourth hand-written copy for the clean exit — rejected (the bug class this structure invites is the copies drifting, as `removeWorktree` already has on the layout). + +### D2 — Clean vs failed is decided in the daemon, `unseen` in the terminal +`TerminalProcess` keeps its single `exited(code, signal)` event; `createTerm`'s handler retires the terminal when `code === 0 && signal === null` and broadcasts `termExited` otherwise. `TerminalProcess.onExit` sets `unseen` only for a failed exit. Output before a clean exit still sets `unseen` as any output does (Codex finding; the spec scenario is narrowed to "without further output" rather than buffering activity until the exit status is known). + +### D3 — No exit before registration +`exited` cannot fire before `createTerm` assigns the id and registers the entry: `TerminalProcess.spawn` builds the PTY and the object synchronously after its last await, the continuation in `createTerm` runs as a microtask before any PTY event, and `exited` is further deferred behind a mirror write callback. (Codex raised this; rejected as impossible, recorded here because `retire` relies on it.) + +### D4 — `DAEMON_PROTOCOL_VERSION` 6 → 7, no schema tightening +No message shape changes, but hub and web now rely on "an exited terminal is a failed one"; raising the version makes a daemon still running old code `outdated`, so the existing restart flow offers the switch instead of the feature silently not applying. The wire golden is re-blessed at 7 (a bump with unchanged schemas is legal). `termExited` and the terminal `exit` schema are NOT tightened to reject `{code: 0, signal: null}`: a decoder rejection closes the whole daemon link, a far worse failure than a mislabelled mark; the invariant is held by D1/D2. `BROWSER_PROTOCOL_VERSION` stays 10: the hub serves the web build it talks to. + +### D5 — Web: one exited mark +`MARK_ORDER` becomes `input, failed, done, output`; `markOf` maps any non-null `exit` to `failed`; the status text is "killed by " or "exited with code N"; `.mark-exited` and the e2e contract's `exited` mark go. The "(exited)" tab and pane-header label stays. + +### D6 — Choices take the focus only when it was left on the body +When the selected worktree's last terminal closes, the disposed xterm (self-close) or the removed close dialog (confirmed close) leaves `document.activeElement` on the body. The choices list focuses its first option on mount only if the terminal set just went from non-empty to empty for the shown worktree and the active element is the body (or null). First entry is unaffected (selecting a worktree leaves the focus on its row) and focus the user put elsewhere (sidebar search) is never taken. Implementation must make the check after the xterm container left the document (the manager disposes it on `termClosed`) — a check before that would see the dying textarea. +- Alternative: "the closed terminal's pane held the focus" — not evaluable once the pane is gone, and false after a confirmed close where the dialog held it (Codex finding). + +## Risks / Trade-offs + +- [A one-shot command preset that succeeds disappears with its output] → Accepted (user decision): presets are interactive programs; a failing command still stays readable. +- [Upgrading `wtd` marks every running daemon `outdated`; restarting ends its terminals] → The existing restart dialog names the terminals and needs confirmation. +- [Disposal and focus ordering in the page is timing-sensitive] → e2e scenarios for self-close, confirmed close and focus held elsewhere. + +## Migration Plan + +Ship as one release. Old daemons show as `outdated` until restarted from the page; rollback is reverting the change (an older hub then sees version-7 daemons as `outdated` and restarts them the same way). diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/proposal.md b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/proposal.md new file mode 100644 index 0000000..19b616a --- /dev/null +++ b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/proposal.md @@ -0,0 +1,31 @@ +## Why + +A terminal whose shell or program exits cleanly (`exit`, Ctrl-D, `/exit` in an agent) stays as a dead "(exited)" tab that the user must close by hand, although leaving was the user's own choice. Only a failed exit carries information worth keeping on screen. + +## What Changes + +- A terminal whose process exits with code 0 and no signal is closed by the daemon itself, whatever its preset: after its last output is offered it leaves its worktree's layout and every watcher receives `termClosed`; no `termExited` is sent. +- A failed exit (non-zero code or a signal) keeps today's behaviour: `termExited`, the tab stays with its failed mark and readable screen until the user closes it. +- The daemon ends terminals through one closing path shared by `closeTerm`, worktree removal and a clean exit. +- A clean exit no longer sets `unseen` of its own. +- `DAEMON_PROTOCOL_VERSION` rises from 6 to 7, so a daemon still running the old behaviour shows as `outdated` and can be restarted from the page. +- The web drops the plain "exited" attention mark: any exit the page is told about is a failure. +- When the shown worktree's last terminal closes and the keyboard focus is left on the page body, the "Start a terminal" choices take the focus. +- The red worktree-deletion dialog border is unchanged. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `daemon`: Terminal lifetime (clean exit closes the terminal), Activity (no `unseen` from a clean exit), Persistence and pruning (pruning after a clean exit). +- `protocol`: Protocol version raised to 7 for the daemon link. +- `hub`: the newer-daemon restart scenario stops naming fixed versions. +- `web`: Terminal tabs (exited tab means a failed exit; clean exit drops the tab; choices take the focus), Pane focus, Closing terminals, Attention indicators (no plain "exited" mark). + +## Impact + +- `src/daemon/server/daemon.ts` (one retire path; clean-exit handling), `src/daemon/terminals/terminal.ts` (unseen only on a failed exit). +- `src/protocol/constants.ts`, `src/protocol/wire.golden.json`, version strings in CLI and install tests. +- `src/web/ui/marks.ts`, `src/web/ui/app.tsx`, `src/web/ui/view.ts`, `src/web/main/styles.css`, `test/e2e/contract.ts`. +- Upgrading `wtd` marks running daemons `outdated`; restarting one ends its terminals, as the existing restart flow states. diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/daemon/spec.md b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/daemon/spec.md new file mode 100644 index 0000000..3d6d863 --- /dev/null +++ b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/daemon/spec.md @@ -0,0 +1,142 @@ +## MODIFIED Requirements + +### Requirement: Terminal lifetime +A terminal SHALL end only by `closeTerm`, by its process exiting, or by `shutdown`; a client disconnecting or unwatching SHALL NOT end it. An exit with code 0 and no signal SHALL be a clean exit; any other exit SHALL be a failed exit. When the process exits, the daemon SHALL first offer every remaining output byte. After a failed exit it SHALL then send `termExited{code, signal}` to the repo's watchers, `signal` being the signal name or null, and the terminal SHALL remain, reporting `exit`, until closed. After a clean exit it SHALL close the terminal itself, without sending `termExited`: it SHALL remove the terminal from its layout and send `termClosed` to the repo's watchers, sending `termClosed` even when the changed layout cannot be saved. `closeTerm` SHALL send `SIGHUP` to the terminal's process group and `SIGKILL` 5 s later if it is still alive, never signalling a process after the terminal's process has been reaped; it SHALL remove the terminal from its layout, send `termClosed` to the repo's watchers, then reply `done`. + +#### Scenario: Client leaves, terminal stays +- **WHEN** the only attached client disconnects and a new client attaches later +- **THEN** the terminal is still running and its output produced meanwhile is in the snapshot + +#### Scenario: Exit after a final burst +- **WHEN** a terminal's process writes 1 MiB and exits immediately with code 1 +- **THEN** an attached client receives all of it before `termExited` + +#### Scenario: Clean exit after a final burst +- **WHEN** a terminal's process writes 1 MiB and exits immediately with code 0 +- **THEN** an attached client receives all of it before `termClosed` + +#### Scenario: Exit code reported +- **WHEN** a terminal's shell runs `exit 3` +- **THEN** watchers receive `termExited` with `code` 3 and `signal` null + +#### Scenario: Killed by a signal stays +- **WHEN** a terminal's shell is killed by `SIGTERM` sent from outside the daemon +- **THEN** watchers receive `termExited` with `signal` "SIGTERM", and a client watching the repo afterwards is told of the terminal with that `exit` + +#### Scenario: Clean exit closes the terminal +- **WHEN** a terminal's shell runs `exit` +- **THEN** watchers receive `termClosed` and no `termExited`, and a client watching the repo afterwards is not told of the terminal + +#### Scenario: Clean exit leaves the layout +- **WHEN** the terminal in one half of a split exits with code 0 +- **THEN** watchers receive `layoutChanged` in which the split is replaced by the other half, and then `termClosed` + +#### Scenario: Clean exit with a layout that cannot be saved +- **WHEN** a terminal in a layout exits with code 0 and writing the changed layout fails +- **THEN** watchers receive `termClosed`, the stored layout is unchanged, and the failure is reported + +#### Scenario: Close kills the process group +- **WHEN** a terminal whose shell started a background child is closed +- **THEN** both processes end and watchers receive `termClosed` + +### Requirement: Activity +A terminal SHALL be visible while at least one connection lists it in its latest `setVisible`; ids of unknown terminals or of repos the connection does not watch SHALL be ignored, and a connection's visibility SHALL be withdrawn when it unwatches the repo or disconnects. Output while not visible SHALL set `unseen`; a failed exit while not visible SHALL set `unseen`, and a clean exit SHALL NOT; becoming visible SHALL clear it. +Each terminal SHALL have an attention `state`, `idle` when created. Times below are when the daemon received the output or input concerned. +- An attention signal SHALL be: a bell character outside any escape sequence; an OSC 9 sequence whose payload does not start with a decimal number followed by `;`; an OSC 777 sequence whose payload starts with `notify;`; an OSC 99 sequence. A bell received within 1 s after an input frame that is not only focus reports SHALL NOT be a signal. +- A signal SHALL set `input`, whether or not the terminal is visible. +- In `working` or `idle`, output SHALL set `working`; `working` SHALL become `idle` once no output has been received for 3 s. +- In `input`, output received more than 1 s after both the last signal and the last input frame consisting only of focus reports SHALL set `working`; other output SHALL leave `input`. +- An input frame SHALL set `working` from `input`, unless the frame consists only of focus reports (`ESC [ I`, `ESC [ O`). Input SHALL be written to the PTY unchanged in every case. +- After the process exits, `state` SHALL no longer change. +Every change of `unseen` or `state` SHALL be sent as `activity{termId, unseen, state}` to the repo's watchers, and terminal entries SHALL carry both. + +#### Scenario: Output while hidden +- **WHEN** a terminal not listed by any `setVisible` produces output +- **THEN** watchers receive `activity` with `unseen` true and `state` "working" + +#### Scenario: Shown clears flags +- **WHEN** a client lists a terminal with `unseen` set in `setVisible` +- **THEN** watchers receive `activity` with `unseen` false + +#### Scenario: Unseen only when no client shows the terminal +- **WHEN** one of two clients lists a terminal in `setVisible` and the terminal produces output +- **THEN** `unseen` stays false + +#### Scenario: Disconnect withdraws visibility +- **WHEN** the only client showing a terminal disconnects and the terminal produces output +- **THEN** the terminal's `unseen` becomes true + +#### Scenario: Exit while hidden +- **WHEN** a terminal not listed by any `setVisible` exits with code 1 without producing further output +- **THEN** watchers receive `activity` with `unseen` true + +#### Scenario: Clean exit while hidden +- **WHEN** a terminal not listed by any `setVisible` exits with code 0 without producing further output +- **THEN** watchers receive `termClosed` and no `activity` with `unseen` true + +#### Scenario: Quiet terminal becomes idle +- **WHEN** a terminal prints a line and then nothing for 3 s +- **THEN** watchers receive `activity` with `state` "working" and then, 3 s after the output, `state` "idle" + +#### Scenario: Bell asks for input +- **WHEN** a terminal's program rings the bell without any input in the previous second +- **THEN** watchers receive `activity` with `state` "input" + +#### Scenario: Desktop notifications ask for input +- **WHEN** a terminal's program writes `ESC ] 9 ; done BEL`, `ESC ] 777 ; notify ; t ; b BEL` or `ESC ] 99 ; ; hi ESC \` +- **THEN** each sets `state` "input" + +#### Scenario: Progress report is not a signal +- **WHEN** a terminal's program writes `ESC ] 9 ; 4 ; 1 ; 50 BEL` +- **THEN** `state` does not become "input" + +#### Scenario: Signal split across output chunks +- **WHEN** an OSC 777 notification arrives in two output chunks split inside its payload +- **THEN** `state` becomes "input" once + +#### Scenario: Bell answering typing is ignored +- **WHEN** a client sends a tab character and the shell rings the bell within 1 s +- **THEN** `state` does not become "input" + +#### Scenario: Typing clears input +- **WHEN** a terminal in `input` receives an input frame holding `y` +- **THEN** its `state` becomes "working" + +#### Scenario: Focus reports do not clear input +- **WHEN** a terminal in `input` receives an input frame holding `ESC [ O ESC [ I` +- **THEN** its `state` stays "input" and the bytes reach the program + +#### Scenario: Reply to a focus report keeps input +- **WHEN** a terminal in `input` receives `ESC [ I` 5 s after the signal and its program writes `ESC ( B SI` at once +- **THEN** its `state` stays "input" + +#### Scenario: Output resuming clears input +- **WHEN** a terminal in `input` produces output 1.5 s after the signal +- **THEN** its `state` becomes "working" + +#### Scenario: Output around the signal keeps input +- **WHEN** a terminal produces output 0.5 s after a signal +- **THEN** its `state` stays "input" + +### Requirement: Persistence and pruning +Checked state and layouts SHALL be stored in `state.json` and survive daemon restarts; entries with default values SHALL NOT be stored. On start, every layout SHALL lose its terminal leaves, because no terminal survives a restart. A state file that cannot be parsed SHALL be renamed to `state.json.corrupt-` and the daemon SHALL start with empty state. Whenever a repo's worktree list is read, and whenever a terminal closes, entries for worktrees of that repo that are no longer listed and have no live terminals SHALL be removed. + +#### Scenario: Checked survives restart +- **WHEN** a worktree is checked, its terminal is placed in a layout, and the daemon restarts +- **THEN** the worktree is still checked and its layout has no tabs + +#### Scenario: Removed worktree pruned +- **WHEN** a checked worktree without terminals is removed with `git worktree remove` +- **THEN** its entry is gone from `state.json` + +#### Scenario: Removed worktree with a terminal kept until close +- **WHEN** a checked worktree with a live terminal is removed, and the terminal is later closed +- **THEN** the entry stays while the terminal lives and is removed when it closes + +#### Scenario: Removed worktree pruned after a clean exit +- **WHEN** a checked worktree with a live terminal is removed with `git worktree remove`, and the terminal's shell then exits with code 0 +- **THEN** its entry is gone from `state.json` + +#### Scenario: Corrupt state file +- **WHEN** `state.json` holds invalid JSON at start +- **THEN** it is renamed to a `state.json.corrupt-` file and the daemon starts with no checked worktrees diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/hub/spec.md b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/hub/spec.md new file mode 100644 index 0000000..8c3d4d9 --- /dev/null +++ b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/hub/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Daemon restart +`restartDaemon{req, host}` for a `connected` or `outdated` host, local or remote, SHALL send the daemon answering on that host the frozen `hello` and `shutdown`, then dial that host — starting a daemon when none serves — until a daemon whose `hello` has another `instance` and the hub's `protocol` answers, and then reply `done`; if that does not happen within 10 s it SHALL reply `error{code: internal}`. Only the frozen `hello` SHALL be decoded from a daemon being restarted. Restart and reinstall requests for one host, from any sessions, SHALL be coordinated: concurrent requests of the same kind SHALL share one run, a restart requested while a reinstall of that host runs SHALL share the reinstall, and any other request SHALL wait until the running one ends. For any other status it SHALL reply `host-unavailable`. + +#### Scenario: Outdated daemon restarted +- **WHEN** host 0 is `outdated` and the browser sends `restartDaemon` +- **THEN** the old daemon exits, host 0 becomes `connected` with a new instance and the hub's protocol, and the browser receives `done` + +#### Scenario: Newer daemon restarted +- **WHEN** host 0 is served by a daemon speaking a newer protocol than the hub's, whose messages after `hello` are not valid messages of the hub's protocol, and the browser sends `restartDaemon` +- **THEN** that daemon exits and host 0 becomes `connected` with a new instance and the hub's protocol + +#### Scenario: Remote daemon restarted +- **WHEN** a remote host is `connected` and the browser sends `restartDaemon` for it +- **THEN** the remote daemon is replaced by a new instance and the browser receives `done` + +#### Scenario: Concurrent restarts start one daemon +- **WHEN** two sessions send `restartDaemon` for host 0 at the same time +- **THEN** exactly one new daemon instance serves and both receive `done` + +#### Scenario: Restart during a reinstall shares it +- **WHEN** a session sends `restartDaemon` for a remote host while another session's `reinstallDaemon` of that host runs +- **THEN** exactly one new daemon instance serves and both receive `done` when the reinstall ends diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/protocol/spec.md b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/protocol/spec.md new file mode 100644 index 0000000..ecf4cbb --- /dev/null +++ b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/protocol/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Protocol version and frozen handshake messages +The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 7, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 10, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. The print-code line and the page fragments, exchanged between `wtd` installations of any versions, SHALL likewise never change shape. + +#### Scenario: Hello round-trips +- **WHEN** a `hello` with `protocol` 1, `version` "0.1.0" and `instance` "a1B2_c3" is encoded and decoded +- **THEN** the decoded value equals the original + +#### Scenario: Hello from another protocol version is still decodable +- **WHEN** a `hello` carrying `protocol` 8 is decoded +- **THEN** decoding succeeds and yields `protocol` 8 + +#### Scenario: Invalid instance rejected +- **WHEN** a `hello` whose `instance` contains a space is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Browser-link change leaves the daemon version +- **WHEN** a message is added to the browser-to-hub catalogue and only `BROWSER_PROTOCOL_VERSION` is raised +- **THEN** the wire golden check passes, and it fails if a daemon-link schema changed as well + +#### Scenario: Shared constant needs both versions +- **WHEN** `MAX_FRAME` changes and only one of the two versions is raised +- **THEN** the wire golden check fails + +#### Scenario: Frozen print-code and fragment shapes +- **WHEN** the print-code line or a page fragment schema accepts or rejects a sample differently from the frozen golden +- **THEN** the frozen golden check fails diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/web/spec.md b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/web/spec.md new file mode 100644 index 0000000..4a17ea1 --- /dev/null +++ b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/web/spec.md @@ -0,0 +1,89 @@ +## MODIFIED Requirements + +### Requirement: Terminal tabs +The terminal area SHALL show the selected worktree's layout tabs, each labelled by its first terminal, with the layout's active tab shown and its tab shown in the accent colour; live terminals of the worktree missing from its layout SHALL be shown as further single-pane tabs. Choosing a tab SHALL show it at once and then store it as active with `setLayout`. The tab bar SHALL offer controls to create a new tab, split the focused pane right and split it down, each naming its keyboard shortcut on hover and each starting a new terminal through the preset picker; the split controls SHALL be disabled when the focused pane's tab cannot be split further, the new-tab control when the layout holds 64 tabs, and all of them while the host is not `connected`, the worktree is prunable or gone, or no presets have been received. A new tab SHALL send `createTerm` with the chosen preset's name and command and the size of the terminal area, then add the terminal as a new active tab with `setLayout`. A terminal SHALL never be created again because a request failed or the session closed. The tab of a terminal whose process failed, and its pane header in a split, SHALL show that it exited and keep its screen readable until closed; a terminal whose process exited cleanly SHALL leave the tabs of every page, as any closed terminal does. A worktree without terminals SHALL show the presets as choices in the terminal area; no terminal SHALL be created without the user choosing one. When the selected worktree's last terminal closes and the keyboard focus is left on the page body, the choices SHALL take the keyboard focus on their first preset; otherwise showing them SHALL NOT move the keyboard focus. + +#### Scenario: New terminal +- **WHEN** the user picks a preset in a worktree without terminals +- **THEN** that preset runs in that worktree's directory, its tab is active, and it is listed in the layout of every page + +#### Scenario: Terminal without a layout tab still shown +- **WHEN** a live terminal of the worktree is missing from its layout +- **THEN** it is shown as a tab + +#### Scenario: Created terminal survives a lost layout write +- **WHEN** the session closes after a terminal is created and before its `setLayout` is answered +- **THEN** after reconnecting the terminal is shown as a tab, and no second terminal was created + +#### Scenario: Exited terminal +- **WHEN** the shell in a terminal runs `exit 1` +- **THEN** its tab shows that it exited and its screen stays readable until it is closed + +#### Scenario: Clean exit closes the tab +- **WHEN** the shell in one of a worktree's two single-pane tabs runs `exit` +- **THEN** its tab disappears from every page showing the worktree and the remaining tab is shown + +#### Scenario: Last terminal leaves the choices +- **WHEN** the user types `exit` in the only terminal of the selected worktree and then presses 1 +- **THEN** the preset choices are shown, and the first preset starts as a new terminal of that worktree + +#### Scenario: Confirmed close leaves the choices focused +- **WHEN** the user closes the only, running, terminal of the selected worktree, confirms, and then presses 1 +- **THEN** the first preset starts as a new terminal of that worktree + +#### Scenario: Choices leave the focus elsewhere alone +- **WHEN** the only terminal of the selected worktree exits cleanly while the keyboard focus is in the worktree search +- **THEN** the preset choices are shown and the search keeps the keyboard focus + +### Requirement: Pane focus +Each tab SHALL have one focused pane, kept per page and not stored: the first pane until the user chooses another, the pane the user clicks, a new pane once created, and, when the focused pane's terminal closes, the first pane of the subtree that took its place. In a split, the focused pane's header SHALL be shown in the accent colour. The focused pane SHALL hold the keyboard focus whenever its tab is shown and no picker or dialog is open. + +#### Scenario: Focus follows a closed pane's sibling +- **WHEN** the focused pane of a two-pane split is closed +- **THEN** the remaining pane is focused and receives typed input + +#### Scenario: Focus follows a cleanly exited pane's sibling +- **WHEN** the user types `exit` in the focused pane of a two-pane split +- **THEN** the remaining pane is focused and receives typed input + +### Requirement: Closing terminals +Each pane SHALL have a close control that closes its terminal, and Ctrl+Shift+W SHALL close the focused pane's terminal; a tab's close control SHALL close every terminal of the tab. When any terminal to be closed has not exited, the page SHALL first show a dialog listing those terminals by preset and id and close nothing until the user confirms with its button or Enter; Escape or cancel SHALL close nothing. After confirmation the page SHALL send `closeTerm` for each target still live, treating `unknown-term` as done. The dialog SHALL close by itself when all its targets are gone. When every target has exited, they SHALL close without a dialog. + +#### Scenario: Running terminal asks first +- **WHEN** the user presses Ctrl+Shift+W on a pane whose shell is running and then Escape +- **THEN** no `closeTerm` is sent and the terminal keeps running + +#### Scenario: Tab close confirmed +- **WHEN** the user closes a tab of two running panes and confirms +- **THEN** both terminals are closed and the tab disappears from every page + +#### Scenario: Exited terminal closes at once +- **WHEN** the user closes a pane whose process has exited with code 1 +- **THEN** it closes without a dialog + +#### Scenario: Mixed tab cancelled closes nothing +- **WHEN** the user closes a tab holding one pane whose process exited with code 1 and one running pane, and cancels +- **THEN** both terminals remain + +#### Scenario: Dialog closes when its terminal exits cleanly +- **WHEN** the close dialog lists a single running terminal and that terminal's shell exits with code 0 +- **THEN** the dialog closes and no `closeTerm` is sent + +### Requirement: Attention indicators +Each terminal tab, and each pane of a split, SHALL show one mark for its terminal, the first that applies of: needs input (`state` `input`); exited with failure (the terminal has exited, which only a failed exit leaves visible); done (`unseen` with `state` `idle`); new output (`unseen` with `state` `working`); none otherwise. A tab holding several panes SHALL show the first-ranked mark of its terminals. Each worktree row and each repo tab SHALL show the first-ranked mark among the terminals of that worktree or of that repo's worktrees, counting an exited terminal only while it is `unseen`, with the number of terminals per mark on hover. Marks SHALL follow `activity`, `termExited` and terminal list changes without user action. + +#### Scenario: Agent waiting in a hidden worktree +- **WHEN** a terminal in a non-selected worktree of a non-selected repo rings the bell +- **THEN** its worktree row and its repo tab show the needs-input mark + +#### Scenario: Viewing clears new output +- **WHEN** a hidden terminal prints output and its worktree is then selected +- **THEN** its new-output mark disappears from the tab, the worktree row and the repo tab + +#### Scenario: Failed exit +- **WHEN** a hidden terminal's process exits with code 1 +- **THEN** its tab shows the failed-exit mark and its worktree row shows it until the terminal is viewed + +#### Scenario: Killed terminal marked failed +- **WHEN** a terminal's shell is killed by a signal sent from outside the daemon +- **THEN** its tab shows the failed-exit mark diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md new file mode 100644 index 0000000..dec81e9 --- /dev/null +++ b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md @@ -0,0 +1,27 @@ +## 1. Tests first (pr-tests) + +- [ ] 1.1 Daemon unit tests in `src/daemon/server/daemon.test.ts`: clean exit retires (layoutChanged then termClosed, no termExited, absent from a later repoState), failed exit and signal exit keep the terminal with termExited, clean exit with a failing layout write still sends termClosed and reports, `removeWorktree` of a worktree with two terminals writes its layout once; verify they fail before implementation +- [ ] 1.2 Terminal unit test in `src/daemon/terminals/terminal.test.ts`: a hidden clean exit without further output reports no unseen activity, a hidden failed exit does; verify they fail before implementation +- [ ] 1.3 Process tests: `test/process/daemon-terminals-lifecycle.test.ts` (clean exit closes, `exit 3` reported, killed by SIGTERM stays, removed worktree pruned after a clean exit), `test/process/daemon-terminals-output.test.ts` (1 MiB burst then code 1 before termExited, code 0 before termClosed), `test/process/daemon-terminals-unseen.test.ts` (Exit while hidden with code 1; Clean exit while hidden); verify the new ones fail before implementation +- [ ] 1.4 Protocol tests: `src/protocol/constants.test.ts` / `wire.golden.test.ts` expect daemon protocol 7, `src/protocol/messages.test.ts` hello sample uses protocol 8; version strings `daemon protocol 7` in `src/cli/run.test.ts`, `test/process/cli.test.ts`, `test/process/install-local.test.ts`, `test/process/daemon-server-startup.test.ts`; hub restart tests stop assuming fixed versions +- [ ] 1.5 Web unit test `src/web/ui/marks.test.ts`: MARK_ORDER `input, failed, done, output`, any exit marks `failed`, status text "exited with code N" / "killed by " +- [ ] 1.6 E2E: `test/e2e/terminals.spec.ts` (Exited terminal uses `exit 1`; Clean exit closes the tab on every page; Last terminal leaves the choices and 1 starts the first preset; Confirmed close leaves the choices focused; Choices leave the focus in the worktree search alone), `test/e2e/splits.spec.ts` or the pane-focus spec (cleanly exited pane's sibling focused and typed into), `test/e2e/closing.spec.ts` (existing `exit` cases become `exit 1`; dialog closes when its terminal exits cleanly), `test/e2e/attention.spec.ts` (no `exited` mark in counts; killed terminal marked failed); update `MARKS` in `test/e2e/contract.ts` + +## 2. Daemon + +- [ ] 2.1 Add `retire(terms)` in `src/daemon/server/daemon.ts` per design D1 and route `closeTerm` and `removeWorktree` through it; `shutdown` keeps only process ending; verify 1.1 and existing daemon tests pass +- [ ] 2.2 Branch the `exited` handler in `createTerm` on clean vs failed (design D2); verify 1.1 and 1.3 pass +- [ ] 2.3 In `TerminalProcess.onExit` set `unseen` only for a failed exit; verify 1.2 and the unseen process tests pass + +## 3. Protocol + +- [ ] 3.1 Raise `DAEMON_PROTOCOL_VERSION` to 7 in `src/protocol/constants.ts` and re-bless `src/protocol/wire.golden.json` with schemas unchanged; verify 1.4 passes + +## 4. Web + +- [ ] 4.1 Drop the plain `exited` mark (`src/web/ui/marks.ts`, `.mark-exited` in `src/web/main/styles.css`); verify 1.5 and the attention e2e pass +- [ ] 4.2 Choices focus rule (design D6) in `src/web/ui/app.tsx` / `src/web/ui/view.ts`; verify the choices e2e scenarios in 1.6 pass + +## 5. Gates + +- [ ] 5.1 Run `pnpm lint`, `tsc -b`, `la-arch-check` and the full `pnpm test`; all green From d5c966ebd3b6de3932e349174b20bbdfc4651d96 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 26 Oct 2026 11:38:59 +0100 Subject: [PATCH 04/11] Migrate the repo to living-architecture format 0.4.0 (la-arch-migrate) --- architecture/index.yaml | 8 ++++---- architecture/model.c4 | 6 ------ architecture/{cli.arc42.md => typescript.cli.arc42.md} | 0 .../{daemon.arc42.md => typescript.daemon.arc42.md} | 0 architecture/{hub.arc42.md => typescript.hub.arc42.md} | 0 .../{platform.arc42.md => typescript.platform.arc42.md} | 0 .../{protocol.arc42.md => typescript.protocol.arc42.md} | 0 architecture/{web.arc42.md => typescript.web.arc42.md} | 0 living-architecture.yaml | 1 + 9 files changed, 5 insertions(+), 10 deletions(-) rename architecture/{cli.arc42.md => typescript.cli.arc42.md} (100%) rename architecture/{daemon.arc42.md => typescript.daemon.arc42.md} (100%) rename architecture/{hub.arc42.md => typescript.hub.arc42.md} (100%) rename architecture/{platform.arc42.md => typescript.platform.arc42.md} (100%) rename architecture/{protocol.arc42.md => typescript.protocol.arc42.md} (100%) rename architecture/{web.arc42.md => typescript.web.arc42.md} (100%) diff --git a/architecture/index.yaml b/architecture/index.yaml index 7e0b414..64fd2d2 100644 --- a/architecture/index.yaml +++ b/architecture/index.yaml @@ -6,7 +6,7 @@ legacy_arrows: {baseline: 0} diagrams: architecture/system.arc42.md: [system] - architecture/platform.arc42.md: [platform] - architecture/daemon.arc42.md: [daemon] - architecture/hub.arc42.md: [hub] - architecture/web.arc42.md: [web] + architecture/typescript.platform.arc42.md: [platform] + architecture/typescript.daemon.arc42.md: [daemon] + architecture/typescript.hub.arc42.md: [hub] + architecture/typescript.web.arc42.md: [web] diff --git a/architecture/model.c4 b/architecture/model.c4 index cb14da0..242e00e 100644 --- a/architecture/model.c4 +++ b/architecture/model.c4 @@ -11,7 +11,6 @@ model { protocol = node 'Protocol' { metadata { package 'src/protocol' - arc42 'architecture/protocol.arc42.md' specs ['protocol'] } } @@ -19,7 +18,6 @@ model { platform = node 'Platform' { metadata { package 'src/platform' - arc42 'architecture/platform.arc42.md' specs ['platform'] } files = component 'Files' @@ -30,7 +28,6 @@ model { daemon = node 'Daemon' { metadata { package 'src/daemon' - arc42 'architecture/daemon.arc42.md' specs ['daemon'] } worktrees = component 'Worktrees' @@ -43,7 +40,6 @@ model { hub = node 'Hub' { metadata { package 'src/hub' - arc42 'architecture/hub.arc42.md' specs ['hub'] } server = component 'Server' @@ -56,7 +52,6 @@ model { cli = node 'CLI' { metadata { package 'src/cli' - arc42 'architecture/cli.arc42.md' specs ['cli'] } } @@ -64,7 +59,6 @@ model { web = node 'Web' { metadata { package 'src/web' - arc42 'architecture/web.arc42.md' specs ['web'] } client = component 'Client' diff --git a/architecture/cli.arc42.md b/architecture/typescript.cli.arc42.md similarity index 100% rename from architecture/cli.arc42.md rename to architecture/typescript.cli.arc42.md diff --git a/architecture/daemon.arc42.md b/architecture/typescript.daemon.arc42.md similarity index 100% rename from architecture/daemon.arc42.md rename to architecture/typescript.daemon.arc42.md diff --git a/architecture/hub.arc42.md b/architecture/typescript.hub.arc42.md similarity index 100% rename from architecture/hub.arc42.md rename to architecture/typescript.hub.arc42.md diff --git a/architecture/platform.arc42.md b/architecture/typescript.platform.arc42.md similarity index 100% rename from architecture/platform.arc42.md rename to architecture/typescript.platform.arc42.md diff --git a/architecture/protocol.arc42.md b/architecture/typescript.protocol.arc42.md similarity index 100% rename from architecture/protocol.arc42.md rename to architecture/typescript.protocol.arc42.md diff --git a/architecture/web.arc42.md b/architecture/typescript.web.arc42.md similarity index 100% rename from architecture/web.arc42.md rename to architecture/typescript.web.arc42.md diff --git a/living-architecture.yaml b/living-architecture.yaml index c942ea1..406c3fc 100644 --- a/living-architecture.yaml +++ b/living-architecture.yaml @@ -1,3 +1,4 @@ +format: 0.4.0 tracker: linear # detected: Linear MCP + egor/dev-NNNN- branches openspec: true # detected: openspec/ architecture: true # detected: architecture/index.yaml From e174e592c2c94b241301adeaa6a4d125850d7695 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 26 Oct 2026 11:39:06 +0100 Subject: [PATCH 05/11] Failing tests: a terminal whose process exits cleanly closes its tab --- .../tasks.md | 12 +- src/cli/run.test.ts | 2 +- src/daemon/server/daemon.test.ts | 106 +++++++++++++++++- src/daemon/terminals/terminal.test.ts | 16 ++- src/protocol/constants.test.ts | 2 +- src/protocol/messages.test.ts | 2 +- src/protocol/wire.golden.test.ts | 4 +- src/web/ui/marks.test.ts | 26 ++--- test/e2e/attention.spec.ts | 27 +++-- test/e2e/closing.spec.ts | 22 +++- test/e2e/contract.ts | 6 +- test/e2e/splits.spec.ts | 19 ++++ test/e2e/terminals.spec.ts | 78 ++++++++++++- test/process/cli.test.ts | 2 +- test/process/connect.test.ts | 2 +- test/process/daemon-server-startup.test.ts | 4 +- .../daemon-terminals-lifecycle.test.ts | 76 ++++++++++--- test/process/daemon-terminals-output.test.ts | 34 +++++- test/process/daemon-terminals-unseen.test.ts | 30 ++++- test/process/daemon-worktrees.test.ts | 2 +- test/process/hub-router-config.test.ts | 2 +- test/process/hub-router-routing.test.ts | 2 +- test/process/install-local.test.ts | 10 +- 23 files changed, 403 insertions(+), 83 deletions(-) diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md index dec81e9..76eea16 100644 --- a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md +++ b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md @@ -1,11 +1,11 @@ ## 1. Tests first (pr-tests) -- [ ] 1.1 Daemon unit tests in `src/daemon/server/daemon.test.ts`: clean exit retires (layoutChanged then termClosed, no termExited, absent from a later repoState), failed exit and signal exit keep the terminal with termExited, clean exit with a failing layout write still sends termClosed and reports, `removeWorktree` of a worktree with two terminals writes its layout once; verify they fail before implementation -- [ ] 1.2 Terminal unit test in `src/daemon/terminals/terminal.test.ts`: a hidden clean exit without further output reports no unseen activity, a hidden failed exit does; verify they fail before implementation -- [ ] 1.3 Process tests: `test/process/daemon-terminals-lifecycle.test.ts` (clean exit closes, `exit 3` reported, killed by SIGTERM stays, removed worktree pruned after a clean exit), `test/process/daemon-terminals-output.test.ts` (1 MiB burst then code 1 before termExited, code 0 before termClosed), `test/process/daemon-terminals-unseen.test.ts` (Exit while hidden with code 1; Clean exit while hidden); verify the new ones fail before implementation -- [ ] 1.4 Protocol tests: `src/protocol/constants.test.ts` / `wire.golden.test.ts` expect daemon protocol 7, `src/protocol/messages.test.ts` hello sample uses protocol 8; version strings `daemon protocol 7` in `src/cli/run.test.ts`, `test/process/cli.test.ts`, `test/process/install-local.test.ts`, `test/process/daemon-server-startup.test.ts`; hub restart tests stop assuming fixed versions -- [ ] 1.5 Web unit test `src/web/ui/marks.test.ts`: MARK_ORDER `input, failed, done, output`, any exit marks `failed`, status text "exited with code N" / "killed by " -- [ ] 1.6 E2E: `test/e2e/terminals.spec.ts` (Exited terminal uses `exit 1`; Clean exit closes the tab on every page; Last terminal leaves the choices and 1 starts the first preset; Confirmed close leaves the choices focused; Choices leave the focus in the worktree search alone), `test/e2e/splits.spec.ts` or the pane-focus spec (cleanly exited pane's sibling focused and typed into), `test/e2e/closing.spec.ts` (existing `exit` cases become `exit 1`; dialog closes when its terminal exits cleanly), `test/e2e/attention.spec.ts` (no `exited` mark in counts; killed terminal marked failed); update `MARKS` in `test/e2e/contract.ts` +- [x] 1.1 Daemon unit tests in `src/daemon/server/daemon.test.ts`: clean exit retires (layoutChanged then termClosed, no termExited, absent from a later repoState), failed exit and signal exit keep the terminal with termExited, clean exit with a failing layout write still sends termClosed and reports, `removeWorktree` of a worktree with two terminals writes its layout once; verify they fail before implementation +- [x] 1.2 Terminal unit test in `src/daemon/terminals/terminal.test.ts`: a hidden clean exit without further output reports no unseen activity, a hidden failed exit does; verify they fail before implementation +- [x] 1.3 Process tests: `test/process/daemon-terminals-lifecycle.test.ts` (clean exit closes, `exit 3` reported, killed by SIGTERM stays, removed worktree pruned after a clean exit), `test/process/daemon-terminals-output.test.ts` (1 MiB burst then code 1 before termExited, code 0 before termClosed), `test/process/daemon-terminals-unseen.test.ts` (Exit while hidden with code 1; Clean exit while hidden); verify the new ones fail before implementation +- [x] 1.4 Protocol tests: `src/protocol/constants.test.ts` / `wire.golden.test.ts` expect daemon protocol 7, `src/protocol/messages.test.ts` hello sample uses protocol 8; version strings `daemon protocol 7` in `src/cli/run.test.ts`, `test/process/cli.test.ts`, `test/process/install-local.test.ts`, `test/process/daemon-server-startup.test.ts`; hub restart tests stop assuming fixed versions +- [x] 1.5 Web unit test `src/web/ui/marks.test.ts`: MARK_ORDER `input, failed, done, output`, any exit marks `failed`, status text "exited with code N" / "killed by " +- [x] 1.6 E2E: `test/e2e/terminals.spec.ts` (Exited terminal uses `exit 1`; Clean exit closes the tab on every page; Last terminal leaves the choices and 1 starts the first preset; Confirmed close leaves the choices focused; Choices leave the focus in the worktree search alone), `test/e2e/splits.spec.ts` or the pane-focus spec (cleanly exited pane's sibling focused and typed into), `test/e2e/closing.spec.ts` (existing `exit` cases become `exit 1`; dialog closes when its terminal exits cleanly), `test/e2e/attention.spec.ts` (no `exited` mark in counts; killed terminal marked failed); update `MARKS` in `test/e2e/contract.ts` ## 2. Daemon diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts index 979cd72..e43eb99 100644 --- a/src/cli/run.test.ts +++ b/src/cli/run.test.ts @@ -64,7 +64,7 @@ describe('wtd --version', () => { it('prints the package and both protocol versions', async () => { expect(await wtd('--version')).toEqual({ code: 0, - stdout: `wtd ${packageVersion()} (daemon protocol 6, browser protocol 10)\n`, + stdout: `wtd ${packageVersion()} (daemon protocol 7, browser protocol 10)\n`, stderr: '', }); }); diff --git a/src/daemon/server/daemon.test.ts b/src/daemon/server/daemon.test.ts index 3a1910e..a97c0fa 100644 --- a/src/daemon/server/daemon.test.ts +++ b/src/daemon/server/daemon.test.ts @@ -2,10 +2,10 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DaemonClient } from '../../../test/support/daemon-client.js'; import { sleep } from '../../../test/support/daemon-host.js'; -import type { Worktree } from '../../protocol/index.js'; +import type { Layout, Worktree } from '../../protocol/index.js'; import { StateStore } from '../state/index.js'; import { SpawnError, TerminalProcess, type TerminalEvents, type TerminalSpec } from '../terminals/index.js'; import type { WorktreeHandlers, WorktreeRisks, WorktreeWatch } from '../worktrees/index.js'; @@ -19,6 +19,7 @@ const worktrees: Worktree[] = [ { path: WT, head: null, branch: 'feat', detached: false, locked: false, prunable: false, bare: false, main: false }, ]; const NO_RISK: WorktreeRisks = { base: 'origin/main', ahead: 0, changes: 0 }; +const ANY_REQ: unknown = expect.any(Number); interface PendingSpawn { spec: TerminalSpec; @@ -41,6 +42,7 @@ let watchHandlers: WorktreeHandlers[]; /** What the risk check answers; a held promise keeps a removal in progress. */ let risks: Promise; let removed: string[]; +let reported: unknown[]; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'wtd-daemon-')); @@ -51,6 +53,7 @@ beforeEach(async () => { watchHandlers = []; risks = Promise.resolve(NO_RISK); removed = []; + reported = []; const services: DaemonServices = { watchWorktrees: async (_repo, handlers) => { watchHandlers.push(handlers); @@ -81,7 +84,9 @@ beforeEach(async () => { store, env: process.env, shutdown: () => undefined, - report: () => undefined, + report: (error) => { + reported.push(error); + }, }); server = createServer((socket) => { daemon.accept(socket); @@ -215,3 +220,98 @@ it('refuses to start a terminal in a worktree while it is being removed', async await client.waitFor('done', (m) => m.req === 1, { from }); expect(removed).toEqual([WT]); }); + +describe('ending terminals', () => { + afterEach(async () => { + await daemon.shutdown(); + }); + + const twoPanes = (a: number, b: number): Layout => ({ + tabs: [{ id: 'main', root: { split: 'right', ratio: 0.5, a: { term: a }, b: { term: b } } }], + active: 0, + }); + + /** Creates a terminal in `worktree` whose spawn runs `command` in bash; resolves with its id. */ + const start = async (client: DaemonClient, worktree: string, command: string): Promise => { + const count = spawns.length; + const reply = client.request({ t: 'createTerm', worktree, preset: 'shell', command: null, cols: 80, rows: 24 }); + await vi.waitFor(() => { + expect(spawns).toHaveLength(count + 1); + }); + const pending = spawns[count]; + if (pending === undefined) throw new Error('no spawn'); + const env = { ...process.env, HOME: dir, SHELL: '/bin/bash' }; + pending.resolve(await TerminalProcess.spawn({ ...pending.spec, cwd: dir, command, env }, pending.events)); + const created = await reply; + if (created.t !== 'termCreated') throw new Error(`createTerm failed: ${JSON.stringify(created)}`); + return created.term.termId; + }; + + it('closes a cleanly exited terminal: its split gives way to the other half, then termClosed, and no termExited', async () => { + const client = await connected(); + await client.watch(REPO); + const leaving = await start(client, WT, 'read -r _; exit 0'); + const staying = await start(client, WT, 'exec sleep 60'); + await client.ok({ t: 'setLayout', worktree: WT, layout: twoPanes(leaving, staying) }); + const from = client.mark(); + client.sendInput(leaving, '\r'); + await client.waitFor('termClosed', (m) => m.termId === leaving, { from, timeout: 3000 }); + await client.expectNone('termExited', () => true, 300); + expect(client.messages.slice(from).filter((m) => m.t !== 'activity')).toEqual([ + { t: 'layoutChanged', worktree: WT, layout: { tabs: [{ id: 'main', root: { term: staying } }], active: 0 } }, + { t: 'termClosed', termId: leaving }, + ]); + expect((await (await connected()).watch(REPO)).terminals.map((t) => t.termId)).toEqual([staying]); + }); + + it.each([ + ['a non-zero code', 'read -r _; exit 1', { code: 1, signal: null }], + ['a signal', 'read -r _; kill -KILL $$', { signal: 'SIGKILL' }], + ])('keeps a terminal that exited with %s, sending termExited', async (_name, command, exit) => { + const client = await connected(); + await client.watch(REPO); + const term = await start(client, WT, command); + const from = client.mark(); + client.sendInput(term, '\r'); + expect(await client.waitFor('termExited', (m) => m.termId === term, { from })).toMatchObject(exit); + await client.expectNone('termClosed', () => true, 300); + expect((await (await connected()).watch(REPO)).terminals.find((t) => t.termId === term)?.exit).toMatchObject(exit); + }); + + it('sends termClosed for a cleanly exited terminal whose layout cannot be saved, and reports the failure', async () => { + const client = await connected(); + await client.watch(REPO); + const leaving = await start(client, WT, 'read -r _; exit 0'); + const staying = await start(client, WT, 'exec sleep 60'); + await client.ok({ t: 'setLayout', worktree: WT, layout: twoPanes(leaving, staying) }); + const failure = new Error('disk full'); + vi.spyOn(store, 'setLayout').mockRejectedValue(failure); + const from = client.mark(); + client.sendInput(leaving, '\r'); + await client.waitFor('termClosed', (m) => m.termId === leaving, { from, timeout: 3000 }); + expect(client.messages.slice(from).filter((m) => m.t === 'layoutChanged' || m.t === 'termExited')).toEqual([]); + expect(store.layout(REPO, WT)).toEqual(twoPanes(leaving, staying)); + expect(reported).toContain(failure); + }); + + it('writes the layout of a removed worktree once, then sends termClosed for each of its terminals', async () => { + const client = await connected(); + await client.watch(REPO); + const one = await start(client, WT, 'exec sleep 60'); + const two = await start(client, WT, 'exec sleep 60'); + await client.ok({ t: 'setLayout', worktree: WT, layout: twoPanes(one, two) }); + const write = vi.spyOn(store, 'setLayout'); + const from = client.mark(); + await client.ok({ t: 'removeWorktree', worktree: WT, force: true }); + const empty: Layout = { tabs: [], active: 0 }; + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith(REPO, WT, empty); + expect(client.messages.slice(from).filter((m) => m.t !== 'activity')).toEqual([ + { t: 'layoutChanged', worktree: WT, layout: empty }, + { t: 'termClosed', termId: one }, + { t: 'termClosed', termId: two }, + { t: 'done', req: ANY_REQ }, + ]); + expect(removed).toEqual([WT]); + }); +}); diff --git a/src/daemon/terminals/terminal.test.ts b/src/daemon/terminals/terminal.test.ts index 8ce6c2d..2d7c46e 100644 --- a/src/daemon/terminals/terminal.test.ts +++ b/src/daemon/terminals/terminal.test.ts @@ -169,8 +169,8 @@ describe('attention signals in the output', () => { expect(events.filter(([, state]) => state === 'input')).toEqual([[true, 'input']]); }); - it('sets unseen when the process exits while hidden without further output', async () => { - const { term, events, exited } = await run('exec sleep 1'); + it('sets unseen when the process fails while hidden without further output', async () => { + const { term, events, exited } = await run("exec sh -c 'sleep 1; exit 1'"); term.setVisible(true); await sleep(500); term.setVisible(false); @@ -179,4 +179,16 @@ describe('attention signals in the output', () => { await waitUntil(() => term.unseen, 'unseen', 2000); expect(events.slice(before)).toEqual([[true, term.state]]); }); + + it('leaves unseen clear when the process exits cleanly while hidden without further output', async () => { + const { term, events, exited } = await run('exec sleep 1'); + term.setVisible(true); + await sleep(500); + term.setVisible(false); + const before = events.length; + await exited; + await sleep(200); + expect(term.unseen).toBe(false); + expect(events.slice(before)).toEqual([]); + }); }); diff --git a/src/protocol/constants.test.ts b/src/protocol/constants.test.ts index 7ee5a9e..178fa71 100644 --- a/src/protocol/constants.test.ts +++ b/src/protocol/constants.test.ts @@ -22,7 +22,7 @@ describe('shared constants', () => { ACK_EVERY, LAG_EVICT_MS, }).toEqual({ - DAEMON_PROTOCOL_VERSION: 6, + DAEMON_PROTOCOL_VERSION: 7, BROWSER_PROTOCOL_VERSION: 10, MAX_FRAME: 16 * 1024 * 1024, MAX_INPUT: 64 * 1024, diff --git a/src/protocol/messages.test.ts b/src/protocol/messages.test.ts index 47a2c3b..4b3d501 100644 --- a/src/protocol/messages.test.ts +++ b/src/protocol/messages.test.ts @@ -50,7 +50,7 @@ describe('handshake', () => { }); it('decodes a hello from another protocol version', () => { - expect(decodeMessage('daemonToClient', raw({ ...hello, protocol: 7 }))).toEqual({ ...hello, protocol: 7 }); + expect(decodeMessage('daemonToClient', raw({ ...hello, protocol: 8 }))).toEqual({ ...hello, protocol: 8 }); }); it.each([ diff --git a/src/protocol/wire.golden.test.ts b/src/protocol/wire.golden.test.ts index b183a40..a866498 100644 --- a/src/protocol/wire.golden.test.ts +++ b/src/protocol/wire.golden.test.ts @@ -327,8 +327,8 @@ describe('wire golden', () => { await expect(JSON.stringify(current, null, 2) + '\n').toMatchFileSnapshot('./wire.golden.json'); }); - it('is blessed for daemon protocol 6 and browser protocol 10', () => { - expect(previousSchema.parse(wireJson).protocolVersions).toEqual({ daemon: 6, browser: 10 }); + it('is blessed for daemon protocol 7 and browser protocol 10', () => { + expect(previousSchema.parse(wireJson).protocolVersions).toEqual({ daemon: 7, browser: 10 }); }); it('records every sample as valid in its own direction', () => { diff --git a/src/web/ui/marks.test.ts b/src/web/ui/marks.test.ts index 33bfb74..137a2f0 100644 --- a/src/web/ui/marks.test.ts +++ b/src/web/ui/marks.test.ts @@ -22,14 +22,14 @@ const clean = { code: 0, signal: null }; const input = terminal({ state: 'input' }); const failed = terminal({ exit: failedCode, unseen: true }); -const exited = terminal({ exit: clean, unseen: true }); +const killed = terminal({ exit: failedSignal, unseen: true }); const done = terminal({ unseen: true, state: 'idle' }); const output = terminal({ unseen: true, state: 'working' }); const quiet = terminal({ state: 'working' }); describe('mark', () => { it('ranks marks in the specified order', () => { - expect(MARK_ORDER).toEqual(['input', 'failed', 'exited', 'done', 'output']); + expect(MARK_ORDER).toEqual(['input', 'failed', 'done', 'output']); }); it.each<[string, Partial, Mark | null]>([ @@ -38,8 +38,8 @@ describe('mark', () => { ['failed with a non-zero code', { exit: failedCode }, 'failed'], ['failed with a signal', { exit: failedSignal }, 'failed'], ['failed rather than done', { exit: { code: 2, signal: null }, unseen: true, state: 'idle' }, 'failed'], - ['exited cleanly', { exit: clean }, 'exited'], - ['exited rather than new output', { exit: clean, unseen: true, state: 'working' }, 'exited'], + ['failed rather than new output', { exit: failedCode, unseen: true, state: 'working' }, 'failed'], + ['exited with code 0, which only a failure leaves visible', { exit: clean }, 'failed'], ['done when unseen and idle', { unseen: true, state: 'idle' }, 'done'], ['new output when unseen and working', { unseen: true, state: 'working' }, 'output'], ['nothing when seen and idle', { state: 'idle' }, null], @@ -62,19 +62,19 @@ describe('aggregate', () => { }); it('counts a seen exited terminal on a tab', () => { - const seen = terminal({ exit: clean }); const seenFailed = terminal({ exit: failedCode }); - expect(aggregate([output, seen], 'tab')).toEqual({ mark: 'exited', counts: { output: 1, exited: 1 } }); - expect(aggregate([seenFailed, seen], 'tab')).toEqual({ mark: 'failed', counts: { failed: 1, exited: 1 } }); + const seenKilled = terminal({ exit: failedSignal }); + expect(aggregate([output, seenFailed], 'tab')).toEqual({ mark: 'failed', counts: { output: 1, failed: 1 } }); + expect(aggregate([seenFailed, seenKilled], 'tab')).toEqual({ mark: 'failed', counts: { failed: 2 } }); }); it('counts an exited terminal on a worktree row or repo tab only while it is unseen', () => { - const seen = terminal({ exit: clean }); const seenFailed = terminal({ exit: failedCode }); - const seenWaiting = terminal({ exit: clean, state: 'input' }); - expect(aggregate([output, seen, seenFailed, seenWaiting], 'group')).toEqual({ mark: 'output', counts: { output: 1 } }); - expect(aggregate([seen, seenFailed], 'group')).toEqual({ mark: null, counts: {} }); - expect(aggregate([output, failed, exited], 'group')).toEqual({ mark: 'failed', counts: { output: 1, failed: 1, exited: 1 } }); + const seenKilled = terminal({ exit: failedSignal }); + const seenWaiting = terminal({ exit: failedCode, state: 'input' }); + expect(aggregate([output, seenFailed, seenKilled, seenWaiting], 'group')).toEqual({ mark: 'output', counts: { output: 1 } }); + expect(aggregate([seenFailed, seenKilled], 'group')).toEqual({ mark: null, counts: {} }); + expect(aggregate([output, failed, killed], 'group')).toEqual({ mark: 'failed', counts: { output: 1, failed: 2 } }); }); it('shows a group’s first-ranked mark with the number of terminals per mark', () => { @@ -96,7 +96,7 @@ describe('terminalStatus', () => { ['running', { state: 'working', unseen: true }], ['done', { state: 'idle', unseen: true }], ['idle', { state: 'idle', unseen: false }], - ['exited', { exit: clean }], + ['exited with code 0', { exit: clean }], ['exited with code 1', { exit: failedCode }], ['killed by SIGKILL', { exit: failedSignal }], ] as const)('says %s', (status, fields) => { diff --git a/test/e2e/attention.spec.ts b/test/e2e/attention.spec.ts index 78ae720..b5ddac7 100644 --- a/test/e2e/attention.spec.ts +++ b/test/e2e/attention.spec.ts @@ -1,4 +1,4 @@ -import { COMMANDS, expectCounts, expectMark, runIn } from './attention-helpers.js'; +import { COMMANDS, expectCounts, expectMark, markTitle, runIn } from './attention-helpers.js'; import { pane, repoTab, terminalBox, termTab, worktreeEntry } from './contract.js'; import { expect, LOCAL, makeRepoWith, openUi, selectRepo, selectWorktree, test, watcher } from './fixture.js'; import { leaf, split, storeTabs } from './panes.js'; @@ -90,6 +90,21 @@ test.describe('attention marks', () => { await expectMark(page, pane(failing), null); }); + test('a terminal killed by a signal marks its tab failed', async ({ hub, page }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const client = await watcher(hub, repo); + const killed = await runIn(client, repo, 'read -r _; kill -KILL $$'); + await storeTabs(client, repo, [leaf(killed)]); + await expect(page.locator(terminalBox(LOCAL, killed))).toBeVisible(); + + client.sendInput(killed, '\r'); + expect(await client.waitFor('termExited', (m) => m.termId === killed, { timeout: 10_000 })).toMatchObject({ signal: 'SIGKILL' }); + await expectMark(page, termTab(killed), 'failed'); + await expect(page.locator(termTab(killed))).toContainText('exited'); + }); + test('a hidden terminal that printed and then stayed quiet for 3 s is marked done', async ({ hub, page }) => { const { repo, worktrees } = makeRepoWith(hub, 'app', ['feat']); const feat = worktrees[0] ?? ''; @@ -117,23 +132,21 @@ test.describe('attention marks', () => { await runIn(client, feat, COMMANDS.oneLine); await runIn(client, feat, COMMANDS.ticker); await client.waitFor('termExited', (m) => m.termId === failing, { timeout: 10_000 }); - await client.waitFor('termExited', (m) => m.termId === succeeding, { timeout: 10_000 }); + await client.waitFor('termClosed', (m) => m.termId === succeeding, { timeout: 10_000 }); - const all = { input: 1, failed: 1, exited: 1, done: 1, output: 1 }; + const all = { input: 1, failed: 1, done: 1, output: 1 }; await expectMark(page, worktreeEntry(feat), 'input'); await expectCounts(page, worktreeEntry(feat), all); await expectMark(page, repoTab(repo), 'input'); await expectCounts(page, repoTab(repo), all); + expect(await markTitle(page, worktreeEntry(feat))).not.toContain('exited'); // Typing clears `input`; the echoed `y` is new output. client.sendInput(ringing, 'y'); await expectMark(page, worktreeEntry(feat), 'failed'); - await expectCounts(page, worktreeEntry(feat), { failed: 1, exited: 1 }); + await expectCounts(page, worktreeEntry(feat), { failed: 1 }); await client.ok({ t: 'closeTerm', termId: failing }); - await expectMark(page, worktreeEntry(feat), 'exited'); - await expectMark(page, repoTab(repo), 'exited'); - await client.ok({ t: 'closeTerm', termId: succeeding }); await expectMark(page, worktreeEntry(feat), 'done'); await expectMark(page, repoTab(repo), 'done'); }); diff --git a/test/e2e/closing.spec.ts b/test/e2e/closing.spec.ts index 812404f..5a24c04 100644 --- a/test/e2e/closing.spec.ts +++ b/test/e2e/closing.spec.ts @@ -136,7 +136,7 @@ test.describe('closing terminals', () => { hub.writeRepos([repo]); await openUi(hub, page); const { left, right } = await twoPanes(page); - await typeLine(page, right, 'exit'); + await typeLine(page, right, 'exit 1'); await exited(wire, right); const mark = wire.markSent(); @@ -151,7 +151,7 @@ test.describe('closing terminals', () => { hub.writeRepos([repo]); await openUi(hub, page); const { left, right } = await twoPanes(page); - await typeLine(page, right, 'exit'); + await typeLine(page, right, 'exit 1'); await exited(wire, right); const mark = wire.markSent(); @@ -183,6 +183,24 @@ test.describe('closing terminals', () => { expect(closesSent(wire, mark)).toEqual([]); }); + test('the dialog closes by itself when its only terminal exits cleanly', async ({ hub, page, wire }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const client = await watcher(hub, repo); + const term = await newTerminal(page); + await ready(page, term); + const mark = wire.markSent(); + + await page.keyboard.press('Control+Shift+KeyW'); + await expect(dialogOf(page)).toBeVisible(); + client.sendInput(term, 'exit\r'); + await expect(dialogOf(page)).toHaveCount(0); + await expect(page.locator(termTab(term))).toHaveCount(0); + await page.waitForTimeout(500); // NOSONAR(S2925) — absence check: nothing to synchronise on + expect(closesSent(wire, mark)).toEqual([]); + }); + test('confirming after one target was closed elsewhere closes only the one still live', async ({ hub, page, wire }) => { const { repo } = makeRepoWith(hub, 'app', []); hub.writeRepos([repo]); diff --git a/test/e2e/contract.ts b/test/e2e/contract.ts index bd77953..48dcb15 100644 --- a/test/e2e/contract.ts +++ b/test/e2e/contract.ts @@ -96,7 +96,7 @@ export const TID = { divider: 'divider', /** The preset picker over the terminal area. */ presetPicker: 'preset-picker', - /** The preset list a worktree without terminals shows in its terminal area. */ + /** The preset list a worktree without terminals shows in its terminal area; its first option takes the keyboard focus when the shown worktree's last terminal closed with the focus left on the page body. */ presetChoices: 'preset-choices', /** One preset in a picker or in the choices, in configured order; text = preset name; `aria-selected="true"` on the highlighted one. */ presetOption: 'preset-option', @@ -124,7 +124,7 @@ export const TID = { closeConfirm: 'close-confirm', /** Cancels the close dialog. */ closeCancel: 'close-cancel', - /** Attention mark inside a pane, terminal tab, worktree entry or repo tab, absent without a mark; `data-mark` = input|failed|exited|done|output; `title` gives the number of terminals per mark, one line `: ` per mark present, in rank order. */ + /** Attention mark inside a pane, terminal tab, worktree entry or repo tab, absent without a mark; `data-mark` = input|failed|done|output; `title` gives the number of terminals per mark, one line `: ` per mark present, in rank order. */ mark: 'mark', /** Banner for problems reported on the page, such as a failed copy, a failed host action or a repo that cannot be removed. */ notice: 'notice', @@ -147,7 +147,7 @@ export const TID = { } as const; /** Mark names in rank order, as `data-mark` values. */ -export const MARKS = ['input', 'failed', 'exited', 'done', 'output'] as const; +export const MARKS = ['input', 'failed', 'done', 'output'] as const; /** Performance mark at the start of the worktree-switch input handler (design D13). */ export const SWITCH_START = 'wtd:switch-start'; diff --git a/test/e2e/splits.spec.ts b/test/e2e/splits.spec.ts index f576d9f..10f9b08 100644 --- a/test/e2e/splits.spec.ts +++ b/test/e2e/splits.spec.ts @@ -512,6 +512,25 @@ test.describe('pane focus', () => { await waitScreen(page, left, 'FOCUS-OK'); }); + test('`exit` in the focused pane of a two-pane split focuses the other, which receives typed input', async ({ hub, page }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const left = await newTerminal(page); + await ready(page, left); + const right = await splitPane(page, 'right'); + await ready(page, right); + await expect.poll(() => focusedPane(page)).toBe(right); + + await page.keyboard.type('exit'); + await page.keyboard.press('Enter'); + await expect.poll(() => paneIds(page)).toEqual([left]); + await expect.poll(() => focusedPane(page)).toBe(left); + await page.keyboard.type("echo FOCUS''-OK"); + await page.keyboard.press('Enter'); + await waitScreen(page, left, 'FOCUS-OK'); + }); + test('closing a focused pane focuses the first pane of the subtree that took its place', async ({ hub, page }) => { const { repo } = makeRepoWith(hub, 'app', []); hub.writeRepos([repo]); diff --git a/test/e2e/terminals.spec.ts b/test/e2e/terminals.spec.ts index 6bf159c..8b8503e 100644 --- a/test/e2e/terminals.spec.ts +++ b/test/e2e/terminals.spec.ts @@ -22,7 +22,7 @@ import { watcher, xtermOf, } from './fixture.js'; -import { createTerms, leaf, split, storeTabs } from './panes.js'; +import { createsSent, createTerms, leaf, split, storeTabs } from './panes.js'; import { installStreamCheck } from './stream.js'; /** Where the page's first session is cut: when the hub answers its `createTerm`, or when it sends the `setLayout` that follows. */ @@ -143,21 +143,91 @@ test.describe('terminal tabs', () => { await expect(page.locator(termTab(term.termId))).toBeVisible(); }); - test('an exited terminal says so and its screen stays readable', async ({ hub, page }) => { + test('a terminal whose shell exits with code 1 says so and its screen stays readable', async ({ hub, page }) => { const { repo } = makeRepoWith(hub, 'app', []); hub.writeRepos([repo]); await openUi(hub, page); const term = await newTerminal(page); await ready(page, term); - await typeLine(page, term, "echo BYE''-MARK; exit"); + await typeLine(page, term, "echo BYE''-MARK; exit 1"); await expect(page.locator(termTab(term))).toContainText('exited'); - await expect.poll(() => markOf(page, termTab(term))).toBe('exited'); + await expect.poll(() => markOf(page, termTab(term))).toBe('failed'); // A lone pane has no header: its tab carries the mark. expect(await markOf(page, pane(term))).toBeNull(); await expect(page.locator(terminalBox(LOCAL, term))).toBeVisible(); expect(await screenOf(page, term)).toContain('BYE-MARK'); }); + test('a shell exiting cleanly drops its tab from every page and the remaining tab is shown', async ({ hub, page, context }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const first = await newTerminal(page); + await ready(page, first); + const second = await newTerminal(page); + await ready(page, second); + const other = await openPage(hub, context); + await expect(other.page.locator(termTab(second))).toBeVisible(); + + await typeLine(page, second, 'exit'); + for (const p of [page, other.page]) { + await expect(p.locator(termTab(second))).toHaveCount(0); + await expect(p.locator(byTestId(TID.termTab))).toHaveCount(1); + await expect(p.locator(termTab(first))).toHaveAttribute('aria-selected', 'true'); + await expect(p.locator(terminalBox(LOCAL, first))).toBeVisible(); + } + }); + + test('after `exit` in the only terminal, the choices are shown and 1 starts the first preset there', async ({ hub, page, wire }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const term = await newTerminal(page); + await ready(page, term); + + await typeLine(page, term, 'exit'); + const options = page.locator(byTestId(TID.presetChoices)).locator(byTestId(TID.presetOption)); + await expect(options).toHaveText(PRESETS.map((p) => p.name)); + await expect(page.locator(termTab(term))).toHaveCount(0); + await expect(options.nth(0)).toBeFocused(); + const mark = wire.markSent(); + await page.keyboard.press('1'); + await expect.poll(() => createsSent(wire, mark)).toEqual([expect.objectContaining({ worktree: repo, preset: PRESETS[0].name })]); + await expect.poll(() => activeTerm(page)).not.toBe(term); + }); + + test('after the only running terminal is closed and confirmed, 1 starts the first preset there', async ({ hub, page, wire }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const term = await newTerminal(page); + await ready(page, term); + + await page.locator(termTab(term)).locator(byTestId(TID.termTabClose)).click(); + await confirmClose(page); + const options = page.locator(byTestId(TID.presetChoices)).locator(byTestId(TID.presetOption)); + await expect(options.nth(0)).toBeFocused(); + const mark = wire.markSent(); + await page.keyboard.press('1'); + await expect.poll(() => createsSent(wire, mark)).toEqual([expect.objectContaining({ worktree: repo, preset: PRESETS[0].name })]); + }); + + test('the choices shown after a clean exit leave the keyboard focus in the worktree search', async ({ hub, page }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const client = await watcher(hub, repo); + const term = await client.create(repo, { command: 'read -r _; exit 0' }); + await expect(page.locator(terminalBox(LOCAL, term.termId))).toBeVisible(); + const search = page.locator(byTestId(TID.worktreeSearch)); + await search.focus(); + + client.sendInput(term.termId, '\r'); + await expect(page.locator(byTestId(TID.presetChoices))).toBeVisible(); + await page.waitForTimeout(500); // NOSONAR(S2925) — absence check: nothing to synchronise on + await expect(search).toBeFocused(); + }); + for (const at of ['setLayout', 'termCreated'] as const) { test(`a terminal created before the session closes at its ${at} is shown as a tab after reconnecting and created once`, async ({ hub, diff --git a/test/process/cli.test.ts b/test/process/cli.test.ts index 3468570..6162d23 100644 --- a/test/process/cli.test.ts +++ b/test/process/cli.test.ts @@ -38,7 +38,7 @@ describe('dist/wtd.mjs', () => { it('prints its version', () => { expect(wtd('--version')).toEqual({ status: 0, - stdout: `wtd ${packageVersion()} (daemon protocol 6, browser protocol 10)\n`, + stdout: `wtd ${packageVersion()} (daemon protocol 7, browser protocol 10)\n`, stderr: '', }); }); diff --git a/test/process/connect.test.ts b/test/process/connect.test.ts index 6ac0792..962995a 100644 --- a/test/process/connect.test.ts +++ b/test/process/connect.test.ts @@ -39,7 +39,7 @@ describe('wtd connect', () => { const hello = await client.waitFor('hello', () => true, { timeout: 10_000 }); expect(client.received[0]).toEqual({ kind: 'message', message: hello }); expect(hello.protocol).toBe(DAEMON_PROTOCOL_VERSION); - expect(hello.protocol).toBe(6); + expect(hello.protocol).toBe(7); const [daemon, ...others] = host.daemonPids(); if (daemon === undefined) throw new Error('no daemon is running'); expect(others).toEqual([]); diff --git a/test/process/daemon-server-startup.test.ts b/test/process/daemon-server-startup.test.ts index 4a901d1..6f5e300 100644 --- a/test/process/daemon-server-startup.test.ts +++ b/test/process/daemon-server-startup.test.ts @@ -120,7 +120,7 @@ describe('single instance', () => { }); describe('handshake', () => { - it('greets every connection with hello for protocol 6, the package version and one instance id', async () => { + it('greets every connection with hello for protocol 7, the package version and one instance id', async () => { await fx.host.start(); const a = await fx.host.rawClient(); const b = await fx.host.rawClient(); @@ -128,7 +128,7 @@ describe('handshake', () => { const helloB = await b.waitFor('hello'); expect(a.received[0]).toEqual({ kind: 'message', message: helloA }); expect(helloA.protocol).toBe(DAEMON_PROTOCOL_VERSION); - expect(helloA.protocol).toBe(6); + expect(helloA.protocol).toBe(7); expect(helloA.version).toBe(packageVersion()); expect(helloA.instance).toMatch(/^[A-Za-z0-9_-]{1,64}$/); expect(helloB.instance).toBe(helloA.instance); diff --git a/test/process/daemon-terminals-lifecycle.test.ts b/test/process/daemon-terminals-lifecycle.test.ts index ee69009..192a69c 100644 --- a/test/process/daemon-terminals-lifecycle.test.ts +++ b/test/process/daemon-terminals-lifecycle.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { Layout } from '../../src/protocol/index.js'; import type { DaemonClient, Event, RequestBody } from '../support/daemon-client.js'; import { addWorktree, alive, git, sleep, waitUntil } from '../support/daemon-host.js'; -import { fx, MiB, restart, started, useDaemonTerminalsHost } from '../support/daemon-terminals.js'; +import { fx, restart, started, useDaemonTerminalsHost } from '../support/daemon-terminals.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useDaemonTerminalsHost(); @@ -22,12 +22,6 @@ const pidIn = (dir: string, name: string): Promise => return text.endsWith('\n') ? Number(text) : undefined; }, `pid file ${name}`); -const countByte = (data: Uint8Array, byte: number): number => { - let n = 0; - for (const b of data) if (b === byte) n++; - return n; -}; - const stateFile = (): unknown => JSON.parse(readFileSync(fx.host.statePath, 'utf8')); const stateMentions = (path: string): boolean => @@ -168,17 +162,6 @@ describe('terminal lifetime', () => { expect((await fx.b.watch(fx.repo)).terminals.map((t) => t.termId)).toEqual([term.termId]); }); - it('delivers a final burst before termExited', async () => { - const term = await started(fx.a, "read go; head -c 1048576 /dev/zero | tr '\\0' x; exit 0"); - fx.a.sendInput(term.termId, 'go\r'); - await fx.a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 30_000 }); - const exitedAt = fx.a.received.findIndex((r) => r.kind === 'message' && r.message.t === 'termExited'); - const before = fx.a.received - .slice(0, exitedAt) - .reduce((n, r) => n + (r.kind === 'output' && r.termId === term.termId ? countByte(r.data, 0x78) : 0), 0); - expect(before).toBe(MiB); - }); - it('reports the exit code to every watcher and keeps the terminal until closed', async () => { const term = await fx.a.create(fx.wt, { command: 'exit 3' }); const expected = { t: 'termExited', termId: term.termId, code: 3, signal: null }; @@ -194,6 +177,31 @@ describe('terminal lifetime', () => { expect(await fx.a.waitFor('termExited', (m) => m.termId === term.termId)).toMatchObject({ signal: 'SIGKILL' }); }); + it('keeps a terminal killed by SIGTERM from outside the daemon, reporting the signal', async () => { + const term = await fx.a.create(fx.wt, { command: 'echo $$ > sh.pid; exec sleep 600' }); + process.kill(await pidIn(fx.wt, 'sh.pid'), 'SIGTERM'); + expect(await fx.a.waitFor('termExited', (m) => m.termId === term.termId)).toMatchObject({ signal: 'SIGTERM' }); + const state = await (await fx.host.client()).watch(fx.repo); + expect(state.terminals.find((t) => t.termId === term.termId)?.exit).toMatchObject({ signal: 'SIGTERM' }); + await fx.a.ok({ t: 'closeTerm', termId: term.termId }); + }); + + it('closes a terminal whose shell exits cleanly, telling every watcher, without termExited', async () => { + const term = await started(fx.a, null); + const from = fx.a.mark(); + const fromB = fx.b.mark(); + fx.a.sendInput(term.termId, 'exit\r'); + expect(await fx.a.waitFor('termClosed', (m) => m.termId === term.termId, { from })).toEqual({ t: 'termClosed', termId: term.termId }); + expect(await fx.b.waitFor('termClosed', (m) => m.termId === term.termId, { from: fromB })).toEqual({ + t: 'termClosed', + termId: term.termId, + }); + await sleep(300); + expect(fx.a.messages.slice(from).filter((m) => m.t === 'termExited')).toEqual([]); + expect((await (await fx.host.client()).watch(fx.repo)).terminals).toEqual([]); + expect(await fx.a.fails({ t: 'closeTerm', termId: term.termId })).toBe('unknown-term'); + }); + it('ends the shell and its background job on close, then reports termClosed before done', async () => { const term = await fx.a.create(fx.wt, { command: 'sleep 600 & echo $! > bg.pid; echo $$ > sh.pid; wait' }); const shell = await pidIn(fx.wt, 'sh.pid'); @@ -289,6 +297,24 @@ describe('checked state and layouts', () => { }); }); + it('replaces a split by its other half, then sends termClosed, when a terminal in it exits cleanly', async () => { + const one = await fx.a.create(fx.wt, { command: 'read -r _; exit 0' }); + const two = await fx.a.create(fx.wt, { command: 'exec sleep 60' }); + const fromSet = fx.b.mark(); + await fx.a.ok({ t: 'setLayout', worktree: fx.wt, layout: twoPanes(one.termId, two.termId) }); + await fx.b.waitFor('layoutChanged', () => true, { from: fromSet }); + const from = fx.b.mark(); + fx.a.sendInput(one.termId, '\r'); + await fx.b.waitFor('termClosed', (m) => m.termId === one.termId, { from }); + expect(repliesFrom(fx.b, from)).toEqual([ + { t: 'layoutChanged', worktree: fx.wt, layout: { tabs: [{ id: 'main', root: { term: two.termId } }], active: 0 } }, + { t: 'termClosed', termId: one.termId }, + ]); + expect((await (await fx.host.client()).watch(fx.repo)).layouts).toEqual([ + { worktree: fx.wt, layout: { tabs: [{ id: 'main', root: { term: two.termId } }], active: 0 } }, + ]); + }); + it('removes a tab left empty when its last terminal is closed', async () => { const one = await fx.a.create(fx.wt, { command: 'exec sleep 60' }); const fromSet = fx.b.mark(); @@ -364,6 +390,20 @@ describe('persistence and pruning', () => { await waitUntil(() => !stateMentions(gone), 'the entry to be pruned', 2000); }); + it('prunes a removed worktree once its terminal exits cleanly', async () => { + const gone = addWorktree(fx.repo, join(fx.host.dir, 'gone'), 'gone'); + await fx.a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); + const term = await fx.a.create(gone, { command: 'read -r _; exit 0' }); + await fx.a.ok({ t: 'setChecked', worktree: gone, checked: true }); + git(fx.repo, 'worktree', 'remove', '--force', gone); + await fx.a.waitFor('worktreesChanged', (m) => !m.worktrees.some((w) => w.path === gone)); + await sleep(500); + expect(stateMentions(gone)).toBe(true); + fx.a.sendInput(term.termId, '\r'); + await fx.a.waitFor('termClosed', (m) => m.termId === term.termId); + await waitUntil(() => !stateMentions(gone), 'the entry to be pruned', 2000); + }); + it('moves a corrupt state file aside and starts with no checked worktrees', async () => { await fx.a.ok({ t: 'setChecked', worktree: fx.wt, checked: true }); fx.a.send({ t: 'shutdown' }); diff --git a/test/process/daemon-terminals-output.test.ts b/test/process/daemon-terminals-output.test.ts index d298fb5..7623ad1 100644 --- a/test/process/daemon-terminals-output.test.ts +++ b/test/process/daemon-terminals-output.test.ts @@ -9,6 +9,28 @@ import { fx, KiB, MiB, started, useDaemonTerminalsHost } from '../support/daemon vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useDaemonTerminalsHost(); +const countByte = (data: Uint8Array, byte: number): number => { + let n = 0; + for (const b of data) if (b === byte) n++; + return n; +}; + +describe('final burst', () => { + it.each([ + ['termExited after a failed exit', 1, 'termExited'], + ['termClosed after a clean exit', 0, 'termClosed'], + ] as const)('delivers 1 MiB written just before the exit before %s', async (_name, code, end) => { + const term = await started(fx.a, `read go; head -c 1048576 /dev/zero | tr '\\0' x; exit ${String(code)}`); + fx.a.sendInput(term.termId, 'go\r'); + await fx.a.waitFor(end, (m) => m.termId === term.termId, { timeout: 30_000 }); + const endAt = fx.a.received.findIndex((r) => r.kind === 'message' && r.message.t === end); + const before = fx.a.received + .slice(0, endAt) + .reduce((n, r) => n + (r.kind === 'output' && r.termId === term.termId ? countByte(r.data, 0x78) : 0), 0); + expect(before).toBe(MiB); + }); +}); + describe('attach and snapshot', () => { const GENERATOR = String.raw` read go @@ -74,7 +96,7 @@ exec sleep 600 }); it('shrinks the scrollback of a snapshot that would exceed MAX_FRAME', async () => { - const awk = `awk 'BEGIN { for (l = 0; l < 5100; l++) { s = ""; for (c = 0; c < 500; c++) s = s "\\033[31ma\\033[32mb"; print s } }'; echo BIG-DONE`; + const awk = `awk 'BEGIN { for (l = 0; l < 5100; l++) { s = ""; for (c = 0; c < 500; c++) s = s "\\033[31ma\\033[32mb"; print s } }'; echo BIG-DONE; exit 1`; const term = await fx.a.create(fx.wt, { command: awk, cols: 1000, rows: 50 }); await fx.a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 90_000 }); const view = await fx.a.attach(term.termId, 60_000); @@ -105,7 +127,7 @@ exec sleep 600 }); it('attaches to an exited terminal', async () => { - const term = await fx.a.create(fx.wt, { command: 'echo bye-bye; exit 0' }); + const term = await fx.a.create(fx.wt, { command: 'echo bye-bye; exit 1' }); await fx.a.waitFor('termExited', (m) => m.termId === term.termId); const view = await fx.a.attach(term.termId); expect(await view.screen.text()).toContain('bye-bye'); @@ -114,7 +136,7 @@ exec sleep 600 describe('output flow control', () => { it('evicts a client that never acks while an acking client receives every byte', async () => { - const term = await started(fx.b, 'read go; seq 1 1500000; echo FLOOD-DONE'); + const term = await started(fx.b, 'read go; seq 1 1500000; echo FLOOD-DONE; exit 1'); fx.a.autoAck = false; await fx.a.attach(term.termId); const goAt = Date.now(); @@ -139,7 +161,7 @@ describe('output flow control', () => { }); it('lets an unwatched flood complete and shows its final screen to a later attach', async () => { - const term = await fx.a.create(fx.wt, { command: 'seq 1 2000000; echo NOBODY-DONE' }); + const term = await fx.a.create(fx.wt, { command: 'seq 1 2000000; echo NOBODY-DONE; exit 1' }); await fx.a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 60_000 }); const text = await (await fx.a.attach(term.termId)).screen.text(); expect(text).toContain('2000000'); @@ -180,12 +202,12 @@ describe('input', () => { const lines = Array.from({ length: 300 }, (_, i) => `line ${String(i)}`); for (const line of lines) fx.a.sendInput(term.termId, `${line}\n`); fx.a.sendInput(term.termId, '\x04'); - await fx.a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 10_000 }); + await fx.a.waitFor('termClosed', (m) => m.termId === term.termId, { timeout: 10_000 }); expect(readFileSync(join(fx.wt, 'in.txt'), 'utf8')).toBe(lines.map((l) => `${l}\n`).join('')); }); it('discards input to an exited terminal without an error', async () => { - const term = await fx.a.create(fx.wt, { command: 'exit 0' }); + const term = await fx.a.create(fx.wt, { command: 'exit 1' }); await fx.a.waitFor('termExited', (m) => m.termId === term.termId); fx.a.sendInput(term.termId, 'ignored\r'); await fx.a.expectNone('error', () => true, 400); diff --git a/test/process/daemon-terminals-unseen.test.ts b/test/process/daemon-terminals-unseen.test.ts index da04bdc..995c3aa 100644 --- a/test/process/daemon-terminals-unseen.test.ts +++ b/test/process/daemon-terminals-unseen.test.ts @@ -75,8 +75,8 @@ describe('activity', () => { await fx.b.expectNone('activity', () => true, 400); }); - it('flags an exit while hidden as unseen', async () => { - const term = await fx.a.create(fx.wt, { command: 'read -r _; exec sleep 1' }); + it('flags a failed exit while hidden as unseen', async () => { + const term = await fx.a.create(fx.wt, { command: "read -r _; exec sh -c 'sleep 1; exit 1'" }); fx.a.send({ t: 'setVisible', termIds: [term.termId] }); await fx.a.attach(term.termId); fx.a.sendInput(term.termId, '\r'); @@ -94,6 +94,32 @@ describe('activity', () => { expect(fx.a.view(term.termId).position).toBe(position); }); + it('closes a terminal exiting cleanly while hidden without flagging it unseen', async () => { + const term = await fx.a.create(fx.wt, { command: 'read -r _; exec sleep 1' }); + fx.a.send({ t: 'setVisible', termIds: [term.termId] }); + await fx.a.attach(term.termId); + fx.a.sendInput(term.termId, '\r'); + await sleep(400); + fx.a.send({ t: 'setVisible', termIds: [] }); + const from = fx.b.mark(); + await fx.b.waitFor('termClosed', (m) => m.termId === term.termId, { from }); + expect(activityOf(fx.b, term.termId, from).filter((m) => m.unseen)).toEqual([]); + expect(fx.b.messages.slice(from).filter((m) => m.t === 'termExited')).toEqual([]); + }); + + it('flags output just before a clean exit while hidden as unseen, then closes the terminal', async () => { + const term = await fx.a.create(fx.wt, { command: "read -r _; exec sh -c 'sleep 1; echo last-words'" }); + fx.a.send({ t: 'setVisible', termIds: [term.termId] }); + await fx.a.attach(term.termId); + fx.a.sendInput(term.termId, '\r'); + await sleep(400); + fx.a.send({ t: 'setVisible', termIds: [] }); + const from = fx.b.mark(); + await fx.b.waitFor('termClosed', (m) => m.termId === term.termId, { from }); + const closedAt = fx.b.messages.findIndex((m, i) => i >= from && m.t === 'termClosed' && m.termId === term.termId); + expect(fx.b.messages.slice(from, closedAt).some((m) => m.t === 'activity' && m.termId === term.termId && m.unseen)).toBe(true); + }); + it('reports working, then idle 3 s after the last output', async () => { const from = fx.b.mark(); const term = await started(fx.a, 'echo quiet-line; exec sleep 60'); diff --git a/test/process/daemon-worktrees.test.ts b/test/process/daemon-worktrees.test.ts index d4dca07..96b6c55 100644 --- a/test/process/daemon-worktrees.test.ts +++ b/test/process/daemon-worktrees.test.ts @@ -408,7 +408,7 @@ describe('removeWorktree', () => { const wt = addWorktree(repo, join(host.dir, 'wt'), 'feat'); const client = await host.client(); await client.watch(repo); - const exited = await client.create(wt, { command: 'true' }); + const exited = await client.create(wt, { command: 'exit 1' }); await client.waitFor('termExited', (m) => m.termId === exited.termId); const from = client.mark(); expect(await client.request({ t: 'removeWorktree', worktree: wt, force: false })).toEqual({ t: 'done', req: ANY_REQ }); diff --git a/test/process/hub-router-config.test.ts b/test/process/hub-router-config.test.ts index a171268..24a756a 100644 --- a/test/process/hub-router-config.test.ts +++ b/test/process/hub-router-config.test.ts @@ -262,7 +262,7 @@ describe('removing repos', () => { it('refuses a repo with an exited terminal not yet closed, and removes it once closed', async () => { const client = await watching(); - const term = await client.create(0, fx.repo, { command: 'exit 0' }); + const term = await client.create(0, fx.repo, { command: 'exit 1' }); await client.waitEvent(0, 'termExited', (m) => m.termId === term.termId, { timeout: 10_000 }); expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: fx.repo })).m).toMatchObject({ t: 'error', code: 'busy' }); await client.ok(0, { t: 'closeTerm', termId: term.termId }); diff --git a/test/process/hub-router-routing.test.ts b/test/process/hub-router-routing.test.ts index a2a412c..9f560db 100644 --- a/test/process/hub-router-routing.test.ts +++ b/test/process/hub-router-routing.test.ts @@ -119,7 +119,7 @@ describe('routing', () => { it('relays a snapshot near the frame limit intact', async () => { const client = await watching(); - const awk = `awk 'BEGIN { for (l = 0; l < 5100; l++) { s = ""; for (c = 0; c < 500; c++) s = s "\\033[31ma\\033[32mb"; print s } }'; echo BIG-DONE`; + const awk = `awk 'BEGIN { for (l = 0; l < 5100; l++) { s = ""; for (c = 0; c < 500; c++) s = s "\\033[31ma\\033[32mb"; print s } }'; echo BIG-DONE; exit 1`; const term = await client.create(0, fx.wt, { command: awk, cols: 1000, rows: 50 }); await client.waitEvent(0, 'termExited', (m) => m.termId === term.termId, { timeout: 90_000 }); const view = await client.attach(0, term.termId, 60_000); diff --git a/test/process/install-local.test.ts b/test/process/install-local.test.ts index 84eedf8..d6e2be9 100644 --- a/test/process/install-local.test.ts +++ b/test/process/install-local.test.ts @@ -103,7 +103,7 @@ describe('wtd install-local', () => { expect(readdirSync(join(dir, PTY_PACKAGE, 'prebuilds')).length).toBeGreaterThan(0); expect(shim(host.home, ['--version'])).toEqual({ status: 0, - stdout: `wtd ${version} (daemon protocol 6, browser protocol 10)\n`, + stdout: `wtd ${version} (daemon protocol 7, browser protocol 10)\n`, stderr: '', }); }); @@ -150,7 +150,7 @@ describe('wtd install-local', () => { await shimDaemon(host.home); const pid = await waitUntil(() => daemonPidsOf(host.stateHome)[0], 'the shim daemon'); expect(readlinkSync(`/proc/${String(pid)}/exe`)).toBe(node); - expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 10)\n`); + expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (daemon protocol 7, browser protocol 10)\n`); }); }); @@ -179,7 +179,7 @@ describe('launcher', () => { expectInstalled(await installLocal([], { HOME: home })); const [program, ...args] = desktopExec(iniGroup(readFileSync(installPaths(home).launcher, 'utf8'), 'Desktop Entry').get('Exec') ?? ''); const result = spawnSync(program ?? '', [...args.slice(0, -1), '--version'], { env: host.env({ HOME: home }), encoding: 'utf8' }); - expect(result.stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 10)\n`); + expect(result.stdout).toBe(`wtd ${packageVersion()} (daemon protocol 7, browser protocol 10)\n`); }); it('writes no unit file without --systemd', async () => { @@ -206,7 +206,7 @@ describe('--systemd', () => { ['--user', 'start', UNIT_NAME], ]); await waitUntil(() => canConnect(host.socket), 'the daemon the unit started', 10_000); - expect((await (await client()).waitFor('hello')).protocol).toBe(6); + expect((await (await client()).waitFor('hello')).protocol).toBe(7); }); it('writes an owner-only unit running the shim with daemon, restarting on failure, for default.target', async () => { @@ -316,7 +316,7 @@ describe('reinstalling', () => { await after.attach(term.termId); after.sendInput(term.termId, '\r'); await after.waitOutput(term.termId, 'after-reinstall'); - expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 10)\n`); + expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (daemon protocol 7, browser protocol 10)\n`); }); it('runs the newest release through the shim after a reinstall', async () => { From 9b15aa6ba7a027e896de9e2d42b3f7a74bdacfa6 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 26 Oct 2026 11:48:19 +0100 Subject: [PATCH 06/11] A terminal whose process exits cleanly closes its tab --- .../tasks.md | 14 ++--- src/daemon/server/daemon.ts | 60 ++++++++++++------- src/daemon/terminals/terminal.ts | 3 +- src/protocol/constants.ts | 2 +- src/protocol/wire.golden.json | 2 +- src/web/main/styles.css | 4 -- src/web/ui/app.tsx | 17 ++++-- src/web/ui/marks.ts | 9 ++- 8 files changed, 66 insertions(+), 45 deletions(-) diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md index 76eea16..03bc974 100644 --- a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md +++ b/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md @@ -9,19 +9,19 @@ ## 2. Daemon -- [ ] 2.1 Add `retire(terms)` in `src/daemon/server/daemon.ts` per design D1 and route `closeTerm` and `removeWorktree` through it; `shutdown` keeps only process ending; verify 1.1 and existing daemon tests pass -- [ ] 2.2 Branch the `exited` handler in `createTerm` on clean vs failed (design D2); verify 1.1 and 1.3 pass -- [ ] 2.3 In `TerminalProcess.onExit` set `unseen` only for a failed exit; verify 1.2 and the unseen process tests pass +- [x] 2.1 Add `retire(terms)` in `src/daemon/server/daemon.ts` per design D1 and route `closeTerm` and `removeWorktree` through it; `shutdown` keeps only process ending; verify 1.1 and existing daemon tests pass +- [x] 2.2 Branch the `exited` handler in `createTerm` on clean vs failed (design D2); verify 1.1 and 1.3 pass +- [x] 2.3 In `TerminalProcess.onExit` set `unseen` only for a failed exit; verify 1.2 and the unseen process tests pass ## 3. Protocol -- [ ] 3.1 Raise `DAEMON_PROTOCOL_VERSION` to 7 in `src/protocol/constants.ts` and re-bless `src/protocol/wire.golden.json` with schemas unchanged; verify 1.4 passes +- [x] 3.1 Raise `DAEMON_PROTOCOL_VERSION` to 7 in `src/protocol/constants.ts` and re-bless `src/protocol/wire.golden.json` with schemas unchanged; verify 1.4 passes ## 4. Web -- [ ] 4.1 Drop the plain `exited` mark (`src/web/ui/marks.ts`, `.mark-exited` in `src/web/main/styles.css`); verify 1.5 and the attention e2e pass -- [ ] 4.2 Choices focus rule (design D6) in `src/web/ui/app.tsx` / `src/web/ui/view.ts`; verify the choices e2e scenarios in 1.6 pass +- [x] 4.1 Drop the plain `exited` mark (`src/web/ui/marks.ts`, `.mark-exited` in `src/web/main/styles.css`); verify 1.5 and the attention e2e pass +- [x] 4.2 Choices focus rule (design D6) in `src/web/ui/app.tsx` / `src/web/ui/view.ts`; verify the choices e2e scenarios in 1.6 pass ## 5. Gates -- [ ] 5.1 Run `pnpm lint`, `tsc -b`, `la-arch-check` and the full `pnpm test`; all green +- [x] 5.1 Run `pnpm lint`, `tsc -b`, `la-arch-check` and the full `pnpm test`; all green diff --git a/src/daemon/server/daemon.ts b/src/daemon/server/daemon.ts index 945f702..7554e3f 100644 --- a/src/daemon/server/daemon.ts +++ b/src/daemon/server/daemon.ts @@ -367,7 +367,12 @@ export class Daemon { this.broadcast(repo, { t: 'activity', termId, unseen, state }); }, exited: (code, signal) => { - this.broadcast(repo, { t: 'termExited', termId, code, signal }); + if (code !== 0 || signal !== null) { + this.broadcast(repo, { t: 'termExited', termId, code, signal }); + return; + } + const term = this.terminals.get(termId); + if (term !== undefined) void this.retire([term]); }, }, ); @@ -425,9 +430,33 @@ export class Daemon { } private async closeTerm(peer: Peer, termId: number): Promise { - const term = this.access(peer, termId); - this.closeTerminal(term); - const { repo, worktree } = term; + const failure = await this.retire([this.access(peer, termId)]); + if (failure !== null) throw new RequestError('internal', `terminal closed, but its layout could not be saved: ${failure.message}`); + } + + /** + * Closes `terms`, drops them from their worktrees' layouts, then sends `termClosed` for each. Resolves + * with a layout write failure, already reported, or null. + */ + private async retire(terms: readonly TermEntry[]): Promise { + for (const term of terms) this.closeTerminal(term); + let failure: Error | null = null; + const worktrees = new Map(terms.map((t) => [t.worktree, t.repo])); + for (const [worktree, repo] of worktrees) { + try { + await this.pruneLayoutOf(repo, worktree); + } catch (error) { + this.options.report(error); + failure ??= error instanceof Error ? error : new Error(String(error)); + } + } + for (const term of terms) this.broadcast(term.repo, { t: 'termClosed', termId: term.termId }); + for (const repo of new Set(worktrees.values())) void this.prune(repo); + return failure; + } + + /** Removes terminals that are no longer live from `worktree`'s layout. */ + private async pruneLayoutOf(repo: string, worktree: string): Promise { const layout = this.options.store.layout(repo, worktree); const live = new Set( this.terminalsOf(repo) @@ -435,19 +464,9 @@ export class Daemon { .map((t) => t.termId), ); const pruned = pruneLayout(layout, live); - let failure: Error | null = null; - if (JSON.stringify(pruned) !== JSON.stringify(layout)) { - try { - await this.options.store.setLayout(repo, worktree, pruned); - this.broadcast(repo, { t: 'layoutChanged', worktree, layout: pruned }); - } catch (error) { - this.options.report(error); - failure = error instanceof Error ? error : new Error(String(error)); - } - } - this.broadcast(repo, { t: 'termClosed', termId }); - void this.prune(repo); - if (failure !== null) throw new RequestError('internal', `terminal closed, but its layout could not be saved: ${failure.message}`); + if (JSON.stringify(pruned) === JSON.stringify(layout)) return; + await this.options.store.setLayout(repo, worktree, pruned); + this.broadcast(repo, { t: 'layoutChanged', worktree, layout: pruned }); } /** @@ -483,11 +502,8 @@ export class Daemon { } } await this.options.services.removeWorktree(repo, worktree, m.force); - for (const term of this.terminalsOf(repo).filter((t) => t.worktree === m.worktree)) { - this.closeTerminal(term); - this.broadcast(repo, { t: 'termClosed', termId: term.termId }); - } - void this.prune(repo); + // A failed layout write is reported; the removed worktree's entry is pruned anyway. + await this.retire(this.terminalsOf(repo).filter((t) => t.worktree === m.worktree)); return true; } diff --git a/src/daemon/terminals/terminal.ts b/src/daemon/terminals/terminal.ts index d82da81..a7d4442 100644 --- a/src/daemon/terminals/terminal.ts +++ b/src/daemon/terminals/terminal.ts @@ -349,7 +349,8 @@ export class TerminalProcess { this.mirror.write('', () => { if (this.closing) return; this.attention.exit(); - if (!this.visible && !this.unseen) { + const failed = status.code !== 0 || status.signal !== null; + if (failed && !this.visible && !this.unseen) { this.unseen = true; this.report(); } diff --git a/src/protocol/constants.ts b/src/protocol/constants.ts index 1fc93ec..3b2f5c0 100644 --- a/src/protocol/constants.ts +++ b/src/protocol/constants.ts @@ -2,7 +2,7 @@ const KiB = 1024; const MiB = 1024 * KiB; /** Version of the daemon link (client ↔ daemon), whose two ends can run different releases. */ -export const DAEMON_PROTOCOL_VERSION = 6; +export const DAEMON_PROTOCOL_VERSION = 7; /** Version of the browser link (browser ↔ hub); the hub serves the page, so a bump needs no daemon restart. */ export const BROWSER_PROTOCOL_VERSION = 10; diff --git a/src/protocol/wire.golden.json b/src/protocol/wire.golden.json index e2b1d45..bb0ac90 100644 --- a/src/protocol/wire.golden.json +++ b/src/protocol/wire.golden.json @@ -1,6 +1,6 @@ { "protocolVersions": { - "daemon": 6, + "daemon": 7, "browser": 10 }, "constants": { diff --git a/src/web/main/styles.css b/src/web/main/styles.css index 4cab22b..a0ba24a 100644 --- a/src/web/main/styles.css +++ b/src/web/main/styles.css @@ -674,10 +674,6 @@ dialog.add-repo-dialog { background: var(--danger); } -.mark-exited { - background: var(--muted); -} - .mark-done { background: #23d18b; } diff --git a/src/web/ui/app.tsx b/src/web/ui/app.tsx index 453c6d4..7f971b0 100644 --- a/src/web/ui/app.tsx +++ b/src/web/ui/app.tsx @@ -962,16 +962,18 @@ const PresetPicker = ({ view, presets }: { view: View; presets: readonly Preset[ ); }; -/** The presets a worktree without terminals offers. */ -const PresetChoices = ({ view, presets }: { view: View; presets: readonly Preset[] }) => { +/** The presets a worktree without terminals offers; with `emptied`, they take a keyboard focus left on the page body. */ +const PresetChoices = ({ view, presets, emptied }: { view: View; presets: readonly Preset[]; emptied: boolean }) => { const [index, setIndex] = useState(0); + // Decided at mount, after the closed terminal's xterm left the document. + const [focus] = useState(() => emptied && (document.activeElement === null || document.activeElement === document.body)); return (
Start a terminal
{ const preset = presets[i]; @@ -1059,6 +1061,13 @@ const TerminalArea = ({ client, manager, view }: AppProps) => { const presets = client.store.presets.value; const focused = view.focused.value; const empty = view.termTabs.value.tabs.length === 0; + const worktree = view.worktree.value; + /** The worktree and whether it was empty at the last render. */ + const last = useRef<{ worktree: string | null; empty: boolean }>({ worktree, empty }); + const emptied = empty && last.current.worktree === worktree && !last.current.empty; + useLayoutEffect(() => { + last.current = { worktree, empty }; + }); return (
{view.panes.value.map((pane) => ( @@ -1073,7 +1082,7 @@ const TerminalArea = ({ client, manager, view }: AppProps) => { {view.dividers.value.map((divider) => ( ))} - {empty && view.canCreate.value && presets !== null && } + {empty && view.canCreate.value && presets !== null && } {presets !== null && }
); diff --git a/src/web/ui/marks.ts b/src/web/ui/marks.ts index 72730e0..1db654f 100644 --- a/src/web/ui/marks.ts +++ b/src/web/ui/marks.ts @@ -1,7 +1,7 @@ import type { Terminal } from '../../protocol/index.js'; /** Attention marks in rank order. */ -export const MARK_ORDER = ['input', 'failed', 'exited', 'done', 'output'] as const; +export const MARK_ORDER = ['input', 'failed', 'done', 'output'] as const; export type Mark = (typeof MARK_ORDER)[number]; @@ -13,10 +13,10 @@ export interface Aggregate { counts: Partial>; } -/** The terminal's mark: the first of needs input, failed, exited, done and new output that applies. */ +/** The terminal's mark: the first of needs input, failed (any exit), done and new output that applies. */ export const mark = (t: Terminal): Mark | null => { if (t.state === 'input') return 'input'; - if (t.exit !== null) return t.exit.code !== 0 || t.exit.signal !== null ? 'failed' : 'exited'; + if (t.exit !== null) return 'failed'; if (!t.unseen) return null; return t.state === 'idle' ? 'done' : 'output'; }; @@ -39,8 +39,7 @@ export const markTitle = (counts: Aggregate['counts']): string => /** What a terminal is doing, in words: waiting for input, running, done, idle, or how it exited. */ export const terminalStatus = (t: Terminal): string => { if (t.exit !== null) { - if (t.exit.signal !== null) return `killed by ${t.exit.signal}`; - return t.exit.code === 0 ? 'exited' : `exited with code ${String(t.exit.code)}`; + return t.exit.signal === null ? `exited with code ${String(t.exit.code)}` : `killed by ${t.exit.signal}`; } if (t.state === 'input') return 'waiting for input'; if (t.state === 'working') return 'running'; From d1261ce45aa9303cda5153354d68431f866c1122 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 26 Oct 2026 13:36:36 +0100 Subject: [PATCH 07/11] Observe a retired tunnel listener closing on a connection it accepted, not on its freed port --- test/process/hub-router-opening.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/process/hub-router-opening.test.ts b/test/process/hub-router-opening.test.ts index 9c08457..d5a18fe 100644 --- a/test/process/hub-router-opening.test.ts +++ b/test/process/hub-router-opening.test.ts @@ -1,3 +1,4 @@ +import { once } from 'node:events'; import { readFileSync } from 'node:fs'; import { connect, createServer, type Socket } from 'node:net'; import { join } from 'node:path'; @@ -576,12 +577,17 @@ describe('hub tunnels', () => { configure([{ name: 'box', ssh: 'box', port: oldPort }]); const client = await fx.host.session(); expect(opened(await openHub(client, 1)).port).toBe(oldPort); + // Once freed, the old port may be bound by any other process, so the old listener's closing is + // observed on a connection it accepted. + const old = await tcp(oldPort); + old.on('error', () => undefined); + const oldClosed = once(old, 'close').then(() => true); configure([{ name: 'box', ssh: 'box', port: newPort }]); const { port, fragment } = opened(await openHub(client, 1)); expect(port).toBe(newPort); const { client: page } = await tunneled(newPort, fragment.code); page.close(); - await waitUntil(() => refused(oldPort), 'the old listener to close', 5000); + expect(await Promise.race([oldClosed, sleep(5000).then(() => false)])).toBe(true); }, INSTALL_TIMEOUT, ); From a399d63bfa440b27cfd7b9dd38556b923aaedab9 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 26 Oct 2026 15:30:55 +0100 Subject: [PATCH 08/11] Address review: la tools 0.4.1 in CI, safer wtd-dev-restart, reduced motion, deduplicated tests - CI installs living-architecture 0.4.1 from npm, which reads the format living-architecture.yaml records - wtd-dev-restart stops the hub through its authenticated API, skips SSH control sockets, refuses a non-Vite process on the Vite port and waits for the new Vite to own it - retire prunes layouts without awaiting in a loop; closeTerm's layout write failure is tested - the starred switch does not animate under prefers-reduced-motion - shared helpers replace duplicated test setup --- .github/actions/setup/action.yml | 21 +---- README.md | 2 +- scripts/wtd-dev-restart | 82 ++++++++++++++----- src/daemon/server/daemon.test.ts | 19 +++++ src/daemon/server/daemon.ts | 14 ++-- src/web/main/styles.css | 7 ++ test/e2e/closing.spec.ts | 39 +++++---- test/e2e/sidebar.spec.ts | 12 +++ test/e2e/splits.spec.ts | 34 ++++---- .../daemon-terminals-attention.test.ts | 17 +--- .../daemon-terminals-lifecycle.test.ts | 21 +++-- test/process/daemon-terminals-unseen.test.ts | 51 ++++-------- test/process/hub-router-opening.test.ts | 7 +- test/support/daemon-terminals.ts | 15 +++- 14 files changed, 191 insertions(+), 150 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 41ef84c..cb56119 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -3,7 +3,7 @@ description: pnpm, Node, git identity and dependencies; optionally the living-ar inputs: arch-tools: - description: Install la-arch-check and its TypeScript twin, which the architecture tests run. + description: Install la-arch-check, which the architecture tests run. required: false default: 'false' @@ -15,25 +15,10 @@ runs: with: node-version: 22 cache: pnpm - - if: inputs.arch-tools == 'true' - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 - # la-arch-check and its TypeScript twin, which is not on npm. - - if: inputs.arch-tools == 'true' - shell: bash - run: uv tool install --no-build living-architecture==0.3.1 + # The version must read the format that living-architecture.yaml records. - if: inputs.arch-tools == 'true' shell: bash - run: | - twin="$RUNNER_TEMP/living-architecture" - git init -q "$twin" - git -C "$twin" fetch -q --depth 1 https://github.com/MotleyAI/living-architecture.git 15ed4263ccce08513535aae077c8e7ba7363571d # v0.3.1 - git -C "$twin" checkout -q FETCH_HEAD - cd "$twin/node" - npm ci --ignore-scripts - npm run build - mkdir "$twin/bin" - for file in dist/bin/*.js; do ln -s "$PWD/$file" "$twin/bin/$(basename "$file" .js)"; done - echo "$twin/bin" >> "$GITHUB_PATH" + run: npm install -g --ignore-scripts living-architecture@0.4.1 - shell: bash run: git config --global user.name ci && git config --global user.email ci@example.com - shell: bash diff --git a/README.md b/README.md index 0935906..8228141 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ Panes, terminal tabs, worktree rows and repo tabs show the most urgent mark of t ## Development -Requires Node 22 (`.nvmrc`), pnpm, and the `la-*` architecture tools on `PATH` (`uv tool install living-architecture==0.3.1`), which `pnpm test` runs. +Requires Node 22 (`.nvmrc`), pnpm, and the `la-*` architecture tools on `PATH` (`npm install -g living-architecture@0.4.1`), which `pnpm test` runs. ```sh pnpm install diff --git a/scripts/wtd-dev-restart b/scripts/wtd-dev-restart index b88854f..d580e94 100755 --- a/scripts/wtd-dev-restart +++ b/scripts/wtd-dev-restart @@ -53,17 +53,28 @@ stop_pid() { exit 1 } -# Waits up to $2 tenths of a second for something to listen on TCP port $1. -wait_port() { - for _ in $(seq "$2"); do - ss -Hltn "sport = :$1" | grep -q . && return 0 - sleep 0.1 - done - return 1 +listening() { ss -Hltn "sport = :$1" | grep -q .; } + +# Pid of a process listening on TCP port $1, if any. +port_pid() { ss -Hltnp "sport = :$1" | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2 || true; } + +hub_record() { [[ -f $STATE/run/hub.json ]] && sed -n "s/.*\"$1\":\"\{0,1\}\([A-Za-z0-9_-]*\).*/\1/p" "$STATE/run/hub.json" || true; } + +# Calls $1 /api/$2 on the hub on port $3 with the hub token, which stays off the command line. +hub_api() { + local token + token=$(cat "$STATE/hub-token" 2>/dev/null) || return 1 + printf 'Authorization: Bearer %s\n' "$token" | curl -sf --max-time 2 -X "$1" -H @- "http://127.0.0.1:$3/api/$2" } -hub_pid() { [[ -f $STATE/run/hub.json ]] && sed -n 's/.*"pid":\([0-9]*\).*/\1/p' "$STATE/run/hub.json" || true; } -hub_port() { [[ -f $STATE/run/hub.json ]] && sed -n 's/.*"port":\([0-9]*\).*/\1/p' "$STATE/run/hub.json" || true; } +# The instance of the hub that answers for its record, if any. +hub_instance() { + local port instance + port=$(hub_record port) instance=$(hub_record instance) + [[ -n $port && -n $instance ]] || return 0 + hub_api GET identity "$port" 2>/dev/null | grep -qF "\"instance\":\"$instance\"" && echo "$instance" + return 0 +} cd "$REPO" mkdir -p "$STATE" @@ -73,35 +84,62 @@ if ! pnpm build >"$STATE/build.log" 2>&1; then exit 1 fi -old_hub=$(hub_pid) -[[ -n $old_hub ]] && stop_pid "$old_hub" hub +vite_pid=$(port_pid "$VITE_PORT") +if [[ -n $vite_pid ]]; then + if ! tr '\0' ' ' <"/proc/$vite_pid/cmdline" 2>/dev/null | grep -q vite; then + echo "wtd-dev-restart: port $VITE_PORT is taken by pid $vite_pid, which is not Vite" >&2 + exit 1 + fi + stop_pid "$vite_pid" "Vite dev server" +fi + +# The hub is stopped through its authenticated API, never by a pid from its record, which may be stale. +old_hub=$(hub_instance) +if [[ -n $old_hub ]]; then + old_port=$(hub_record port) + log "stopping the hub (port $old_port)" + hub_api POST shutdown "$old_port" >/dev/null + for _ in $(seq 100); do + listening "$old_port" || break + sleep 0.1 + done + if listening "$old_port"; then + echo "wtd-dev-restart: the hub on port $old_port did not stop" >&2 + exit 1 + fi +fi if ((!keep_daemon)); then - # Daemons of this user's state directory, not the test daemons under /tmp. - for pid in $(ss -Hxlp | grep -F "$STATE/run/" | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u || true); do + # Daemons of this user's state directory, not the test daemons under /tmp nor SSH control masters. + for pid in $(ss -Hxlp | grep -F "$STATE/run/" | grep -E '/[^/ ]+\.sock ' | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u || true); do stop_pid "$pid" daemon done fi -vite_pid=$(ss -Hltnp "sport = :$VITE_PORT" | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2 || true) -[[ -n $vite_pid ]] && stop_pid "$vite_pid" "Vite dev server" - log "starting the hub (log: $STATE/hub.log)" setsid node dist/wtd.mjs hub >>"$STATE/hub.log" 2>&1 /dev/null && wait_port "$(hub_port)" 1; then break; fi + hub=$(hub_instance) + [[ -n $hub && $hub != "$old_hub" ]] && break sleep 0.1 done -pid=$(hub_pid) -if [[ -z $pid || $pid == "$old_hub" ]]; then +if [[ -z $hub || $hub == "$old_hub" ]]; then echo "wtd-dev-restart: the hub did not start; see $STATE/hub.log" >&2 exit 1 fi log "starting Vite (log: $STATE/vite.log)" setsid pnpm dev >"$STATE/vite.log" 2>&1 /dev/null || break + sleep 0.1 +done +if [[ -z $pid ]]; then echo "wtd-dev-restart: Vite did not start; see $STATE/vite.log" >&2 exit 1 fi @@ -110,4 +148,4 @@ url="http://127.0.0.1:$VITE_PORT/login" if ((open_browser)); then setsid "$BROWSER" --app="$url" >/dev/null 2>&1 { expect(reported).toContain(failure); }); + it('closes a terminal on closeTerm whose layout cannot be saved, answering with the failure', async () => { + const client = await connected(); + await client.watch(REPO); + const term = await start(client, WT, 'exec sleep 60'); + const staying = await start(client, WT, 'exec sleep 60'); + await client.ok({ t: 'setLayout', worktree: WT, layout: twoPanes(term, staying) }); + const failure = new Error('disk full'); + vi.spyOn(store, 'setLayout').mockRejectedValue(failure); + const from = client.mark(); + expect(await client.request({ t: 'closeTerm', termId: term })).toEqual({ + t: 'error', + req: ANY_REQ, + code: 'internal', + message: 'terminal closed, but its layout could not be saved: disk full', + }); + expect(client.messages.slice(from).filter((m) => m.t === 'termClosed')).toEqual([{ t: 'termClosed', termId: term }]); + expect(reported).toContain(failure); + }); + it('writes the layout of a removed worktree once, then sends termClosed for each of its terminals', async () => { const client = await connected(); await client.watch(REPO); diff --git a/src/daemon/server/daemon.ts b/src/daemon/server/daemon.ts index 7554e3f..bb21556 100644 --- a/src/daemon/server/daemon.ts +++ b/src/daemon/server/daemon.ts @@ -440,15 +440,13 @@ export class Daemon { */ private async retire(terms: readonly TermEntry[]): Promise { for (const term of terms) this.closeTerminal(term); - let failure: Error | null = null; const worktrees = new Map(terms.map((t) => [t.worktree, t.repo])); - for (const [worktree, repo] of worktrees) { - try { - await this.pruneLayoutOf(repo, worktree); - } catch (error) { - this.options.report(error); - failure ??= error instanceof Error ? error : new Error(String(error)); - } + const pruned = await Promise.allSettled([...worktrees].map(([worktree, repo]) => this.pruneLayoutOf(repo, worktree))); + let failure: Error | null = null; + for (const result of pruned) { + if (result.status === 'fulfilled') continue; + this.options.report(result.reason); + failure ??= result.reason instanceof Error ? result.reason : new Error(String(result.reason)); } for (const term of terms) this.broadcast(term.repo, { t: 'termClosed', termId: term.termId }); for (const repo of new Set(worktrees.values())) void this.prune(repo); diff --git a/src/web/main/styles.css b/src/web/main/styles.css index a0ba24a..45c2114 100644 --- a/src/web/main/styles.css +++ b/src/web/main/styles.css @@ -332,6 +332,13 @@ input:focus-visible, transform: translateX(12px); } +@media (prefers-reduced-motion: reduce) { + .star-filter input, + .star-filter input::after { + transition: none; + } +} + .worktree { display: flex; align-items: center; diff --git a/test/e2e/closing.spec.ts b/test/e2e/closing.spec.ts index 5a24c04..f92f927 100644 --- a/test/e2e/closing.spec.ts +++ b/test/e2e/closing.spec.ts @@ -1,4 +1,6 @@ import type { Locator, Page } from '@playwright/test'; +import type { DaemonClient } from '../support/daemon-client.js'; +import type { HubHost } from '../support/hub-host.js'; import { byTestId, pane, termTab, TID } from './contract.js'; import { confirmClose, @@ -40,6 +42,23 @@ const twoPanes = async (page: Page, preset: 'shell' | 'cat' = 'shell'): Promise< return { left, right }; }; +/** Opens the close dialog on a fresh two-pane tab; `mark` precedes the click. */ +const twoPaneDialog = async ( + hub: HubHost, + page: Page, + wire: WireRecorder, +): Promise<{ client: DaemonClient; left: number; right: number; mark: number }> => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const client = await watcher(hub, repo); + const { left, right } = await twoPanes(page); + const mark = wire.markSent(); + await page.locator(termTab(left)).locator(byTestId(TID.termTabClose)).click(); + await expect(dialogOf(page)).toBeVisible(); + return { client, left, right, mark }; +}; + test.describe('closing terminals', () => { test('Ctrl+Shift+W on a running pane and then Escape closes nothing and the shell keeps running', async ({ hub, page, wire }) => { const { repo } = makeRepoWith(hub, 'app', []); @@ -167,15 +186,7 @@ test.describe('closing terminals', () => { }); test('the dialog closes by itself when its terminals are closed elsewhere', async ({ hub, page, wire }) => { - const { repo } = makeRepoWith(hub, 'app', []); - hub.writeRepos([repo]); - await openUi(hub, page); - const client = await watcher(hub, repo); - const { left, right } = await twoPanes(page); - const mark = wire.markSent(); - - await page.locator(termTab(left)).locator(byTestId(TID.termTabClose)).click(); - await expect(dialogOf(page)).toBeVisible(); + const { client, left, right, mark } = await twoPaneDialog(hub, page, wire); await client.ok({ t: 'closeTerm', termId: left }); await client.ok({ t: 'closeTerm', termId: right }); await expect(dialogOf(page)).toHaveCount(0); @@ -202,15 +213,7 @@ test.describe('closing terminals', () => { }); test('confirming after one target was closed elsewhere closes only the one still live', async ({ hub, page, wire }) => { - const { repo } = makeRepoWith(hub, 'app', []); - hub.writeRepos([repo]); - await openUi(hub, page); - const client = await watcher(hub, repo); - const { left, right } = await twoPanes(page); - const mark = wire.markSent(); - - await page.locator(termTab(left)).locator(byTestId(TID.termTabClose)).click(); - await expect(dialogOf(page)).toBeVisible(); + const { client, left, right, mark } = await twoPaneDialog(hub, page, wire); await client.ok({ t: 'closeTerm', termId: right }); await expect.poll(() => paneIds(page)).toEqual([left]); await confirmClose(page); diff --git a/test/e2e/sidebar.spec.ts b/test/e2e/sidebar.spec.ts index bc9b246..1297ec2 100644 --- a/test/e2e/sidebar.spec.ts +++ b/test/e2e/sidebar.spec.ts @@ -234,6 +234,18 @@ test.describe('checked filter', () => { expect(await animating()).toBe(false); }); + test('a click does not animate the switch when reduced motion is preferred', async ({ hub, page }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await openUi(hub, page); + await page.addStyleTag({ content: '* , *::before, *::after { transition-duration: 10s !important; }' }); + const toggle = page.locator(byTestId(TID.filterChecked)); + + await toggle.check(); + expect(await toggle.evaluate((input) => input.getAnimations({ subtree: true }).length)).toBe(0); + }); + test('the selected worktree stays listed and selected when unchecked under "checked only"', async ({ hub, page, wire }) => { const { repo, worktrees } = makeRepoWith(hub, 'app', ['a', 'b']); const [a = '', b = ''] = worktrees; diff --git a/test/e2e/splits.spec.ts b/test/e2e/splits.spec.ts index 10f9b08..c275d4a 100644 --- a/test/e2e/splits.spec.ts +++ b/test/e2e/splits.spec.ts @@ -1,6 +1,7 @@ import { chmodSync } from 'node:fs'; import type { Page } from '@playwright/test'; import type { DaemonClient } from '../support/daemon-client.js'; +import type { HubHost } from '../support/hub-host.js'; import { byTestId, pane, terminalBox, termTab, TID } from './contract.js'; import { confirmClose, @@ -494,41 +495,40 @@ test.describe('pane limit and concurrent splits', () => { }); test.describe('pane focus', () => { - test('closing the focused pane of a two-pane split focuses the other, which receives typed input', async ({ hub, page }) => { + /** A two-pane split whose new right pane has the focus. */ + const focusedSplit = async (hub: HubHost, page: Page): Promise<{ left: number; right: number }> => { const { repo } = makeRepoWith(hub, 'app', []); hub.writeRepos([repo]); await openUi(hub, page); const left = await newTerminal(page); await ready(page, left); const right = await splitPane(page, 'right'); + await ready(page, right); await expect.poll(() => focusedPane(page)).toBe(right); + return { left, right }; + }; - await page.locator(pane(right)).locator(byTestId(TID.paneClose)).click(); - await confirmClose(page); + /** Waits until only `left` remains, focused and taking typed input. */ + const leftTakesFocus = async (page: Page, left: number): Promise => { await expect.poll(() => paneIds(page)).toEqual([left]); await expect.poll(() => focusedPane(page)).toBe(left); await page.keyboard.type("echo FOCUS''-OK"); await page.keyboard.press('Enter'); await waitScreen(page, left, 'FOCUS-OK'); + }; + + test('closing the focused pane of a two-pane split focuses the other, which receives typed input', async ({ hub, page }) => { + const { left, right } = await focusedSplit(hub, page); + await page.locator(pane(right)).locator(byTestId(TID.paneClose)).click(); + await confirmClose(page); + await leftTakesFocus(page, left); }); test('`exit` in the focused pane of a two-pane split focuses the other, which receives typed input', async ({ hub, page }) => { - const { repo } = makeRepoWith(hub, 'app', []); - hub.writeRepos([repo]); - await openUi(hub, page); - const left = await newTerminal(page); - await ready(page, left); - const right = await splitPane(page, 'right'); - await ready(page, right); - await expect.poll(() => focusedPane(page)).toBe(right); - + const { left } = await focusedSplit(hub, page); await page.keyboard.type('exit'); await page.keyboard.press('Enter'); - await expect.poll(() => paneIds(page)).toEqual([left]); - await expect.poll(() => focusedPane(page)).toBe(left); - await page.keyboard.type("echo FOCUS''-OK"); - await page.keyboard.press('Enter'); - await waitScreen(page, left, 'FOCUS-OK'); + await leftTakesFocus(page, left); }); test('closing a focused pane focuses the first pane of the subtree that took its place', async ({ hub, page }) => { diff --git a/test/process/daemon-terminals-attention.test.ts b/test/process/daemon-terminals-attention.test.ts index f403a82..d9652c3 100644 --- a/test/process/daemon-terminals-attention.test.ts +++ b/test/process/daemon-terminals-attention.test.ts @@ -1,26 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import type { Terminal } from '../../src/protocol/index.js'; -import type { DaemonClient, EventOf } from '../support/daemon-client.js'; import { sleep, waitUntil } from '../support/daemon-host.js'; -import { fx, started, useDaemonTerminalsHost } from '../support/daemon-terminals.js'; +import { activityOf, entryOf, fx, isInput, started, useDaemonTerminalsHost } from '../support/daemon-terminals.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useDaemonTerminalsHost(); describe('activity', () => { - /** Activity messages about `termId` that `client` received from index `from`. */ - const activityOf = (client: DaemonClient, termId: number, from: number): EventOf<'activity'>[] => - client.messages.slice(from).filter((m): m is EventOf<'activity'> => m.t === 'activity' && m.termId === termId); - - const isInput = - (termId: number): ((m: EventOf<'activity'>) => boolean) => - (m) => - m.termId === termId && m.state === 'input'; - - /** The terminal entry a fresh watcher of the repo receives. */ - const entryOf = async (termId: number): Promise => - (await (await fx.host.client()).watch(fx.repo)).terminals.find((t) => t.termId === termId); - it('sets input on a bell, also in the terminal entry', async () => { const term = await fx.a.create(fx.wt, { command: String.raw`sleep 0.3; printf '\a'; exec sleep 60` }); expect(await fx.a.waitFor('activity', isInput(term.termId))).toEqual({ diff --git a/test/process/daemon-terminals-lifecycle.test.ts b/test/process/daemon-terminals-lifecycle.test.ts index 192a69c..b29ac37 100644 --- a/test/process/daemon-terminals-lifecycle.test.ts +++ b/test/process/daemon-terminals-lifecycle.test.ts @@ -1,7 +1,7 @@ import { chmodSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import type { Layout } from '../../src/protocol/index.js'; +import type { Layout, Terminal } from '../../src/protocol/index.js'; import type { DaemonClient, Event, RequestBody } from '../support/daemon-client.js'; import { addWorktree, alive, git, sleep, waitUntil } from '../support/daemon-host.js'; import { fx, restart, started, useDaemonTerminalsHost } from '../support/daemon-terminals.js'; @@ -375,15 +375,21 @@ describe('persistence and pruning', () => { expect((await (await fx.host.client()).watch(fx.repo)).checked).toEqual([]); }); - it('keeps a removed worktree with a live terminal until the terminal is closed', async () => { + /** A checked worktree with a terminal running `command`, removed with `--force`; its state entry is kept. */ + const removedWithTerminal = async (command: string): Promise<{ gone: string; term: Terminal }> => { const gone = addWorktree(fx.repo, join(fx.host.dir, 'gone'), 'gone'); await fx.a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); - const term = await fx.a.create(gone, { command: 'exec sleep 600' }); + const term = await fx.a.create(gone, { command }); await fx.a.ok({ t: 'setChecked', worktree: gone, checked: true }); git(fx.repo, 'worktree', 'remove', '--force', gone); await fx.a.waitFor('worktreesChanged', (m) => !m.worktrees.some((w) => w.path === gone)); await sleep(500); expect(stateMentions(gone)).toBe(true); + return { gone, term }; + }; + + it('keeps a removed worktree with a live terminal until the terminal is closed', async () => { + const { gone, term } = await removedWithTerminal('exec sleep 600'); const state = await (await fx.host.client()).watch(fx.repo); expect(state.terminals.map((t) => t.worktree)).toEqual([gone]); await fx.a.ok({ t: 'closeTerm', termId: term.termId }); @@ -391,14 +397,7 @@ describe('persistence and pruning', () => { }); it('prunes a removed worktree once its terminal exits cleanly', async () => { - const gone = addWorktree(fx.repo, join(fx.host.dir, 'gone'), 'gone'); - await fx.a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); - const term = await fx.a.create(gone, { command: 'read -r _; exit 0' }); - await fx.a.ok({ t: 'setChecked', worktree: gone, checked: true }); - git(fx.repo, 'worktree', 'remove', '--force', gone); - await fx.a.waitFor('worktreesChanged', (m) => !m.worktrees.some((w) => w.path === gone)); - await sleep(500); - expect(stateMentions(gone)).toBe(true); + const { gone, term } = await removedWithTerminal('read -r _; exit 0'); fx.a.sendInput(term.termId, '\r'); await fx.a.waitFor('termClosed', (m) => m.termId === term.termId); await waitUntil(() => !stateMentions(gone), 'the entry to be pruned', 2000); diff --git a/test/process/daemon-terminals-unseen.test.ts b/test/process/daemon-terminals-unseen.test.ts index 995c3aa..c08358e 100644 --- a/test/process/daemon-terminals-unseen.test.ts +++ b/test/process/daemon-terminals-unseen.test.ts @@ -1,26 +1,23 @@ import { describe, expect, it, vi } from 'vitest'; import type { Terminal } from '../../src/protocol/index.js'; -import type { DaemonClient, EventOf } from '../support/daemon-client.js'; import { sleep } from '../support/daemon-host.js'; -import { fx, started, useDaemonTerminalsHost } from '../support/daemon-terminals.js'; +import { activityOf, entryOf, fx, isInput, started, useDaemonTerminalsHost } from '../support/daemon-terminals.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useDaemonTerminalsHost(); -describe('activity', () => { - /** Activity messages about `termId` that `client` received from index `from`. */ - const activityOf = (client: DaemonClient, termId: number, from: number): EventOf<'activity'>[] => - client.messages.slice(from).filter((m): m is EventOf<'activity'> => m.t === 'activity' && m.termId === termId); - - const isInput = - (termId: number): ((m: EventOf<'activity'>) => boolean) => - (m) => - m.termId === termId && m.state === 'input'; - - /** The terminal entry a fresh watcher of the repo receives. */ - const entryOf = async (termId: number): Promise => - (await (await fx.host.client()).watch(fx.repo)).terminals.find((t) => t.termId === termId); +/** Creates a terminal running `command` once `fx.a` shows it and sends a line, then hides it; `from` marks `fx.b`. */ +const runThenHide = async (command: string): Promise<{ term: Terminal; from: number }> => { + const term = await fx.a.create(fx.wt, { command: `read -r _; ${command}` }); + fx.a.send({ t: 'setVisible', termIds: [term.termId] }); + await fx.a.attach(term.termId); + fx.a.sendInput(term.termId, '\r'); + await sleep(400); + fx.a.send({ t: 'setVisible', termIds: [] }); + return { term, from: fx.b.mark() }; +}; +describe('activity', () => { it('flags output while hidden as unseen and working to every watcher', async () => { const term = await fx.a.create(fx.wt, { command: 'sleep 0.3; echo out; exec sleep 60' }); for (const client of [fx.a, fx.b]) { @@ -76,13 +73,7 @@ describe('activity', () => { }); it('flags a failed exit while hidden as unseen', async () => { - const term = await fx.a.create(fx.wt, { command: "read -r _; exec sh -c 'sleep 1; exit 1'" }); - fx.a.send({ t: 'setVisible', termIds: [term.termId] }); - await fx.a.attach(term.termId); - fx.a.sendInput(term.termId, '\r'); - await sleep(400); - fx.a.send({ t: 'setVisible', termIds: [] }); - const from = fx.b.mark(); + const { term, from } = await runThenHide("exec sh -c 'sleep 1; exit 1'"); const position = fx.a.view(term.termId).position; await fx.b.waitFor('termExited', (m) => m.termId === term.termId, { from }); expect(await fx.b.waitFor('activity', (m) => m.termId === term.termId && m.unseen, { from })).toEqual({ @@ -95,26 +86,14 @@ describe('activity', () => { }); it('closes a terminal exiting cleanly while hidden without flagging it unseen', async () => { - const term = await fx.a.create(fx.wt, { command: 'read -r _; exec sleep 1' }); - fx.a.send({ t: 'setVisible', termIds: [term.termId] }); - await fx.a.attach(term.termId); - fx.a.sendInput(term.termId, '\r'); - await sleep(400); - fx.a.send({ t: 'setVisible', termIds: [] }); - const from = fx.b.mark(); + const { term, from } = await runThenHide('exec sleep 1'); await fx.b.waitFor('termClosed', (m) => m.termId === term.termId, { from }); expect(activityOf(fx.b, term.termId, from).filter((m) => m.unseen)).toEqual([]); expect(fx.b.messages.slice(from).filter((m) => m.t === 'termExited')).toEqual([]); }); it('flags output just before a clean exit while hidden as unseen, then closes the terminal', async () => { - const term = await fx.a.create(fx.wt, { command: "read -r _; exec sh -c 'sleep 1; echo last-words'" }); - fx.a.send({ t: 'setVisible', termIds: [term.termId] }); - await fx.a.attach(term.termId); - fx.a.sendInput(term.termId, '\r'); - await sleep(400); - fx.a.send({ t: 'setVisible', termIds: [] }); - const from = fx.b.mark(); + const { term, from } = await runThenHide("exec sh -c 'sleep 1; echo last-words'"); await fx.b.waitFor('termClosed', (m) => m.termId === term.termId, { from }); const closedAt = fx.b.messages.findIndex((m, i) => i >= from && m.t === 'termClosed' && m.termId === term.termId); expect(fx.b.messages.slice(from, closedAt).some((m) => m.t === 'activity' && m.termId === term.termId && m.unseen)).toBe(true); diff --git a/test/process/hub-router-opening.test.ts b/test/process/hub-router-opening.test.ts index d5a18fe..28ce558 100644 --- a/test/process/hub-router-opening.test.ts +++ b/test/process/hub-router-opening.test.ts @@ -1,4 +1,3 @@ -import { once } from 'node:events'; import { readFileSync } from 'node:fs'; import { connect, createServer, type Socket } from 'node:net'; import { join } from 'node:path'; @@ -581,7 +580,11 @@ describe('hub tunnels', () => { // observed on a connection it accepted. const old = await tcp(oldPort); old.on('error', () => undefined); - const oldClosed = once(old, 'close').then(() => true); + const oldClosed = new Promise((resolve) => { + old.once('close', () => { + resolve(true); + }); + }); configure([{ name: 'box', ssh: 'box', port: newPort }]); const { port, fragment } = opened(await openHub(client, 1)); expect(port).toBe(newPort); diff --git a/test/support/daemon-terminals.ts b/test/support/daemon-terminals.ts index 3dc4f1e..2f3aabe 100644 --- a/test/support/daemon-terminals.ts +++ b/test/support/daemon-terminals.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach } from 'vitest'; import type { Terminal } from '../../src/protocol/index.js'; -import type { DaemonClient } from './daemon-client.js'; +import type { DaemonClient, EventOf } from './daemon-client.js'; import { addWorktree, DaemonHost, makeRepo, sleep, type WtdProcess } from './daemon-host.js'; /** Per-test values, refilled by the `beforeEach` that `useDaemonTerminalsHost` registers. */ @@ -59,6 +59,19 @@ export const started = async ( return term; }; +/** Activity messages about `termId` that `client` received from index `from`. */ +export const activityOf = (client: DaemonClient, termId: number, from: number): EventOf<'activity'>[] => + client.messages.slice(from).filter((m): m is EventOf<'activity'> => m.t === 'activity' && m.termId === termId); + +export const isInput = + (termId: number): ((m: EventOf<'activity'>) => boolean) => + (m) => + m.termId === termId && m.state === 'input'; + +/** The terminal entry a fresh watcher of the repo receives. */ +export const entryOf = async (termId: number): Promise => + (await (await fx.host.client()).watch(fx.repo)).terminals.find((t) => t.termId === termId); + /** Restarts the daemon with extra environment, re-watching from fresh clients. */ export const restart = async (env: Record = {}): Promise => { fx.a.send({ t: 'shutdown' }); From a4a01832472a9fb31cbc634090790bad2fec1fb2 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 26 Oct 2026 15:41:11 +0100 Subject: [PATCH 09/11] Prune state when removing a worktree without terminals; README describes clean exits closing terminals --- README.md | 4 ++-- src/daemon/server/daemon.test.ts | 11 +++++++++++ src/daemon/server/daemon.ts | 4 +++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8228141..74a2f5f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Three running components, all started from the one `wtd` binary: | 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. +- **The daemon manages the terminals.** A terminal lives until it is closed, its worktree is removed, or its program exits cleanly; one that fails stays open, marked, until closed. 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 "$HOME/.local/bin/wtd" connect` remotely. `wtd connect` only bridges SSH's stdio to the remote daemon's socket, starting the daemon if needed. @@ -142,7 +142,7 @@ The preset picker takes 1–9, the arrow keys and Enter, and Escape cancels it. ## Attention marks -Panes, terminal tabs, worktree rows and repo tabs show the most urgent mark of their terminals: needs input, exited with failure, exited, done (quiet after unseen output) and new output. A terminal needs input when its program rings the bell or sends a desktop notification (OSC 9, OSC 777 `notify` or OSC 99). Claude Code does so only when told to; put this in `~/.claude/settings.json`: +Panes, terminal tabs, worktree rows and repo tabs show the most urgent mark of their terminals: needs input, exited with failure, done (quiet after unseen output) and new output. A terminal needs input when its program rings the bell or sends a desktop notification (OSC 9, OSC 777 `notify` or OSC 99). Claude Code does so only when told to; put this in `~/.claude/settings.json`: ```json { "preferredNotifChannel": "terminal_bell" } diff --git a/src/daemon/server/daemon.test.ts b/src/daemon/server/daemon.test.ts index 1a6dcbe..958b38e 100644 --- a/src/daemon/server/daemon.test.ts +++ b/src/daemon/server/daemon.test.ts @@ -204,6 +204,17 @@ it('waits for a terminal still starting in a worktree being removed, and reports await process.close(); }); +it('prunes state of unlisted worktrees when removing a worktree without terminals', async () => { + const client = await connected(); + await client.watch(REPO); + await store.setChecked(REPO, '/r.worktrees/gone', true); + await client.ok({ t: 'removeWorktree', worktree: WT, force: true }); + await vi.waitFor(() => { + expect(store.paths(REPO)).not.toContain('/r.worktrees/gone'); + }); + expect(removed).toEqual([WT]); +}); + it('refuses to start a terminal in a worktree while it is being removed', async () => { const client = await connected(); await client.watch(REPO); diff --git a/src/daemon/server/daemon.ts b/src/daemon/server/daemon.ts index bb21556..26fb1d6 100644 --- a/src/daemon/server/daemon.ts +++ b/src/daemon/server/daemon.ts @@ -501,7 +501,9 @@ export class Daemon { } await this.options.services.removeWorktree(repo, worktree, m.force); // A failed layout write is reported; the removed worktree's entry is pruned anyway. - await this.retire(this.terminalsOf(repo).filter((t) => t.worktree === m.worktree)); + const terms = this.terminalsOf(repo).filter((t) => t.worktree === m.worktree); + if (terms.length > 0) await this.retire(terms); + else void this.prune(repo); return true; } From ae2652d9cfa32d5dba660440dfe673ea60059cd1 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 26 Oct 2026 15:44:13 +0100 Subject: [PATCH 10/11] wtd-dev-restart stops only a worktree-term checkout's Vite on the dev port --- scripts/wtd-dev-restart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/wtd-dev-restart b/scripts/wtd-dev-restart index d580e94..30f7e99 100755 --- a/scripts/wtd-dev-restart +++ b/scripts/wtd-dev-restart @@ -84,10 +84,12 @@ if ! pnpm build >"$STATE/build.log" 2>&1; then exit 1 fi +# Only a Vite serving a worktree-term checkout is ours to stop. vite_pid=$(port_pid "$VITE_PORT") if [[ -n $vite_pid ]]; then - if ! tr '\0' ' ' <"/proc/$vite_pid/cmdline" 2>/dev/null | grep -q vite; then - echo "wtd-dev-restart: port $VITE_PORT is taken by pid $vite_pid, which is not Vite" >&2 + vite_cwd=$(readlink "/proc/$vite_pid/cwd" || true) + if ! tr '\0' ' ' <"/proc/$vite_pid/cmdline" 2>/dev/null | grep -q vite || [[ ! -f $vite_cwd/scripts/wtd-dev-restart ]]; then + echo "wtd-dev-restart: port $VITE_PORT is taken by pid $vite_pid, which is not worktree-term's Vite" >&2 exit 1 fi stop_pid "$vite_pid" "Vite dev server" From 05e988593524f956f6b46dc761c537ebc7f2a001 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 26 Oct 2026 16:11:10 +0100 Subject: [PATCH 11/11] Archive the OpenSpec change: a terminal whose process exits cleanly closes its tab --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/daemon/spec.md | 0 .../specs/hub/spec.md | 0 .../specs/protocol/spec.md | 0 .../specs/web/spec.md | 0 .../tasks.md | 0 openspec/specs/daemon/spec.md | 36 ++++++++++++++++-- openspec/specs/hub/spec.md | 2 +- openspec/specs/protocol/spec.md | 6 +-- openspec/specs/web/spec.md | 38 ++++++++++++++++--- 12 files changed, 69 insertions(+), 13 deletions(-) rename openspec/changes/{dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes => archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes}/.openspec.yaml (100%) rename openspec/changes/{dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes => archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes}/design.md (100%) rename openspec/changes/{dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes => archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes}/proposal.md (100%) rename openspec/changes/{dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes => archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes}/specs/daemon/spec.md (100%) rename openspec/changes/{dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes => archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes}/specs/hub/spec.md (100%) rename openspec/changes/{dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes => archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes}/specs/protocol/spec.md (100%) rename openspec/changes/{dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes => archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes}/specs/web/spec.md (100%) rename openspec/changes/{dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes => archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes}/tasks.md (100%) diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/.openspec.yaml b/openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/.openspec.yaml similarity index 100% rename from openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/.openspec.yaml rename to openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/.openspec.yaml diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/design.md b/openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/design.md similarity index 100% rename from openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/design.md rename to openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/design.md diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/proposal.md b/openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/proposal.md similarity index 100% rename from openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/proposal.md rename to openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/proposal.md diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/daemon/spec.md b/openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/daemon/spec.md similarity index 100% rename from openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/daemon/spec.md rename to openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/daemon/spec.md diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/hub/spec.md b/openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/hub/spec.md similarity index 100% rename from openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/hub/spec.md rename to openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/hub/spec.md diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/protocol/spec.md b/openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/protocol/spec.md similarity index 100% rename from openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/protocol/spec.md rename to openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/protocol/spec.md diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/web/spec.md b/openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/web/spec.md similarity index 100% rename from openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/web/spec.md rename to openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/specs/web/spec.md diff --git a/openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md b/openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md similarity index 100% rename from openspec/changes/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md rename to openspec/changes/archive/2026-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/tasks.md diff --git a/openspec/specs/daemon/spec.md b/openspec/specs/daemon/spec.md index 2fab8fc..0de1e2e 100644 --- a/openspec/specs/daemon/spec.md +++ b/openspec/specs/daemon/spec.md @@ -160,20 +160,40 @@ A terminal request, `resize` or input frame for an unknown terminal id SHALL fai - **THEN** the request fails with `unknown-term` ### Requirement: Terminal lifetime -A terminal SHALL end only by `closeTerm`, by its process exiting, or by `shutdown`; a client disconnecting or unwatching SHALL NOT end it. When the process exits, the daemon SHALL first offer every remaining output byte, then send `termExited{code, signal}` to the repo's watchers, `signal` being the signal name or null; the terminal SHALL remain, reporting `exit`, until closed. `closeTerm` SHALL send `SIGHUP` to the terminal's process group and `SIGKILL` 5 s later if it is still alive, never signalling a process after the terminal's process has been reaped; it SHALL remove the terminal from its layout, send `termClosed` to the repo's watchers, then reply `done`. +A terminal SHALL end only by `closeTerm`, by its process exiting, or by `shutdown`; a client disconnecting or unwatching SHALL NOT end it. An exit with code 0 and no signal SHALL be a clean exit; any other exit SHALL be a failed exit. When the process exits, the daemon SHALL first offer every remaining output byte. After a failed exit it SHALL then send `termExited{code, signal}` to the repo's watchers, `signal` being the signal name or null, and the terminal SHALL remain, reporting `exit`, until closed. After a clean exit it SHALL close the terminal itself, without sending `termExited`: it SHALL remove the terminal from its layout and send `termClosed` to the repo's watchers, sending `termClosed` even when the changed layout cannot be saved. `closeTerm` SHALL send `SIGHUP` to the terminal's process group and `SIGKILL` 5 s later if it is still alive, never signalling a process after the terminal's process has been reaped; it SHALL remove the terminal from its layout, send `termClosed` to the repo's watchers, then reply `done`. #### Scenario: Client leaves, terminal stays - **WHEN** the only attached client disconnects and a new client attaches later - **THEN** the terminal is still running and its output produced meanwhile is in the snapshot #### Scenario: Exit after a final burst -- **WHEN** a terminal's process writes 1 MiB and exits immediately +- **WHEN** a terminal's process writes 1 MiB and exits immediately with code 1 - **THEN** an attached client receives all of it before `termExited` +#### Scenario: Clean exit after a final burst +- **WHEN** a terminal's process writes 1 MiB and exits immediately with code 0 +- **THEN** an attached client receives all of it before `termClosed` + #### Scenario: Exit code reported - **WHEN** a terminal's shell runs `exit 3` - **THEN** watchers receive `termExited` with `code` 3 and `signal` null +#### Scenario: Killed by a signal stays +- **WHEN** a terminal's shell is killed by `SIGTERM` sent from outside the daemon +- **THEN** watchers receive `termExited` with `signal` "SIGTERM", and a client watching the repo afterwards is told of the terminal with that `exit` + +#### Scenario: Clean exit closes the terminal +- **WHEN** a terminal's shell runs `exit` +- **THEN** watchers receive `termClosed` and no `termExited`, and a client watching the repo afterwards is not told of the terminal + +#### Scenario: Clean exit leaves the layout +- **WHEN** the terminal in one half of a split exits with code 0 +- **THEN** watchers receive `layoutChanged` in which the split is replaced by the other half, and then `termClosed` + +#### Scenario: Clean exit with a layout that cannot be saved +- **WHEN** a terminal in a layout exits with code 0 and writing the changed layout fails +- **THEN** watchers receive `termClosed`, the stored layout is unchanged, and the failure is reported + #### Scenario: Close kills the process group - **WHEN** a terminal whose shell started a background child is closed - **THEN** both processes end and watchers receive `termClosed` @@ -219,7 +239,7 @@ Input frames SHALL be written to the terminal's PTY in order. Input to an exited - **THEN** the output is `30 100` ### Requirement: Activity -A terminal SHALL be visible while at least one connection lists it in its latest `setVisible`; ids of unknown terminals or of repos the connection does not watch SHALL be ignored, and a connection's visibility SHALL be withdrawn when it unwatches the repo or disconnects. Output while not visible SHALL set `unseen`; the process exiting while not visible SHALL set `unseen`; becoming visible SHALL clear it. +A terminal SHALL be visible while at least one connection lists it in its latest `setVisible`; ids of unknown terminals or of repos the connection does not watch SHALL be ignored, and a connection's visibility SHALL be withdrawn when it unwatches the repo or disconnects. Output while not visible SHALL set `unseen`; a failed exit while not visible SHALL set `unseen`, and a clean exit SHALL NOT; becoming visible SHALL clear it. Each terminal SHALL have an attention `state`, `idle` when created. Times below are when the daemon received the output or input concerned. - An attention signal SHALL be: a bell character outside any escape sequence; an OSC 9 sequence whose payload does not start with a decimal number followed by `;`; an OSC 777 sequence whose payload starts with `notify;`; an OSC 99 sequence. A bell received within 1 s after an input frame that is not only focus reports SHALL NOT be a signal. - A signal SHALL set `input`, whether or not the terminal is visible. @@ -246,9 +266,13 @@ Every change of `unseen` or `state` SHALL be sent as `activity{termId, unseen, s - **THEN** the terminal's `unseen` becomes true #### Scenario: Exit while hidden -- **WHEN** a terminal not listed by any `setVisible` exits without producing further output +- **WHEN** a terminal not listed by any `setVisible` exits with code 1 without producing further output - **THEN** watchers receive `activity` with `unseen` true +#### Scenario: Clean exit while hidden +- **WHEN** a terminal not listed by any `setVisible` exits with code 0 without producing further output +- **THEN** watchers receive `termClosed` and no `activity` with `unseen` true + #### Scenario: Quiet terminal becomes idle - **WHEN** a terminal prints a line and then nothing for 3 s - **THEN** watchers receive `activity` with `state` "working" and then, 3 s after the output, `state` "idle" @@ -323,6 +347,10 @@ Checked state and layouts SHALL be stored in `state.json` and survive daemon res - **WHEN** a checked worktree with a live terminal is removed, and the terminal is later closed - **THEN** the entry stays while the terminal lives and is removed when it closes +#### Scenario: Removed worktree pruned after a clean exit +- **WHEN** a checked worktree with a live terminal is removed with `git worktree remove`, and the terminal's shell then exits with code 0 +- **THEN** its entry is gone from `state.json` + #### Scenario: Corrupt state file - **WHEN** `state.json` holds invalid JSON at start - **THEN** it is renamed to a `state.json.corrupt-` file and the daemon starts with no checked worktrees diff --git a/openspec/specs/hub/spec.md b/openspec/specs/hub/spec.md index 9befa97..db9c977 100644 --- a/openspec/specs/hub/spec.md +++ b/openspec/specs/hub/spec.md @@ -280,7 +280,7 @@ While a session's unsent WebSocket data is at least 1 MiB, the hub SHALL stop re - **THEN** the old daemon exits, host 0 becomes `connected` with a new instance and the hub's protocol, and the browser receives `done` #### Scenario: Newer daemon restarted -- **WHEN** host 0 is served by a daemon speaking protocol 6, whose messages after `hello` are not valid protocol-5 messages, and the browser sends `restartDaemon` +- **WHEN** host 0 is served by a daemon speaking a newer protocol than the hub's, whose messages after `hello` are not valid messages of the hub's protocol, and the browser sends `restartDaemon` - **THEN** that daemon exits and host 0 becomes `connected` with a new instance and the hub's protocol #### Scenario: Remote daemon restarted diff --git a/openspec/specs/protocol/spec.md b/openspec/specs/protocol/spec.md index e4259d4..769c3b8 100644 --- a/openspec/specs/protocol/spec.md +++ b/openspec/specs/protocol/spec.md @@ -6,15 +6,15 @@ The wire contract between the browser UI, the hub and every daemon: which messag ## Requirements ### Requirement: Protocol version and frozen handshake messages -The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 6, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 10, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. The print-code line and the page fragments, exchanged between `wtd` installations of any versions, SHALL likewise never change shape. +The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 7, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 10, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. The print-code line and the page fragments, exchanged between `wtd` installations of any versions, SHALL likewise never change shape. #### Scenario: Hello round-trips - **WHEN** a `hello` with `protocol` 1, `version` "0.1.0" and `instance` "a1B2_c3" is encoded and decoded - **THEN** the decoded value equals the original #### Scenario: Hello from another protocol version is still decodable -- **WHEN** a `hello` carrying `protocol` 7 is decoded -- **THEN** decoding succeeds and yields `protocol` 7 +- **WHEN** a `hello` carrying `protocol` 8 is decoded +- **THEN** decoding succeeds and yields `protocol` 8 #### Scenario: Invalid instance rejected - **WHEN** a `hello` whose `instance` contains a space is decoded diff --git a/openspec/specs/web/spec.md b/openspec/specs/web/spec.md index f6b0e40..1f4bb97 100644 --- a/openspec/specs/web/spec.md +++ b/openspec/specs/web/spec.md @@ -127,7 +127,7 @@ The selected repo tab and each repo's selected worktree SHALL be stored per brow - **THEN** the second repo tab and that worktree are selected ### Requirement: Terminal tabs -The terminal area SHALL show the selected worktree's layout tabs, each labelled by its first terminal, with the layout's active tab shown and its tab shown in the accent colour; live terminals of the worktree missing from its layout SHALL be shown as further single-pane tabs. Choosing a tab SHALL show it at once and then store it as active with `setLayout`. The tab bar SHALL offer controls to create a new tab, split the focused pane right and split it down, each naming its keyboard shortcut on hover and each starting a new terminal through the preset picker; the split controls SHALL be disabled when the focused pane's tab cannot be split further, the new-tab control when the layout holds 64 tabs, and all of them while the host is not `connected`, the worktree is prunable or gone, or no presets have been received. A new tab SHALL send `createTerm` with the chosen preset's name and command and the size of the terminal area, then add the terminal as a new active tab with `setLayout`. A terminal SHALL never be created again because a request failed or the session closed. An exited terminal's tab, and its pane header in a split, SHALL show that it exited and keep its screen readable until closed. A worktree without terminals SHALL show the presets as choices in the terminal area; no terminal SHALL be created without the user choosing one. +The terminal area SHALL show the selected worktree's layout tabs, each labelled by its first terminal, with the layout's active tab shown and its tab shown in the accent colour; live terminals of the worktree missing from its layout SHALL be shown as further single-pane tabs. Choosing a tab SHALL show it at once and then store it as active with `setLayout`. The tab bar SHALL offer controls to create a new tab, split the focused pane right and split it down, each naming its keyboard shortcut on hover and each starting a new terminal through the preset picker; the split controls SHALL be disabled when the focused pane's tab cannot be split further, the new-tab control when the layout holds 64 tabs, and all of them while the host is not `connected`, the worktree is prunable or gone, or no presets have been received. A new tab SHALL send `createTerm` with the chosen preset's name and command and the size of the terminal area, then add the terminal as a new active tab with `setLayout`. A terminal SHALL never be created again because a request failed or the session closed. The tab of a terminal whose process failed, and its pane header in a split, SHALL show that it exited and keep its screen readable until closed; a terminal whose process exited cleanly SHALL leave the tabs of every page, as any closed terminal does. A worktree without terminals SHALL show the presets as choices in the terminal area; no terminal SHALL be created without the user choosing one. When the selected worktree's last terminal closes and the keyboard focus is left on the page body, the choices SHALL take the keyboard focus on their first preset; otherwise showing them SHALL NOT move the keyboard focus. #### Scenario: New terminal - **WHEN** the user picks a preset in a worktree without terminals @@ -142,9 +142,25 @@ The terminal area SHALL show the selected worktree's layout tabs, each labelled - **THEN** after reconnecting the terminal is shown as a tab, and no second terminal was created #### Scenario: Exited terminal -- **WHEN** the shell in a terminal exits +- **WHEN** the shell in a terminal runs `exit 1` - **THEN** its tab shows that it exited and its screen stays readable until it is closed +#### Scenario: Clean exit closes the tab +- **WHEN** the shell in one of a worktree's two single-pane tabs runs `exit` +- **THEN** its tab disappears from every page showing the worktree and the remaining tab is shown + +#### Scenario: Last terminal leaves the choices +- **WHEN** the user types `exit` in the only terminal of the selected worktree and then presses 1 +- **THEN** the preset choices are shown, and the first preset starts as a new terminal of that worktree + +#### Scenario: Confirmed close leaves the choices focused +- **WHEN** the user closes the only, running, terminal of the selected worktree, confirms, and then presses 1 +- **THEN** the first preset starts as a new terminal of that worktree + +#### Scenario: Choices leave the focus elsewhere alone +- **WHEN** the only terminal of the selected worktree exits cleanly while the keyboard focus is in the worktree search +- **THEN** the preset choices are shown and the search keeps the keyboard focus + ### Requirement: Terminal lifetime Each terminal SHALL have exactly one terminal object and container in the page, created on the first view of its worktree, which also attaches every terminal of that worktree. The object SHALL never be re-created or removed from the document while its PTY lives — not by switching, renderer changes, reconnects or snapshots — and SHALL be disposed when its terminal is closed or its daemon instance is gone. Hidden terminals SHALL keep receiving output. Each terminal SHALL keep 10000 lines of scrollback, open links in a new window without access to the page, and measure character widths per Unicode 11. @@ -276,6 +292,10 @@ Each tab SHALL have one focused pane, kept per page and not stored: the first pa - **WHEN** the focused pane of a two-pane split is closed - **THEN** the remaining pane is focused and receives typed input +#### Scenario: Focus follows a cleanly exited pane's sibling +- **WHEN** the user types `exit` in the focused pane of a two-pane split +- **THEN** the remaining pane is focused and receives typed input + ### Requirement: Preset picker The page SHALL keep the presets the hub sent for the session and forget them when the session closes. Starting a new terminal SHALL open a picker listing the presets in order, over the terminal area, for the selected worktree and the requested operation; with exactly one preset it SHALL use that preset without showing the picker. Digits 1–9 SHALL choose the preset at that position; arrow keys and Enter SHALL move and choose; Escape or a click outside SHALL cancel without creating anything. The picker SHALL close without creating anything when the selected worktree changes, its host leaves `connected`, the pane it would split closes, or the operation is no longer possible; choosing SHALL check again that the operation is possible. A worktree without terminals SHALL show the same list in the terminal area, choosable by click, digits or arrows and Enter. @@ -307,13 +327,17 @@ Each pane SHALL have a close control that closes its terminal, and Ctrl+Shift+W - **THEN** both terminals are closed and the tab disappears from every page #### Scenario: Exited terminal closes at once -- **WHEN** the user closes a pane whose process has exited +- **WHEN** the user closes a pane whose process has exited with code 1 - **THEN** it closes without a dialog #### Scenario: Mixed tab cancelled closes nothing -- **WHEN** the user closes a tab holding one exited and one running pane and cancels +- **WHEN** the user closes a tab holding one pane whose process exited with code 1 and one running pane, and cancels - **THEN** both terminals remain +#### Scenario: Dialog closes when its terminal exits cleanly +- **WHEN** the close dialog lists a single running terminal and that terminal's shell exits with code 0 +- **THEN** the dialog closes and no `closeTerm` is sent + ### Requirement: Keyboard shortcuts The page SHALL handle these shortcuts whether the keyboard focus is in a terminal or elsewhere on the page, except while a picker or dialog is open or text is being composed with an input method; a handled shortcut SHALL act once per key press, SHALL NOT reach the terminal's program, and SHALL suppress the browser's own action: - Alt+Up / Alt+Down: select the previous / next worktree as listed in the sidebar under the current filter, doing nothing at either end. @@ -350,7 +374,7 @@ Holding a navigation shortcut SHALL repeat it; holding any other shortcut SHALL - **THEN** the selected text is typed into the second terminal ### Requirement: Attention indicators -Each terminal tab, and each pane of a split, SHALL show one mark for its terminal, the first that applies of: needs input (`state` `input`); exited with failure (a non-zero code or a signal); exited; done (`unseen` with `state` `idle`); new output (`unseen` with `state` `working`); none otherwise. A tab holding several panes SHALL show the first-ranked mark of its terminals. Each worktree row and each repo tab SHALL show the first-ranked mark among the terminals of that worktree or of that repo's worktrees, counting an exited terminal only while it is `unseen`, with the number of terminals per mark on hover. Marks SHALL follow `activity`, `termExited` and terminal list changes without user action. +Each terminal tab, and each pane of a split, SHALL show one mark for its terminal, the first that applies of: needs input (`state` `input`); exited with failure (the terminal has exited, which only a failed exit leaves visible); done (`unseen` with `state` `idle`); new output (`unseen` with `state` `working`); none otherwise. A tab holding several panes SHALL show the first-ranked mark of its terminals. Each worktree row and each repo tab SHALL show the first-ranked mark among the terminals of that worktree or of that repo's worktrees, counting an exited terminal only while it is `unseen`, with the number of terminals per mark on hover. Marks SHALL follow `activity`, `termExited` and terminal list changes without user action. #### Scenario: Agent waiting in a hidden worktree - **WHEN** a terminal in a non-selected worktree of a non-selected repo rings the bell @@ -364,6 +388,10 @@ Each terminal tab, and each pane of a split, SHALL show one mark for its termina - **WHEN** a hidden terminal's process exits with code 1 - **THEN** its tab shows the failed-exit mark and its worktree row shows it until the terminal is viewed +#### Scenario: Killed terminal marked failed +- **WHEN** a terminal's shell is killed by a signal sent from outside the daemon +- **THEN** its tab shows the failed-exit mark + ### Requirement: Page visibility The page SHALL tell each host which of its terminals the page shows: every pane of the shown tab while the page is visible, none while it is hidden. On becoming hidden or being unloaded it SHALL send `setVisible` with no terminals to every host it told of shown terminals; on becoming visible again, and after every host is restored following a reconnect, it SHALL send the terminals it shows.