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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 3 additions & 18 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand Down
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias> "$HOME/.local/bin/wtd" connect` remotely. `wtd connect` only bridges SSH's stdio to the remote daemon's socket, starting the daemon if needed.

Expand Down Expand Up @@ -142,15 +142,15 @@ 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" }
```

## 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
Expand All @@ -161,3 +161,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.
8 changes: 4 additions & 4 deletions architecture/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
6 changes: 0 additions & 6 deletions architecture/model.c4
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@ model {
protocol = node 'Protocol' {
metadata {
package 'src/protocol'
arc42 'architecture/protocol.arc42.md'
specs ['protocol']
}
}

platform = node 'Platform' {
metadata {
package 'src/platform'
arc42 'architecture/platform.arc42.md'
specs ['platform']
}
files = component 'Files'
Expand All @@ -30,7 +28,6 @@ model {
daemon = node 'Daemon' {
metadata {
package 'src/daemon'
arc42 'architecture/daemon.arc42.md'
specs ['daemon']
}
worktrees = component 'Worktrees'
Expand All @@ -43,7 +40,6 @@ model {
hub = node 'Hub' {
metadata {
package 'src/hub'
arc42 'architecture/hub.arc42.md'
specs ['hub']
}
server = component 'Server'
Expand All @@ -56,15 +52,13 @@ model {
cli = node 'CLI' {
metadata {
package 'src/cli'
arc42 'architecture/cli.arc42.md'
specs ['cli']
}
}

web = node 'Web' {
metadata {
package 'src/web'
arc42 'architecture/web.arc42.md'
specs ['web']
}
client = component 'Client'
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions living-architecture.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
format: 0.4.0
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
tracker: linear # detected: Linear MCP + egor/dev-NNNN-<slug> branches
openspec: true # detected: openspec/
architecture: true # detected: architecture/index.yaml
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24
Original file line number Diff line number Diff line change
@@ -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 <signal>" 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).
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading