Skip to content

Fix v0.6 physical-QA failures: multi-session restore, inline interactive UIs, Terminal.app startup - #213

Merged
raiseCatError merged 2 commits into
devfrom
fix/v06-physical-qa
Sep 29, 2026
Merged

raiseCatError merged 2 commits into
devfrom
fix/v06-physical-qa

Conversation

@raiseCatError

Copy link
Copy Markdown
Owner

Closes #211
Closes #212

Physical v0.6 QA found three failures. #1 and #3 turned out to be one lifecycle bug.

1 + 3. Multi-session restore opens only one; Terminal.app nmsh exits with "unsettled top-level await" (#212)

Root cause: spawnLauncher (the #197 window launcher) spawned osascript and awaited its exit after child.unref().

  • An unref'd child doesn't keep Node's event loop alive. With stdin paused after the picker (or with no picker under Open all), nothing else did, so Node exited mid-await with code 13 and the warning.
  • The first launcher had already started, so exactly one extra window appeared. The current window never attached, and later sessions were never launched.
  • In Terminal.app with detached sessions under Open all, that's nmsh failing immediately.
  • Ghostty has the same path.

Reproduced with the built bin/nmsh and a stand-in osascript: exit 13, warning printed, one launch.

Fix (src/host/terminalHost.ts): the child stays referenced while awaited. A launcher that hangs (for example on a macOS Automation prompt) counts as failed after 15 s, so startup continues and names that session's nmsh --attach command. There's no special case for "select all"; the general N-selected path was already correct.

2. Inline interactive CLIs stay in the structured view (#211)

Root cause: CommandClassifier handed the terminal over only for the alternate screen (?1049h/?47h), and PassthroughPolicy only for known names. Claude Code draws inline, so under claude-account2 it:

  • stayed structured, and after 2 s of cursor movement went LIVE;
  • had its cursor-addressed UI rendered as transcript text (the leaked …;2m);
  • sent keys to NMSh's composer instead of the program.

Evidence-based fix: a running program that turns on terminal input modes owns the terminal: bracketed paste ?2004h, mouse ?1000/1002/1003/1005/1006/1015h, focus ?1004h, or a kitty keyboard push CSI > n u. Batch output never does that. Cursor visibility and cursor-key mode are deliberately excluded because progress bars and pagers use them.

  • src/session/TerminalModes.ts (moved out of SessionService, and still re-exported from it) records these modes, now including the kitty flags, and exposes interactive / ownsTerminal.
  • src/output/Classifier.ts: input modes, not only the alternate screen, lead to PASSTHROUGH.
  • src/app/TerminalApp.ts: per-command mode tracking; when the terminal is handed over mid-command, the program's earlier modes are replayed to it (the triggering chunk was already written raw).
  • src/session/SessionService.ts: reattach passthrough, the Live sessions: stale-session cleanup, crash recovery and hardening #131 mode restore, and detached-output exclusion all use ownsTerminal, so an inline UI also works after detach. The /resume fullscreen label still means the alternate screen.

Also: stale .git/index.lock from the multiplexer tests

The #204 tmux and screen tests started NMSh inside this checkout, so its prompt's git status ran in the repository, and killing tmux mid-status left .git/index.lock behind (found twice during this work). They now run NMSh from the sandbox home, like the other live tests. After the change, the full suite leaves no lock.

Tests

  • tests/startupLaunch.test.ts:
    • a child process awaiting a launcher with nothing else alive must finish, not hit the unsettled-await exit;
    • launcher success, failure, missing command, and timeout;
    • 2, 3 and 4 selected sessions, plus a partial selection: one attaches here and each other one launches;
    • a failed launch in the middle: later launches still happen, and the failed one is named;
    • the built bin/nmsh as Terminal.app, with three detached sessions, select all, Enter: this window attaches and the other two launch, with no warning;
    • the built bin/nmsh starts normally as Terminal.app.
  • tests/interactiveCli.test.ts:
    • input modes versus names and output volume, including split reads, progress bars, cursor-key mode and turning modes off;
    • kitty flag tracking and replay;
    • a wrapper-named inline picker gets the terminal: keys reach it, no leaked sequences, clean return;
    • the same after detach and reattach;
    • an ordinary progress loop stays in NMSh.
  • On the old code: 3 launch tests fail (including the built-CLI one) and 3 interactive tests fail. The orchestration tests pass both ways, confirming the N-selection logic was fine and the bug was the process lifecycle.
  • The interactive file passed 12/12 under parallel load after a fixture fix: the fake picker now parses keys as a stream, because two keystrokes can arrive in one read. The mux tests passed 3/3 with no lock left.
  • Build, typecheck and git diff --check are clean. The full suite passed 615/615 four times. No leaked services, tmux or screen.

Physical retest (smallest)

  1. Ghostty, Open all: leave 2–3 detached sessions, run nmsh, select all and press Enter. This window attaches one, and each other one opens in its own Ghostty window in the same app.
  2. Ghostty, picker: select 2 of 3 with Space and press Enter. Two restore, and the third stays in /resume.
  3. Terminal.app: with detached sessions present, nmsh starts (no "unsettled top-level await"). Repeat step 1 there; allow the Automation prompt once.
  4. claude-account2: the trust picker renders cleanly, arrows and Enter work, no NMSh composer shows, and exiting returns to NMSh.
  5. The same, detached: start claude-account2, close the window, reattach, and it's still interactive.
  6. Sanity: npm test or a long sleep loop still shows NMSh's composer and activity row, not passthrough.

…ive UIs

Restoring several detached sessions opened only one and could end NMSh
with 'Detected unsettled top-level await' (in Terminal.app, nmsh failed
to start). The window launcher awaited osascript after unref()'ing it;
with stdin paused after the picker nothing else kept the event loop
alive, so Node exited mid-await after spawning only the first launcher.
The child now stays referenced while awaited, and a launcher that hangs
times out after 15 s so that session's attach command is named instead.

Inline interactive terminal UIs, such as Claude Code run through a
wrapper like claude-account2, stayed in NMSh's structured view: the
classifier handed over the terminal only for the alternate screen or a
known name. A running program that turns on terminal input modes
(bracketed paste, mouse, focus events, kitty keyboard) now counts as
owning the terminal. The mode tracker moves to TerminalModes and records
this; the frontend replays the program's modes when it hands over
mid-command, and the service treats it like fullscreen for reattach,
mode restore and detached output. Progress output and other batch
commands are unaffected.

Tests reproduce both on the old code, including the built bin/nmsh with
a stand-in osascript.
The tmux and screen tests started NMSh inside this checkout, so its
prompt ran git status in the repository; killing tmux mid-status left
.git/index.lock behind. Run it from the sandbox home with absolute entry
and loader paths, as the other live tests do.
@raiseCatError
raiseCatError merged commit a37ac60 into dev Sep 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant