Repository navigation
worktree-term: a terminal whose shell exits cleanly closes its tab #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ZmeiGorynych
merged 12 commits into
main
from
egor/dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes
Oct 26, 2026
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
62ae710
Add wtd-dev-restart and frame the confirmation dialog in the active t…
ZmeiGorynych 6740450
Merge branch 'origin/main' into main
ZmeiGorynych 3060414
Drop the blue confirmation frame that the red deletion frame superseded
ZmeiGorynych 5d30792
Plan: a terminal whose process exits cleanly closes its tab
ZmeiGorynych d5c966e
Migrate the repo to living-architecture format 0.4.0 (la-arch-migrate)
ZmeiGorynych e174e59
Failing tests: a terminal whose process exits cleanly closes its tab
ZmeiGorynych 9b15aa6
A terminal whose process exits cleanly closes its tab
ZmeiGorynych d1261ce
Observe a retired tunnel listener closing on a connection it accepted…
ZmeiGorynych a399d63
Address review: la tools 0.4.1 in CI, safer wtd-dev-restart, reduced …
ZmeiGorynych a4a0183
Prune state when removing a worktree without terminals; README descri…
ZmeiGorynych ae2652d
wtd-dev-restart stops only a worktree-term checkout's Vite on the dev…
ZmeiGorynych 05e9885
Archive the OpenSpec change: a terminal whose process exits cleanly c…
ZmeiGorynych File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
2 changes: 2 additions & 0 deletions
2
...6-10-26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/.openspec.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| schema: spec-driven | ||
| created: 2026-10-24 |
47 changes: 47 additions & 0 deletions
47
...26-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). |
31 changes: 31 additions & 0 deletions
31
...-dev-2151-worktree-term-a-terminal-whose-shell-exits-cleanly-closes/proposal.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.