diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6eb075095..cc971fa4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,25 @@ jobs: command -v "$py" >/dev/null 2>&1 || py=python "$py" scripts/tests/build_wrapper_sdk_guard_tests.py + # release.yml writes the multiplexer protocol range this script reads from MuxProtocol.cs into + # every Velopack release's notes (Phase 5 R10); a release must fail rather than ship a guessed + # one, so the parse is tested here, against the real file, before any release depends on it. + - name: Self-test the release's multiplexer protocol range script + shell: bash + run: | + py=python3 + command -v "$py" >/dev/null 2>&1 || py=python + "$py" scripts/tests/mux_protocol_range_tests.py + + # release.yml refuses a run that would replace a published ntilde-mux, whose hash every installed App + # pins (Codex review of PR #511): scripts/ci/mux-release-guard.sh, tested here against a fake gh. + - name: Self-test the release's ntilde-mux rerun guard + shell: bash + run: | + py=python3 + command -v "$py" >/dev/null 2>&1 || py=python + "$py" scripts/tests/mux_release_guard_tests.py + # ── Rust native ──────────────────────────────────────────────────────────── # Cache the compiled binary keyed on all Rust source files. # On a hit the toolchain install, Swatinem, and cargo build are all skipped. @@ -1542,6 +1561,14 @@ jobs: # linux_packaging_detect, which this job deliberately mirrors. fetch-depth: 0 + - name: Self-test the AOT gate path filter + shell: bash + run: | + py=python3 + command -v "$py" >/dev/null 2>&1 || py=python + "$py" scripts/tests/aot_gate_paths_tests.py + "$py" scripts/tests/aot_smoke_verdict_tests.py + # Dependency-free path filter, matching linux_packaging_detect rather than # introducing a third-party action this repo uses nowhere. # @@ -1581,18 +1608,17 @@ jobs: changed="$(git diff --name-only "$base"...HEAD)" echo "Changed files vs merge base:" echo "$changed" - # Here-string, not `echo | grep`: under pipefail a changed-file list bigger than - # the pipe buffer makes echo take SIGPIPE, the pipeline non-zero, this `if` false, - # and the gate skip itself SILENTLY. Same trap linux_packaging_detect calls out. - # scripts/mux-daemon-smoke.sh: mux_daemon_aot and mux_daemon_no_openssl run it, so an - # edit to it alone must run them. - if grep -qE '^(src/|Directory\.(Build|Packages)\.props$|global\.json$|\.github/workflows/[^/]+\.ya?ml$|scripts/mux-daemon-smoke\.sh$)' <<<"$changed"; then - echo "run=true" >> "$GITHUB_OUTPUT" - echo "Production sources or build inputs changed - running the AOT gate." - else - echo "run=false" >> "$GITHUB_OUTPUT" - echo "Nothing under src/ or the build inputs changed - skipping the AOT gate." - fi + # The path rule lives in scripts/ci/aot-gate-paths.sh (self-tested by the step above), + # so it is a directory rule with one place to read it. It reads the changed paths on + # stdin, as a here-string rather than `echo |` for the SIGPIPE reason + # linux_packaging_detect calls out, and prints run=true or run=false. + # Beyond src/ and the build inputs it covers the test directories that feed + # native_ssh_docker_e2e: that job needs the linux-x64 ntilde-mux binary this gate + # builds, and without it the remote-persistence step skips with a notice and stays + # green, so a PR changing only those tests never ran them. + result="$(bash scripts/ci/aot-gate-paths.sh <<<"$changed")" + echo "$result" >> "$GITHUB_OUTPUT" + echo "AOT gate decision: $result" aot_gate: name: AOT Gate (windows-latest) @@ -1663,79 +1689,97 @@ jobs: $env:NTILDE_APPDATA_ROOT = $root $errorFile = Join-Path $root 'logs/startup_error.txt' $debugLog = Join-Path $root 'logs/debug.log' + $muxLog = Join-Path $root 'logs/mux.log' $exe = Join-Path $PWD 'artifacts/publish/win-x64/Ntilde.exe' if (-not (Test-Path $exe)) { throw "The publish produced no Ntilde.exe at $exe." } - $proc = Start-Process -FilePath $exe -PassThru - Write-Output "Started pid $($proc.Id); watching for 45s." + try { + $proc = Start-Process -FilePath $exe -PassThru + Write-Output "Started pid $($proc.Id); watching for 45s." - # Poll rather than sleep-then-look, so a fast crash is reported as a crash - # instead of waiting out the full window first. - $deadline = (Get-Date).AddSeconds(45) - while ((Get-Date) -lt $deadline) { - if ($proc.HasExited -or (Test-Path $errorFile)) { break } - Start-Sleep -Seconds 2 - } + # Poll rather than sleep-then-look, so a fast crash is reported as a crash + # instead of waiting out the full window first. + $deadline = (Get-Date).AddSeconds(45) + while ((Get-Date) -lt $deadline) { + if ($proc.HasExited -or (Test-Path $errorFile)) { break } + Start-Sleep -Seconds 2 + } - # Diagnostics BEFORE the assertions: whatever the outcome, the log is what a - # human needs, and an assertion that throws first would take it away. - foreach ($f in @($debugLog, $errorFile)) { - if (Test-Path $f) { - Write-Output "---- $f ----" - Get-Content $f -Tail 60 + # Diagnostics BEFORE the assertions: whatever the outcome, the log is what a + # human needs, and an assertion that throws first would take it away. + foreach ($f in @($debugLog, $muxLog, $errorFile)) { + if (Test-Path $f) { + Write-Output "---- $f ----" + Get-Content $f -Tail 60 + } } - } - if (Test-Path $errorFile) { - throw "The bundle failed during startup and wrote startup_error.txt (contents above). Read the exception before retrying: a trimmed-away type or a missing native is a real AOT break and reruns will not clear it. The one environmental cause to rule out is the runner being unable to host a desktop window at all, which shows up as an Avalonia/windowing exception on StartWithClassicDesktopLifetime rather than as anything about types or files." - } - if ($proc.HasExited) { - throw "The bundle exited on its own within 45s (exit code $($proc.ExitCode)) without writing startup_error.txt, so it died outside Program.cs's own try/catch - suspect the native side or the host." - } - if (-not (Test-Path $debugLog)) { - throw "The process is alive but never wrote $debugLog, so managed Main did not reach its startup logging. Suspect a failure before the log sink is wired, in the AOT host itself." - } + if (Test-Path $errorFile) { + throw "The bundle failed during startup and wrote startup_error.txt (contents above). Read the exception before retrying: a trimmed-away type or a missing native is a real AOT break and reruns will not clear it. The one environmental cause to rule out is the runner being unable to host a desktop window at all, which shows up as an Avalonia/windowing exception on StartWithClassicDesktopLifetime rather than as anything about types or files." + } + if ($proc.HasExited) { + throw "The bundle exited on its own within 45s (exit code $($proc.ExitCode)) without writing startup_error.txt, so it died outside Program.cs's own try/catch - suspect the native side or the host." + } + if (-not (Test-Path $debugLog)) { + throw "The process is alive but never wrote $debugLog, so managed Main did not reach its startup logging. Suspect a failure before the log sink is wired, in the AOT host itself." + } - # Program.cs logs three lines at startup, and this asserts on the THIRD, not the - # first. The first ("Ntilde started with args: ...") never reaches the file - - # not in CI and not in a local install either - so the obvious assertion is a - # guaranteed false failure. That is its own bug, worth fixing separately; it is not - # this gate's job to depend on it. - # - # Build: is the better anchor anyway. It proves managed Main got through its startup - # sequence, and it proves DescribeBuild resolved the executable path - the one - # remaining IL3000 in the tree is Assembly.Location in that very method, and - # `path=...Ntilde.exe` appearing here is the standing proof that - # Environment.ProcessPath carries it and the warned fallback never runs. - if (-not (Select-String -Path $debugLog -Pattern 'Build:.*path=.*Ntilde\.exe' -Quiet)) { - throw "$debugLog carries no 'Build: ... path=...Ntilde.exe' line. Either managed Main did not reach its startup logging, or DescribeBuild could not resolve the executable path - which in an AOT bundle means Environment.ProcessPath came back empty and the Assembly.Location fallback (IL3000) was reached after all." - } + # Program.cs logs three lines at startup, and this asserts on the THIRD, not the + # first. The first ("Ntilde started with args: ...") never reaches the file - + # not in CI and not in a local install either - so the obvious assertion is a + # guaranteed false failure. That is its own bug, worth fixing separately; it is not + # this gate's job to depend on it. + # + # Build: is the better anchor anyway. It proves managed Main got through its startup + # sequence, and it proves DescribeBuild resolved the executable path - the one + # remaining IL3000 in the tree is Assembly.Location in that very method, and + # `path=...Ntilde.exe` appearing here is the standing proof that + # Environment.ProcessPath carries it and the warned fallback never runs. + if (-not (Select-String -Path $debugLog -Pattern 'Build:.*path=.*Ntilde\.exe' -Quiet)) { + throw "$debugLog carries no 'Build: ... path=...Ntilde.exe' line. Either managed Main did not reach its startup logging, or DescribeBuild could not resolve the executable path - which in an AOT bundle means Environment.ProcessPath came back empty and the Assembly.Location fallback (IL3000) was reached after all." + } - # One assertion beyond "Main ran": the window and a terminal view actually came up. - # This is the half that matters for a trimming break, which typically survives startup - # and dies when the UI is constructed. Both lines are emitted on a pristine profile - - # the default pane is created and themed before anything is interactive. - # - # If a log-message reword breaks this, re-anchor it to whatever the renderer now says - # on first paint. Do not delete the check: without it this step only proves the - # process launched, which the palette bug did too. - # - # `Spawned ... pid=` rather than the `Spawning` line this step first shipped with: - # RustPtySession logs `Spawning` BEFORE calling pty_spawn, so a PTY that fails to - # spawn writes it anyway and then throws into TerminalPane.InitializeSessionCore's - # catch, which shows an error banner and leaves the process alive and themed - all - # four assertions green over a terminal that cannot open a shell. `Spawned` is - # emitted after the IsInvalid check and carries the child pid, so it is only - # reachable with a real session behind it. (Codex P1 on #445.) - foreach ($pattern in @('\[TerminalView\] Theme applied', '\[RustPtySession\] Spawned .*pid=\d+')) { - if (-not (Select-String -Path $debugLog -Pattern $pattern -Quiet)) { - throw "$debugLog has no line matching '$pattern', so the bundle started but its UI never finished coming up. A trimmed-away type reached only while building the terminal view looks exactly like this." + # The verdict lives in scripts/ci/aot-smoke-verdict.ps1 (self-tested by + # scripts/tests/aot_smoke_verdict_tests.py). The smoke runs on the real default + # (KeepOnClose), so the shell is spawned by the multiplexer daemon (`Ntilde.exe mux + # serve`), not the GUI, and this also proves the AOT daemon path. When the GUI started + # the daemon, the daemon must have served AND spawned a shell (mux.log): a GUI-side + # `Spawned` line alone would be the local fallback hiding a broken daemon. The GUI-only + # line is accepted only when no daemon was started (persistence off). + # `Spawned ... pid=` rather than `Spawning`: Spawning is logged BEFORE pty_spawn + # (Codex P1 on #445). If a log reword breaks this, re-anchor it; do not delete it. + & (Join-Path $PWD 'scripts/ci/aot-smoke-verdict.ps1') -DebugLog $debugLog -MuxLog $muxLog + + Write-Output "Bundle started from a pristine profile, brought up its UI, and stayed up." + } + finally { + # The daemon outlives the GUI process and must never be left running: stop it by its + # own verb first (-Wait: the exe is a WinExe, so a bare call returns at once), then by + # command line (it runs from a staged copy under the profile root, or from the publish + # directory). Nothing here may fail the step. + try { Start-Process -FilePath $exe -ArgumentList 'mux', 'kill-server' -Wait -WindowStyle Hidden } catch { Write-Output "kill-server failed: $_" } + if ($proc) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } + Start-Sleep -Seconds 1 + try { + $publishDir = Split-Path $exe -Parent + Get-CimInstance Win32_Process -Filter "Name = 'Ntilde.exe'" | + Where-Object { + $_.CommandLine -match 'mux\s+serve' -and $_.ExecutablePath -and + ($_.ExecutablePath.StartsWith($publishDir, [StringComparison]::OrdinalIgnoreCase) -or + $_.ExecutablePath.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) + } | + ForEach-Object { + Write-Output "Stopping leftover daemon pid $($_.ProcessId)" + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } } + catch { Write-Output "daemon sweep failed: $_" } } - - Write-Output "Bundle started from a pristine profile, brought up its UI, and stayed up." - Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + # The runner's pwsh wrapper ends the step with `exit $LASTEXITCODE`; kill-server's code + # must not leak into it. + $global:LASTEXITCODE = 0 + exit 0 # ntilde.com (docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md §10.2, §11): the # console launcher release.yml ships beside Ntilde.exe, so a prompt waits for diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2df18e0c4..871440bda 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,6 +90,30 @@ jobs: # Velopack versions are SemVer without a leading 'v'; the tag has one. echo "release_version=${tag#v}" >> "$GITHUB_OUTPUT" + # THE RERUN GUARD (Codex review of PR #511, P1). Every installed App pins the SHA-256 of its own + # version's ntilde-mux- (Phase 5 Task 10). A second run for a tag that already has them - + # "Re-run all jobs", or a re-dispatch of the tag - signs the macOS binary again with a new secure + # timestamp, so new bytes and a new hash, and would replace the asset those Apps verify against. + # So the run stops here when the tag's release already carries ANY ntilde-mux asset. Here because + # every other job needs this one: nothing is built, signed or uploaded before it, and it runs + # before create_release, so none of this run's own uploads can exist yet and no run_attempt check + # is needed. A release that does not exist yet passes. "Re-run failed jobs" skips this job (it + # succeeded); publish_mux_daemon checks its own upload for that case. The script comes from the + # workflow's own ref, like the signing action, never from checkout_ref. + - name: Checkout workflow ref (provides the rerun guard) + uses: actions/checkout@v4 + with: + sparse-checkout: scripts/ci + persist-credentials: false + + - name: Refuse to replace a published ntilde-mux + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ steps.meta.outputs.release_tag }} + run: bash scripts/ci/mux-release-guard.sh preflight "$RELEASE_TAG" + create_release: name: Create Release runs-on: ubuntu-latest @@ -607,6 +631,27 @@ jobs: name: native-${{ matrix.os }} path: src/Ntilde.App/native + # The three ntilde-mux checksums publish_mux_daemon wrote (this job `needs` it), embedded in the App + # below so remote installs verify their download against them (Phase 5 Task 10). Counted: a download + # that silently found nothing would build an App with no pins. + - name: Download the ntilde-mux checksums + uses: actions/download-artifact@v4 + with: + pattern: mux-sha256-* + merge-multiple: true + path: ${{ runner.temp }}/mux-sha256 + + - name: Check the ntilde-mux checksums + shell: bash + env: + MUX_SHA256_DIR: ${{ runner.temp }}/mux-sha256 + run: | + set -euo pipefail + ls -la "$MUX_SHA256_DIR" + for rid in linux-x64 linux-arm64 osx-arm64; do + test -s "$MUX_SHA256_DIR/ntilde-mux-$rid.sha256" || { echo "::error::no ntilde-mux-$rid.sha256 to embed" >&2; exit 1; } + done + - name: Restore run: dotnet restore @@ -625,7 +670,8 @@ jobs: env: SKIP_RUST_NATIVE_BUILD: "1" RELEASE_VERSION: ${{ needs.release_metadata.outputs.release_version }} - run: dotnet publish src/Ntilde.App/Ntilde.App.csproj -c ${{ env.CONFIGURATION }} -r ${{ matrix.rid }} --self-contained true -p:PublishAot=true -p:SkipCliShim=true "-p:Version=$RELEASE_VERSION" "-p:InformationalVersion=$RELEASE_VERSION" -o artifacts/publish/${{ matrix.rid }} + MUX_SHA256_DIR: ${{ runner.temp }}/mux-sha256 + run: dotnet publish src/Ntilde.App/Ntilde.App.csproj -c ${{ env.CONFIGURATION }} -r ${{ matrix.rid }} --self-contained true -p:PublishAot=true -p:SkipCliShim=true "-p:Version=$RELEASE_VERSION" "-p:InformationalVersion=$RELEASE_VERSION" "-p:NtildeMuxSha256Dir=$MUX_SHA256_DIR" -o artifacts/publish/${{ matrix.rid }} # ---- ntilde.com, the console launcher (docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md # ---- §10.3, §11). A GUI-subsystem Ntilde.exe is started and forgotten by a prompt, so @@ -729,72 +775,91 @@ jobs: $env:NTILDE_APPDATA_ROOT = $root $errorFile = Join-Path $root 'logs/startup_error.txt' $debugLog = Join-Path $root 'logs/debug.log' + $muxLog = Join-Path $root 'logs/mux.log' $exe = Join-Path $PWD 'artifacts/publish/win-x64/Ntilde.exe' if (-not (Test-Path $exe)) { throw "The publish produced no Ntilde.exe at $exe." } - $proc = Start-Process -FilePath $exe -PassThru - Write-Output "Started pid $($proc.Id); watching for 45s." + try { + $proc = Start-Process -FilePath $exe -PassThru + Write-Output "Started pid $($proc.Id); watching for 45s." - # Poll rather than sleep-then-look, so a fast crash is reported as a crash instead - # of waiting out the full window first. - $deadline = (Get-Date).AddSeconds(45) - while ((Get-Date) -lt $deadline) { - if ($proc.HasExited -or (Test-Path $errorFile)) { break } - Start-Sleep -Seconds 2 - } + # Poll rather than sleep-then-look, so a fast crash is reported as a crash instead + # of waiting out the full window first. + $deadline = (Get-Date).AddSeconds(45) + while ((Get-Date) -lt $deadline) { + if ($proc.HasExited -or (Test-Path $errorFile)) { break } + Start-Sleep -Seconds 2 + } - # Diagnostics BEFORE the assertions: on a tag build this log is the whole - # diagnosis, and an assertion that throws first would take it away. - foreach ($f in @($debugLog, $errorFile)) { - if (Test-Path $f) { - Write-Output "---- $f ----" - Get-Content $f -Tail 60 + # Diagnostics BEFORE the assertions: on a tag build this log is the whole + # diagnosis, and an assertion that throws first would take it away. + foreach ($f in @($debugLog, $muxLog, $errorFile)) { + if (Test-Path $f) { + Write-Output "---- $f ----" + Get-Content $f -Tail 60 + } } - } - if (Test-Path $errorFile) { - throw "The bundle failed during startup and wrote startup_error.txt (contents above). A trimmed-away type or a native that will not load is a real break in the artifact about to be released - do not retry it away." - } - if ($proc.HasExited) { - throw "The bundle exited on its own within 45s (exit code $($proc.ExitCode)) without writing startup_error.txt, so it died outside Program.cs's own try/catch - suspect the native side or the host." - } - if (-not (Test-Path $debugLog)) { - throw "The process is alive but never wrote $debugLog, so managed Main did not reach its startup logging. Suspect a failure before the log sink is wired, in the AOT host itself." - } + if (Test-Path $errorFile) { + throw "The bundle failed during startup and wrote startup_error.txt (contents above). A trimmed-away type or a native that will not load is a real break in the artifact about to be released - do not retry it away." + } + if ($proc.HasExited) { + throw "The bundle exited on its own within 45s (exit code $($proc.ExitCode)) without writing startup_error.txt, so it died outside Program.cs's own try/catch - suspect the native side or the host." + } + if (-not (Test-Path $debugLog)) { + throw "The process is alive but never wrote $debugLog, so managed Main did not reach its startup logging. Suspect a failure before the log sink is wired, in the AOT host itself." + } - # Asserts on the THIRD line Program.cs logs at startup, not the first: the first - # ("Ntilde started with args: ...") never reaches the file, on any machine, so - # the obvious assertion is a guaranteed false failure. Build: is the better anchor - # anyway - it proves managed Main got through startup AND that DescribeBuild - # resolved the executable path, the one remaining IL3000 in the tree. - if (-not (Select-String -Path $debugLog -Pattern 'Build:.*path=.*Ntilde\.exe' -Quiet)) { - throw "$debugLog carries no 'Build: ... path=...Ntilde.exe' line. Either managed Main did not reach its startup logging, or DescribeBuild could not resolve the executable path - which in an AOT bundle means Environment.ProcessPath came back empty and the Assembly.Location fallback (IL3000) was reached after all." - } + # Asserts on the THIRD line Program.cs logs at startup, not the first: the first + # ("Ntilde started with args: ...") never reaches the file, on any machine, so + # the obvious assertion is a guaranteed false failure. Build: is the better anchor + # anyway - it proves managed Main got through startup AND that DescribeBuild + # resolved the executable path, the one remaining IL3000 in the tree. + if (-not (Select-String -Path $debugLog -Pattern 'Build:.*path=.*Ntilde\.exe' -Quiet)) { + throw "$debugLog carries no 'Build: ... path=...Ntilde.exe' line. Either managed Main did not reach its startup logging, or DescribeBuild could not resolve the executable path - which in an AOT bundle means Environment.ProcessPath came back empty and the Assembly.Location fallback (IL3000) was reached after all." + } - # Beyond "Main ran": the window and a terminal view came up, and the downloaded - # native PTY loaded and spawned a shell with a live child. Both lines are emitted on - # a pristine profile. If a log reword breaks this, re-anchor to whatever the renderer - # and the PTY now say on first paint - do not delete the check, or this step goes - # back to proving only that a process launched, which #443 also did. - # - # `Spawned ... pid=`, NOT the `Spawning` line this step first shipped with: - # RustPtySession logs `Spawning` BEFORE calling pty_spawn, so a rusty_pty that is - # missing, ABI-incompatible, or simply unable to spawn writes it and then throws - # into TerminalPane.InitializeSessionCore's catch, which shows an error banner and - # leaves the process alive and themed. Every assertion in this step would pass and - # the PTY-less bundle would be published - the precise failure this gate exists to - # stop, since this job's natives come from `Download Native Artifact` and are never - # compiled or run anywhere else. `Spawned` sits after the IsInvalid check and carries - # the child pid, so it cannot be reached without a working native. (Codex P1 on #445.) - foreach ($pattern in @('\[TerminalView\] Theme applied', '\[RustPtySession\] Spawned .*pid=\d+')) { - if (-not (Select-String -Path $debugLog -Pattern $pattern -Quiet)) { - throw "$debugLog has no line matching '$pattern', so the bundle started but its UI never finished coming up. A trimmed-away type reached only while building the terminal view, or a native that failed to load, looks exactly like this." + # The verdict lives in scripts/ci/aot-smoke-verdict.ps1 (self-tested by + # scripts/tests/aot_smoke_verdict_tests.py). The smoke runs on the real default + # (KeepOnClose), so the shell is spawned by the multiplexer daemon (`Ntilde.exe mux + # serve`), not the GUI, and this also proves the AOT daemon path. When the GUI started + # the daemon, the daemon must have served AND spawned a shell (mux.log): a GUI-side + # `Spawned` line alone would be the local fallback hiding a broken daemon. The GUI-only + # line is accepted only when no daemon was started (persistence off). + # `Spawned ... pid=` rather than `Spawning`: Spawning is logged BEFORE pty_spawn + # (Codex P1 on #445). If a log reword breaks this, re-anchor it; do not delete it. + & (Join-Path $PWD 'scripts/ci/aot-smoke-verdict.ps1') -DebugLog $debugLog -MuxLog $muxLog + + Write-Output "Bundle started from a pristine profile, brought up its UI, and stayed up." + } + finally { + # The daemon outlives the GUI process and must never be left running: stop it by its + # own verb first (-Wait: the exe is a WinExe, so a bare call returns at once), then by + # command line (it runs from a staged copy under the profile root, or from the publish + # directory). Nothing here may fail the step. + try { Start-Process -FilePath $exe -ArgumentList 'mux', 'kill-server' -Wait -WindowStyle Hidden } catch { Write-Output "kill-server failed: $_" } + if ($proc) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } + Start-Sleep -Seconds 1 + try { + $publishDir = Split-Path $exe -Parent + Get-CimInstance Win32_Process -Filter "Name = 'Ntilde.exe'" | + Where-Object { + $_.CommandLine -match 'mux\s+serve' -and $_.ExecutablePath -and + ($_.ExecutablePath.StartsWith($publishDir, [StringComparison]::OrdinalIgnoreCase) -or + $_.ExecutablePath.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) + } | + ForEach-Object { + Write-Output "Stopping leftover daemon pid $($_.ProcessId)" + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } } + catch { Write-Output "daemon sweep failed: $_" } } - - Write-Output "Bundle started from a pristine profile, brought up its UI, and stayed up." - Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + # The runner's pwsh wrapper ends the step with `exit $LASTEXITCODE`; kill-server's code + # must not leak into it. + $global:LASTEXITCODE = 0 + exit 0 # win-x64 only. osx-arm64's user-facing zip is the Velopack Portable bundle # (Ntilde.app inside a ditto zip) built by the macOS Velopack steps below and @@ -864,6 +929,22 @@ jobs: # is shared. vpk download github --repoUrl https://github.com/benyblack/ntilde --token $env:GH_TOKEN --outputDir artifacts/velopack + # The release notes both packs below ship (Phase 5 R10): this build's multiplexer protocol + # range, as the marker line an installed app reads from the staged update + # (MuxUpdateCompatibility.ParseProtocolRange) to decide whether applying it keeps the running + # multiplexer and its shells. Read from MuxProtocol.cs, since this job has no ntilde-mux binary + # to ask; publish_mux_daemon checks the binary reports the same range. The script fails + # rather than guess, and a missing marker would read as "compatible" in the field. + - name: Write the Velopack release notes (multiplexer protocol) + if: matrix.rid == 'win-x64' || matrix.rid == 'osx-arm64' + shell: bash + run: | + set -euo pipefail + range="$(bash scripts/ci/mux-protocol-range.sh)" + mkdir -p artifacts + printf '\n' "$range" > artifacts/velopack-release-notes.md + cat artifacts/velopack-release-notes.md + - name: Pack Windows installer (Velopack) if: matrix.rid == 'win-x64' shell: pwsh @@ -959,10 +1040,12 @@ jobs: --packTitle Ntilde ` --packAuthors benyblack ` --icon src/Ntilde.App/Assets/ntilde_icon.ico ` + --releaseNotes artifacts/velopack-release-notes.md ` --outputDir artifacts/velopack ` --noPortable # --noPortable: the release already ships ntilde-win-x64-.zip, and two # near-identical zips on the releases page is a support question waiting to happen. + # --releaseNotes: the multiplexer protocol marker (see "Write the Velopack release notes"). # The installer's file name is ours to choose - nothing resolves it by name - so # give it one that reads clearly next to the zips. Matched by glob because vpk @@ -1132,6 +1215,7 @@ jobs: --bundleId com.benyblack.Ntilde \ --icon artifacts/icon/ntilde_icon.icns \ --exclude 'Ntilde\.dSYM' \ + --releaseNotes artifacts/velopack-release-notes.md \ --outputDir artifacts/velopack \ ${sign_args[@]+"${sign_args[@]}"} @@ -1366,7 +1450,9 @@ jobs: # restore + AOT publish + build-deb.sh + vpk pack, with no preinstalled anything # to fall back on. Not 90 like publish_aot - there is no notarization wait here. timeout-minutes: 60 - needs: [release_metadata, create_release, build_native_linux, release_tests] + # publish_mux_daemon: the App embeds its checksums (Phase 5 Task 10). It needs only + # [release_metadata, create_release, release_tests], so this adds no cycle. + needs: [release_metadata, create_release, build_native_linux, release_tests, publish_mux_daemon] permissions: contents: read strategy: @@ -1534,6 +1620,27 @@ jobs: name: native-${{ matrix.os }} path: src/Ntilde.App/native + # The three ntilde-mux checksums publish_mux_daemon wrote (this job `needs` it), embedded in the App + # below so remote installs verify their download against them (Phase 5 Task 10). Counted: a download + # that silently found nothing would build an App with no pins. + - name: Download the ntilde-mux checksums + uses: actions/download-artifact@v4 + with: + pattern: mux-sha256-* + merge-multiple: true + path: ${{ runner.temp }}/mux-sha256 + + - name: Check the ntilde-mux checksums + shell: bash + env: + MUX_SHA256_DIR: ${{ runner.temp }}/mux-sha256 + run: | + set -euo pipefail + ls -la "$MUX_SHA256_DIR" + for rid in linux-x64 linux-arm64 osx-arm64; do + test -s "$MUX_SHA256_DIR/ntilde-mux-$rid.sha256" || { echo "::error::no ntilde-mux-$rid.sha256 to embed" >&2; exit 1; } + done + - name: Restore run: dotnet restore @@ -1546,12 +1653,14 @@ jobs: env: SKIP_RUST_NATIVE_BUILD: "1" RELEASE_VERSION: ${{ needs.release_metadata.outputs.release_version }} + MUX_SHA256_DIR: ${{ runner.temp }}/mux-sha256 run: | set -euo pipefail dotnet publish src/Ntilde.App/Ntilde.App.csproj \ -c "$CONFIGURATION" -r ${{ matrix.rid }} --self-contained true \ -p:PublishAot=true -p:SkipCliShim=true \ "-p:Version=$RELEASE_VERSION" "-p:InformationalVersion=$RELEASE_VERSION" \ + "-p:NtildeMuxSha256Dir=$MUX_SHA256_DIR" \ -o artifacts/publish/${{ matrix.rid }} # BEFORE anything packages this directory, not after. StripSymbols=true @@ -1691,6 +1800,16 @@ jobs: --channel "$RID" \ --outputDir artifacts/linux + # The release notes the pack below ships: the multiplexer protocol marker, exactly as the + # win and osx lanes write it (see "Write the Velopack release notes" in publish_aot). + - name: Write the Velopack release notes (multiplexer protocol) + run: | + set -euo pipefail + range="$(bash scripts/ci/mux-protocol-range.sh)" + mkdir -p artifacts + printf '\n' "$range" > artifacts/velopack-release-notes.md + cat artifacts/velopack-release-notes.md + - name: Pack AppImage (Velopack) env: RELEASE_TAG: ${{ needs.release_metadata.outputs.release_tag }} @@ -1803,6 +1922,7 @@ jobs: --channel "$channel" \ --icon src/Ntilde.App/Assets/ntilde_icon.png \ --exclude '.*\.pdb|Ntilde\.dbg' \ + --releaseNotes artifacts/velopack-release-notes.md \ --outputDir artifacts/linux echo "Velopack output:" @@ -1907,6 +2027,11 @@ jobs: # in statically (spec §2 decision 1); should the osx-arm64 static link ever fail, that RID # alone falls back to {exe, librusty_pty.dylib}, and this job is where that change would land. # =========================================================================== + # RE-RUNS: "Re-run failed jobs" is safe for a leg that failed before its upload. "Re-run all jobs", or + # a re-dispatch of an already published tag, would re-sign the macOS binary, which changes its hash and + # breaks the pin in Apps of that version that were already downloaded. Both are refused: release_metadata + # stops a run whose tag already has any ntilde-mux asset, and a leg rerun on its own fails right after + # an upload that found its assets already there (scripts/ci/mux-release-guard.sh). Cut a new tag instead. publish_mux_daemon: name: Publish ntilde-mux (${{ matrix.rid }}) runs-on: ${{ matrix.os }} @@ -1917,12 +2042,19 @@ jobs: defaults: run: shell: bash - timeout-minutes: 45 + # 90, like publish_aot: the macOS leg now waits on Apple's notarization queue (see the signing + # steps below), which alone can run 10-30+ minutes on a slow day. Every other leg ends in minutes. + timeout-minutes: 90 # release_tests as well, like every other job that publishes assets (publish_aot, # publish_linux): a release whose tests failed gets no ntilde-mux assets either (#156). needs: [release_metadata, create_release, release_tests] permissions: contents: write + # The secrets context is not allowed in step-level `if`, so the signing gate is an env boolean, + # exactly as in publish_aot. Without the secrets (a fork) the macOS leg still builds and publishes, + # unsigned; it says so in a ::notice::. + env: + MAC_SIGNING_ENABLED: ${{ secrets.MAC_CERT_P12_BASE64 != '' }} strategy: fail-fast: false matrix: @@ -1948,6 +2080,30 @@ jobs: head -2 /etc/os-release sed -n '1p' <<<"$(ldd --version)" + # macOS signing setup precedes the checkout_ref checkout on purpose, as in publish_aot: that ref + # can name a revision that lacks the local action, so the workflow's own ref is checked out first, + # the keychain imported (it lives outside the workspace, so the re-checkout cannot disturb it), + # and only then the release revision. Gated like publish_aot's, so every other leg and the + # unsigned lane keep a single checkout. + - name: Checkout workflow ref (provides the local signing action) + if: matrix.rid == 'osx-arm64' && env.MAC_SIGNING_ENABLED == 'true' + uses: actions/checkout@v4 + + - name: Set up macOS signing keychain + id: mac_signing + if: matrix.rid == 'osx-arm64' && env.MAC_SIGNING_ENABLED == 'true' + uses: ./.github/actions/setup-mac-signing + with: + cert-p12-base64: ${{ secrets.MAC_CERT_P12_BASE64 }} + installer-p12-base64: ${{ secrets.MAC_INSTALLER_CERT_P12_BASE64 }} + cert-password: ${{ secrets.MAC_CERT_PASSWORD }} + sign-app-identity: ${{ secrets.MAC_SIGN_APP_IDENTITY }} + sign-install-identity: ${{ secrets.MAC_SIGN_INSTALL_IDENTITY }} + notary-profile: ${{ secrets.MAC_NOTARY_PROFILE }} + apple-id: ${{ secrets.MAC_APPLE_ID }} + team-id: ${{ secrets.MAC_TEAM_ID }} + app-specific-password: ${{ secrets.MAC_APP_SPECIFIC_PASSWORD }} + - uses: actions/checkout@v4 with: ref: ${{ needs.release_metadata.outputs.checkout_ref }} @@ -2095,6 +2251,59 @@ jobs: echo "$version_json" grep -qF "\"version\":\"$RELEASE_VERSION\"" <<<"$version_json" \ || { echo "::error::ntilde-mux --version --json does not report version $RELEASE_VERSION" >&2; exit 1; } + # The Velopack release notes state the protocol range read from MuxProtocol.cs by + # scripts/ci/mux-protocol-range.sh (Phase 5 R10); the built binary must report the same one. + range="$(bash scripts/ci/mux-protocol-range.sh)" + if ! grep -qF "\"protocolMin\":${range%-*}," <<<"$version_json" || ! grep -qF "\"protocolMax\":${range#*-}," <<<"$version_json"; then + echo "::error::ntilde-mux --version --json reports another protocol range than MuxProtocol.cs ($range)" >&2 + exit 1 + fi + + # Sign and notarize ntilde-mux for macOS (Phase 5 Task 10), BEFORE the checksum is written: the + # .sha256 must describe the bytes users download, and the App pins that hash. A bare executable + # cannot carry a stapled ticket, so Gatekeeper checks the notarization online on first run. + # The hardened runtime (--options runtime) is what notarization requires; ntilde-mux needs no + # entitlement for it. Identities reach the script through env, never by interpolation: they contain + # spaces and parentheses. + - name: Sign and notarize ntilde-mux (macOS) + if: matrix.rid == 'osx-arm64' && env.MAC_SIGNING_ENABLED == 'true' + env: + RID: ${{ matrix.rid }} + RELEASE_VERSION: ${{ needs.release_metadata.outputs.release_version }} + KEYCHAIN: ${{ steps.mac_signing.outputs.keychain }} + MAC_SIGN_APP_IDENTITY: ${{ secrets.MAC_SIGN_APP_IDENTITY }} + MAC_NOTARY_PROFILE: ${{ secrets.MAC_NOTARY_PROFILE }} + run: | + set -euo pipefail + bin="artifacts/mux-daemon/$RID/ntilde-mux" + codesign --force --timestamp --options runtime --identifier com.benyblack.ntilde-mux --keychain "$KEYCHAIN" --sign "$MAC_SIGN_APP_IDENTITY" "$bin" + codesign --verify --strict --verbose=2 "$bin" + zip_dir="$(mktemp -d)" + ditto -c -k --keepParent "$bin" "$zip_dir/mux.zip" + # The JSON status decides, not the exit code: an Invalid submission has been reported to exit 0, + # and a missed rejection would be hashed and pinned into every App of this release. A non-zero + # exit is handled too: `|| rc=$?` keeps set -e from killing the step before the log is fetched. + rc=0 + out="$(xcrun notarytool submit "$zip_dir/mux.zip" --keychain-profile "$MAC_NOTARY_PROFILE" --keychain "$KEYCHAIN" --wait --output-format json)" || rc=$? + echo "$out" + status="$(jq -r '.status // "unknown"' <<<"$out" 2>/dev/null || echo unknown)" + id="$(jq -r '.id // empty' <<<"$out" 2>/dev/null || true)" + if [[ "$rc" != "0" || "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then + xcrun notarytool log "$id" --keychain-profile "$MAC_NOTARY_PROFILE" --keychain "$KEYCHAIN" || true + fi + echo "::error::ntilde-mux notarization status: $status (notarytool exit $rc)" >&2 + exit 1 + fi + # Signing and notarizing must not have changed what the binary reports. + version_json="$("$bin" --version --json)" + echo "$version_json" + grep -qF "\"version\":\"$RELEASE_VERSION\"" <<<"$version_json" \ + || { echo "::error::signed ntilde-mux --version --json does not report version $RELEASE_VERSION" >&2; exit 1; } + + - name: Note that ntilde-mux is not signed (macOS) + if: matrix.rid == 'osx-arm64' && env.MAC_SIGNING_ENABLED != 'true' + run: echo "::notice::ntilde-mux is not signed (no signing secrets)" # Flat release-asset names, then the checksum beside each, written from inside the # directory so the .sha256 names the bare asset (` ntilde-mux-`). @@ -2114,15 +2323,39 @@ jobs: test -f "artifacts/release/ntilde-mux-$RID.sha256" || { echo "::error::no ntilde-mux-$RID.sha256" >&2; exit 1; } (cd artifacts/release && cat "ntilde-mux-$RID.sha256" && ls -la "ntilde-mux-$RID") + # Never replaces an asset that is already there (overwrite_files defaults to true): installed Apps + # pin its hash. At this pinned SHA (v2.6.2) `false` SKIPS an existing asset and the step still + # succeeds, leaving it out of the `assets` output, so the next step fails the leg on any skip. - name: Upload ntilde-mux release assets + id: mux_upload uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ needs.release_metadata.outputs.release_tag }} fail_on_unmatched_files: true + overwrite_files: false files: | artifacts/release/ntilde-mux-${{ matrix.rid }} artifacts/release/ntilde-mux-${{ matrix.rid }}.sha256 + # A leg rerun on its own skips release_metadata's guard. If its assets were already published, the + # upload above kept them and this run's checksum describes bytes the release does not carry: fail + # here, before the next step hands that checksum to the App builds. + - name: Check this run uploaded both ntilde-mux assets + env: + RID: ${{ matrix.rid }} + UPLOADED_ASSETS: ${{ steps.mux_upload.outputs.assets }} + run: bash scripts/ci/mux-release-guard.sh uploaded "$RID" + + # The checksum again, as a workflow artifact: publish_aot and publish_linux embed all three in the + # App (-p:NtildeMuxSha256Dir) so a download is verified against what THIS release shipped, not + # against a .sha256 that sits beside a possibly swapped binary. + - name: Upload the ntilde-mux checksum for the App builds + uses: actions/upload-artifact@v4 + with: + name: mux-sha256-${{ matrix.rid }} + path: artifacts/release/ntilde-mux-${{ matrix.rid }}.sha256 + if-no-files-found: error + # THE GATE, plus the only upload. Runs on a plain runner because both halves need # what a `container:` job does not have: a docker daemon (smoke-test.sh) - see the # block comment above publish_linux. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..df943c613 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +User-facing changes, newest release first. This file starts with 0.12.0: the notes for earlier +releases are on [GitHub Releases](https://github.com/benyblack/ntilde/releases). + +## 0.12.0 + +### Persistent sessions and the multiplexer + +Shells can now outlive Ntilde's window. They run in a background process, Ntilde's own +multiplexer, and come back when Ntilde starts again. The +[user manual's chapter 12](docs/USER_MANUAL.md#12-persistent-sessions-and-the-multiplexer) covers +it all. + +- **Local shells can keep running** when the window closes, when Ntilde crashes and across an update. + Reopening Ntilde reattaches every tab to its shell, screen and scrollback included. Settings → + Appearance → *Keep shells running when the window closes* switches it between *Keep running* and + *Off*. +- **On by default** for local shells. A settings file without `SessionPersistence` - every install + that never changed it - gets *Keep running*; one that says `"Off"` stays off. SSH tabs keep + running only for the connections you opt in. +- **The first close asks** whether the shells keep running (*Keep running* or *Close them*, with + *Don't ask again*). **Session: Quit and Close All Shells** in the command palette, or the link + under the setting, ends every local shell and the multiplexer; remote shells keep running. +- **After a restart of the computer** the tabs start fresh shells where they were, without a + warning. +- **Detach and attach.** **Pane: Detach** closes a pane and keeps its shell running. + **Session: Attach to Session…** opens any running shell in a new tab - a detached one, one another + window shows, or one on a remote host - and several windows can share a shell. +- **SSH tabs that survive network drops.** Tick *Keep remote sessions running (ntilde-mux)* on an SSH + connection and install `ntilde-mux` on the host from the same tab of the connection editor. The + tab's shell then runs on the host: it survives a dropped network, a sleeping laptop and a closed + window. After a drop the tab reconnects by itself when it can sign in without you (your keys, + your agent or a saved password), and otherwise waits for Enter. Hosts: Linux on x64 or arm64 with + glibc 2.34 or newer, and macOS on Apple silicon. *Remote Files* and SFTP transfers work + on these tabs (the sidebar with the native SSH backend); port forwards do not. +- **Updates keep your shells** when the new version can talk to the running multiplexer. On Windows + it runs from its own copy in Ntilde's data folder for this. When it is from an older build, a + notification offers **Restart multiplexer now**; when an update cannot keep it, Ntilde asks before + it closes the shells. +- **Command line:** `ntilde mux ls [--all] [--json]`, `ntilde mux attach ` (shows a shell in any + terminal; detach with Ctrl+\ then d), `ntilde mux kill ` and `ntilde mux kill-server [--force]`. +- **Windows console launcher.** `ntilde` typed in PowerShell or cmd now runs `ntilde.com`, which + waits for a command-line mode and returns its exit code, and returns at once for a window. The + installer adds Ntilde's folder to your user `PATH`. +- **Agents** (MCP, with *Agent access* on) also see the shells no window shows, marked windowless: + they can read them, and with *Agent access (act)* type into and close them. + +The other changes in 0.12.0 are listed in its GitHub release notes. diff --git a/Directory.Build.props b/Directory.Build.props index 20e089be8..3f32410e1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,9 +1,9 @@ - 0.11.0 - 0.11.0.0 - 0.11.0.0 - 0.11.0 + 0.12.0 + 0.12.0.0 + 0.12.0.0 + 0.12.0 diff --git a/README.md b/README.md index cabd1a82c..ebf558b60 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Most terminal emulators optimize for speed or features. Ntilde focuses on someth Designed for future workflows (cloud, automation, AI-assisted tooling). - 🤖 **Built for AI agents**\ An opt-in MCP server lets Claude Code and other agents observe your live terminal sessions --- and, behind a separate opt-in, drive them. +- 🔁 **Persistent sessions**\ + Shells keep running when you close the window, reattach when you reopen, and SSH tabs survive network drops. > **Terminal correctness is enforced by automated tests, not guesswork.** @@ -275,6 +277,26 @@ the flip. - Runtime password memory (opt-in, session-scoped) - Native SFTP transfers and a pane-local remote-files sidebar +### Persistent sessions + +With persistent sessions on, shells run in a background process, Ntilde's own multiplexer, instead +of in the window ([user manual, chapter 12](docs/USER_MANUAL.md#12-persistent-sessions-and-the-multiplexer)). + +- Closing the window, a crash or an update leaves your shells running; reopening Ntilde reattaches + every tab to its shell, screen and scrollback included +- The first close asks whether the shells keep running; *Quit and close all shells* ends them all +- After a restart of the computer, tabs start fresh shells where they were, without a warning +- Several windows can share one shell; *Attach to session…* reopens a detached shell, local or on a + remote host +- Persistent SSH tabs: the shell runs on the host in `ntilde-mux`, which Ntilde installs there for + you, so it survives network drops, a sleeping laptop and a closed window; the tab reconnects by + itself when it can sign in without you (keys, agent or a saved password), and otherwise on Enter + (Linux x64/arm64 hosts with glibc 2.34+, and Apple-silicon macOS) +- `ntilde mux ls [--all]`, `attach`, `kill` and `kill-server` from any terminal +- Agents (MCP) can also see and, with the act opt-in, drive shells no window shows +- On by default for local shells: Settings → Appearance → *Keep shells running when the window + closes* turns it off; SSH tabs opt in per connection + ### Cross-platform parity Ntilde guarantees identical terminal behavior across operating systems for VT interpretation, buffer state, wrapping & reflow, and search semantics. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 688421ae4..3f6b300c2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -208,7 +208,9 @@ Shell composition glue (startup orchestration, app paths/logging/services, sessi ### 8.1 Persistent sessions: the mux daemon -With `TerminalSettings.SessionPersistence = "KeepOnClose"` (default `"Off"`), local panes run their +With `TerminalSettings.SessionPersistence = "KeepOnClose"` (the default since 0.12.0, +`TerminalSettings.DefaultSessionPersistence`; a settings file that says `"Off"` keeps Off, Phase 5 +spec R3), local panes run their shells in a separate daemon process and survive window close, an app crash and a restart. SSH panes whose profile sets `SshMuxOptions.PersistRemoteSessions` do the same on the remote host (section 8.2). The local daemon is **a CLI mode of the app executable** (`Ntilde mux serve`), not a separate @@ -225,7 +227,8 @@ holds every verb, and `Ntilde.Mux.Daemon.MuxServeHost` is the `serve` process ar | `Paths` (`MuxPaths`: root, descriptor, endpoint, log folder) | the app-data root, or `NTILDE_APPDATA_ROOT` (`MuxPaths.Default`); a daemon spawned for another root is handed it through `NTILDE_APPDATA_ROOT` | its own root beneath it, `/ntilde-mux`, or `NTILDE_MUX_ROOT` (`MuxPaths.Standalone`); a daemon spawned for another root is handed it through `NTILDE_MUX_ROOT`. On a host that also runs the GUI, the two daemons share no descriptor, socket, lock or log | | `ServeArguments` | `mux serve` | `serve` | | `SessionFactory` | `DefaultTerminalSessionFactory` (the GUI's shells, unchanged) | `LocalShellSessionFactory`: an empty command is the user's login shell with `-l`; `~` is `$HOME`; SSH is refused | -| `Verbs` | serve, ls, kill, kill-server, attach, probe-console | serve, proxy, ls, kill, kill-server, attach, `--version` | +| `Verbs` | serve, ls, kill, kill-server, attach, probe-console, and the hidden `spawn-for-test` (verification runs, Phase 5 Task 24). `ls --all` is taken by the App's adapter before `MuxCli` (`MuxLsAll`, section 8.2) | serve, proxy, ls, kill, kill-server, attach, `--version`; `ls --all` exits 2 ("--all needs the ntilde app") | +| `DaemonImageResolver` | `MuxDaemonImage.ResolverFor`: on a Windows Velopack install, the daemon's own copy outside the install root (below) | none: the running executable | | `PrepareForegroundConsole` | `CliConsoleBindings.Prepare` | none | ``` @@ -290,6 +293,101 @@ holds every verb, and `Ntilde.Mux.Daemon.MuxServeHost` is the `serve` process ar socket itself is `0600`. A stale socket is probe-connected before it is unlinked; a live one means refuse. - Never TCP. +- **Sends never block (Phase 5 spec R6).** `MuxClient` takes a send from any thread without + blocking: a frame that finds the bounded send queue full waits in a per-client overflow, which one + pump moves into the queue in call order, so input, resize, detach and kill reach the wire in the + order they were sent whichever way they went. More than `MuxClientOptions.MaxOverflowBytes` (8 MiB) + waiting behind a stalled link ends the connection (`send overflow`). So the UI thread never waits on + a full queue, local or remote. +- **Closing the window (Tasks 16, 17).** With live local shells, `OnClosing` holds the close and asks + the first-close question (`BuildFirstCloseDialog`: Keep running / Close them / Don't ask again), + unless `MuxCloseChoiceStore` (`/mux-close-choice`, `keep` or `close`) remembers an answer. + Settings deletes that file when a save, import or restore changes `SessionPersistence`. Close ends + the window's local shells through `EndLocalSessionsOnTeardown` (tracked kills, flushed by the + hosts' dispose), skipping a shell another interactive client shows or a share whose sharing is + unknown. The ids that tabs not shown yet hold pending (`PendingLocalMuxSessionIds`, shares and + quiet-lost ids excluded; a startup tab not built yet is a placeholder with no pane, so its ids come + from its saved tree, `HeldLocalMuxSessionIds`) are counted and ended too, once the local daemon's current connection lists + them running with no interactive client (`UnshownLocalMuxSessionsAsync`, 2 s; none when it cannot + say); the close is held for that listing. `ApplicationShutdown` (macOS Cmd+Q) applies a remembered answer and never asks; + `OSShutdown` never kills. "Quit and close all shells" (`QuitAndCloseAllShellsAsync`) kills every + session the local daemon lists and shuts it down (`ShutdownLocalDaemonAsync`). Ended shells are + saved as gone (`MarkLocalShellsEnded`), so the next launch starts fresh ones without a notice; when + the whole daemon stops (this quit, an update that cannot keep it) that covers every local id the + window holds without a live pane too, shares included (`HeldLocalMuxSessionIds`). The quit marks + those only once they are known gone (`MarkHeldLocalShellsEnded`: the stop returned `Gone` or + `StopUnconfirmed`, or each id was killed by name), before `Close()`, whose teardown save reads the + marks; otherwise they stay named and reopen in their tabs. The update marks them all before its + shutdown, as it saves before it; the accepted corner is a daemon judged `UnknownImage` that really + runs outside the root and survives a failed shutdown: its shells keep running unnamed, and the next + launch adopts them as orphans. Task 23's restart marks nothing: its panes let go of their ids, and a + pending id stays named in case the stop failed. +- **Quiet restore (R2).** `Shell/Native/SessionStartBoundary.Read` gives the time no live local session + can predate: on Windows the later of the boot (`UtcNow - TickCount64`) and this logon session's start + (`WTSQuerySessionInformationW`, `WTSSessionInfo`), since Fast Startup's "Shut down" is a logoff that + keeps the tick count; on Linux `/proc/stat`'s `btime`; on macOS `kern.boottime`; null when it cannot + be read. A session file last written before it (`MuxRestoreExpectations`) marks every restored local + node `PaneNode.MuxQuietPreviousLost`, and such a pane whose shell is gone starts a fresh one with no + banner and no notice. A null boundary keeps every notice; remote nodes are never marked; a pane that + has not spawned writes the mark back, so an unvisited background tab stays quiet in the next launch + of the same boot. +- **Designer and test windows never spawn (R3).** `AppServiceBundle` carries the `MuxHostFactory` seam; + `AppServices.BuildForDesigner`'s `MuxHostFactory` refuses, and `CreatePersistentSessionFactory` falls + back to normal sessions, whatever the settings say. +- **Build version (Phase 5 Tasks 20, 23).** The daemon reports its build in `WelcomeResult.ServerVersion` + and the descriptor's `AppVersion` (both optional, absent from older daemons). A connection to a + daemon of another build raises one *Multiplexer* notice per endpoint per launch + (`MuxPreviousBuildNotice`, `MainWindow.MuxRestart.cs`): "previous", "newer" or "different" by + SemVer precedence, and never for an unknown version (null or `0.0.0`). A remote daemon is judged + against the version installed on its host (`SshMuxOptions.RemoteDaemonVersion`, read through the + host's `RemoteMuxConnector`), since a restart there starts that binary and an app update never + replaces it (`MuxPreviousBuildNotice.DecideRemote`): a restart only when the running one is older + than that record (a newer or unordered one means the per-profile record is stale), else "Update + ntilde-mux on {host}…" (the install dialog) when the running one is older than the app; the + click-time last look checks the same. A remote host's offer stays claimed for the launch once a + `shutdown` went out, and is released, and decided again, after each recorded install + (`DecideMuxNoticeAgainAfterInstall`). Its restart lets every pane + of that daemon go first (`TerminalPane.LetGoOfMuxSessionForRestart`: a plain detach, so the stop + cannot reach a pane as its shell's exit), sends `shutdown`, terminates a local daemon by pid after + 5 s (`MuxDaemonStop.Terminate`), and holds Enter until it is over. A launch-wide `Launch` keeps one + restart per endpoint across windows. +- **The daemon image on a Windows install (R9).** Velopack's Windows apply kills every process whose + image is under the install root, `\OpenConsole.exe` console hosts included. So on a Velopack + install `MuxDaemonImage.Resolve` stages `Ntilde.exe`, the DLLs beside it and every + `\OpenConsole.exe` into `\bin\...tmp\`, writes `.complete` (the + files' sizes and the executable's SHA-256) last, and renames it to `\bin\\` + (`-` when a re-pack under the same version differs). `ProcessMuxDaemonSpawner` starts that + copy (`MuxCliHost.DaemonImageResolver`), so the GUI, `ntilde mux` and `ntilde.com` spawn the same + image; elsewhere, and in dev builds, the running executable. The daemon's working directory is the + user profile, never under the install root (a working directory in `current\` makes the apply's + rename fail). `StartPruningOnce` removes other versions' copies once per launch, after the first + connect; Velopack's uninstall hook (`MuxUninstall`) stops the daemon as `kill-server --force` does + (`MuxDaemonStop`) and removes every copy. Only copy-shaped folders are ever deleted + (`docs/CONFIG_STORAGE_CONTRACT.md`). +- **Updates (R10).** `release.yml` puts `` in the release notes + every `vpk pack` ships (`scripts/ci/mux-protocol-range.sh` reads `MuxProtocol.cs`). Applying a staged + update, the GUI reads it (`MuxUpdateCompatibility.ParseProtocolRange`; no marker counts as + compatible) and keeps the daemon when the ranges overlap and its image is outside the install root + (`MuxUpdateCompatibility.WhyUpdateStopsDaemon` returns `None`): no question, no `shutdown`, and the + teardown detaches as on any close. Otherwise it asks, giving that reason (the protocol, the install + folder, or an image or descriptor it cannot read; `SessionLossQuestion`), saves the session without the + shells about to end, and sends `shutdown`. Velopack's startup auto-apply is vetoed + (`MuxUpdateCompatibility.StartupApplyHoldFor`, any reason but `None`) only for a live daemon the apply + would kill; `Program.Main` logs that reason once AppLogger is up (`DescribeStartupApplyHold`). With + `SessionPersistence` explicitly off, both paths keep their pre-Phase-5 behaviour: any live daemon is + asked about and shut down, and vetoes the startup apply. +- **"Attach to session…" (Phase 5 spec §5).** `MuxSessionPicker` builds the rows: each listed host's + sessions (the local daemon first; a remote host only while it is connected, `CurrentClient`, and its + profile keeps its sessions), a `MuxSessionPickerConnectRow` for each such profile with no connection, + and a disabled `MuxSessionPickerErrorRow` worded from `MuxPickerHostError` for a host that could not + be listed. Every host is listed at once, each within its own wait. The remote hosts it lists are held + until the choice is acted on, so a release pass cannot close one whose rows are on screen. A remote + row opens an SSH-profile pane attached `Shared` on `ssh:`; a connect row connects + interactively and reopens the picker. +- **Windowless sessions (R4, R5).** `MuxWindowlessSessions`, over the window's `MuxConnectionHosts` + (`CurrentClient` only, so the agent path never connects or prompts), is the agent host's + `IWindowlessSessionSource`: the sessions no pane of the window shows, listed, read with `readScreen`, + typed into and killed, each operation under one 7 s deadline. See `docs/agent-host/DIRECTION.md`. #### Protocol v2 @@ -318,6 +416,14 @@ Fallback matrix (spec §2.1): | v2 text client ↔ v1 daemon | 1 | Shared attach works; `--read-only` exits 2 ("too old for --read-only") | | v1 client ↔ v2 daemon | 1 | Unchanged: the server never sends `sessionChanged` or `killed` to a client that didn't negotiate v2 | +Phase 5 adds three optional members, on any negotiated version; the range stays 1..2: + +| Item | Phase 5 addition | +|---|---| +| Method `readScreen` | `ReadScreenParams { SessionId, MaxScrollbackRows }` → `ReadScreenResult`: the headless buffer as a serialized `TerminalStateSnapshot` (`CaptureSnapshot`) plus the `sessionInfo` status, taken together on the session's parse thread. Rows are clamped to 0..2000 (`MuxReadScreenLimits`), never refused; a snapshot over 4 MiB is `snapshot_too_large`, and the caller retries with fewer rows. The reply is charged to the connection's snapshot account, never its stream budget, so reads never look like a slow client. A daemon that predates it answers `protocol_error`, which `MuxClient.ReadScreenAsync` returns as null ("unsupported"); every other failure is an exception | +| `WelcomeResult.ServerVersion` | the daemon's build version (no build metadata); absent when unknown | +| `MuxEndpointDescriptor.AppVersion` | the same, in the descriptor | + #### Attach modes `IfUnattached` is decided inside the attach control item, on the session's own parse thread @@ -419,12 +525,16 @@ it). | `RpcTimeout` | 3 s | 10 s | | Liveness ping | none | every 15 s, 10 s to answer | | Reconnect loop | none | 10 minutes | -- **Connecting** (`Shell/Mux/Remote/RemoteMuxConnector`). The remote command is ` proxy --stdio` - when the install flow recorded a path made only of characters no shell treats specially - (`RemoteMuxCommand.IsSafeAbsolutePath`), and otherwise - `sh -c 'exec "$HOME/.local/share/ntilde/bin/ntilde-mux" proxy --stdio'`: one single-quoted script - with no single quote inside, which every login shell (bash, zsh, fish, tcsh, nushell) hands to `sh` - untouched. Neither form looks anything up on `PATH`. The transport starts off the UI thread; +- **Connecting** (`Shell/Mux/Remote/RemoteMuxConnector`). The remote command (`RemoteMuxCommand`) is + ` proxy --stdio` when the install flow recorded a path made only of characters no shell treats + specially; `sh -c 'exec "" proxy --stdio'`, with `$`, a backtick and `"` escaped, when the + recorded path can sit in sh's double quotes (`IsQuotableAbsolutePath`: absolute, and no `'`, `\`, `!`, + control, Unicode format or separator character, nor an unpaired surrogate); and otherwise + `sh -c ' exec "$d/ntilde-mux" proxy --stdio'`, where `$d` is + `$XDG_DATA_HOME/ntilde/bin` when that is set and absolute, else `$HOME/.local/share/ntilde/bin` (the + rule the daemon's own root follows). Each is one single-quoted script with no single quote inside, + which every login shell (bash, zsh, fish, tcsh, nushell) hands to `sh` untouched. None looks anything + up on `PATH`. The transport starts off the UI thread; `StdioMuxTransport.ConnectAsync` discards whatever rc files and the MOTD print until the line `NTILDE-MUX-PROXY 1 ` (bounded at 64 KiB and by the 120 s connect timeout, after which the captured text is the error), and `MuxClient.ConnectAsync` sends the host's `ClientInstanceId`. @@ -454,6 +564,13 @@ it). the remote client's sessions as orphans and end them with its update flow's `shutdown`. The sun_path fallback (`$XDG_RUNTIME_DIR` or the temp directory) is named by a hash of the root, so it stays apart too. +- **A stable agent socket** (`Ntilde.Mux.Daemon.AgentSocketLink`, Unix, Phase 5 Task 8). The daemon + outlives the connection that forwarded an agent, so its shells get `SSH_AUTH_SOCK` = `agent.sock` + beside the endpoint (in its `0700` directory), and every `proxy --stdio` repoints that link at its own + connection's `SSH_AUTH_SOCK` (symlink to a temp name, then rename) - only once that socket answers a + connect and its listener runs as this user (`SO_PEERCRED` on Linux, `LOCAL_PEERCRED` on macOS, against + `geteuid()`; fail closed when it cannot be checked). A link path too long for `sun_path` is skipped, + logged once. - **Dead twins.** A host's `MuxClient` sends the same random `ClientInstanceId` in every hello for the host's life. The server closes every other connection carrying that id before it replies, so the half-open connection a drop leaves behind neither keeps the session "shared with 1" nor blocks @@ -533,9 +650,33 @@ it). stand-in; `NotInstalled`, `Unsupported`, `VersionMismatch` and `ProxyFailed` on a new tab give the plain SSH session with a notice, which offers the install flow for `NotInstalled` and `VersionMismatch`. -- **Not on remote panes, this phase.** A persisted remote pane is not an `ActiveSshSessionRegistry` - session, so the SFTP sidebar, remote files and the palette's SFTP transfers are disabled for it, and the profile's port forwards - are not set up (`ClearAllForwardings=yes`; the native exec mode has no forward router). +- **SFTP and remote files (Phase 5 spec R8).** A native persisted pane registers in + `ActiveSshSessionRegistry` as its mux session id, with its host's `PasswordScopeId`; the registry + keeps every live descriptor per id, so two windows on one shared session each keep theirs. The + sidebar, path completion and transfers open their own non-interactive connections, as a plain native + tab's do. `RemoteMuxInteractionHandler` writes a password into the host's scope only when the user + typed it in an attempt that got in - never a vault-filled answer, an empty one, or anything on a + profile with jump hops (R7) - and the scope is cleared when the host goes. While the host still runs + on a destination the profile no longer names (`MuxConnectionHost.IsRetargeted`), the sidebar, + listing and transfers are refused on that tab, checked again at every listing and once each dialog + is answered. An OpenSSH persisted pane gets the palette's transfers (`scp -B`, its own connection), + and no sidebar, as a plain OpenSSH tab. Port forwards are still not set up (`ClearAllForwardings=yes`; + the native exec mode has no forward router): they would ride the exec channel's single event queue, + drop on every reconnect and collide with plain tabs of the profile. +- **Shared remote panes (Phase 5 Task 26).** A pane opened from the picker keeps its share through a + drop (`_muxReattachShared` on the remote paths), so a reconnect reattaches `Shared` and a share whose + session is gone takes `ShareEnded`, not a fresh shell. A share whose sharing cannot be known now - + link down or reconnecting, attach pending, no connection to its host - closes as a detach, unasked, + local or remote, and queues no kill; the *Shell detached* notice says so. +- **`ntilde mux ls --all`** (`Shell/Mux/MuxLsAll`, `Remote/RemoteMuxLister`, Phase 5 spec §5). The App's + `mux` adapter takes `ls --all [--json]` before `MuxCli`: it lists the local daemon as `ls` does + (never spawning one) and, all at once, every profile with `PersistRemoteSessions`, each through a + connector of its own with the automatic attempt's rules (never interactive: keys, agent, the vault's + saved password once, an existing ControlMaster), its own `ClientInstanceId` (so it never evicts a + window's connection), and a 15 s wait. Off Windows it skips an OpenSSH profile that goes through a + jump host or proxy (`MuxListingError.ThroughJumpHost`): BatchMode does not reach a ProxyJump hop, so + its prompt could reach the user's terminal. Reasons are worded from `MuxListingError`, remote titles + are `RemoteOutputText.Quote`d, and each failure's own text goes to `logs/mux-ls-all.log`. #### Exec transports (`Ntilde.Platform/Ssh/Exec/`) @@ -573,7 +714,9 @@ deadline; the installer uses it. `ExtendedData` (kind 14), the command's EOF as `Eof` (kind 15, which ends `Stdout` at once), the exit status (kind 7) before `Closed`, and `nova_ssh_send_eof` ends stdin. One dedicated poll thread per channel routes the events: stdout into a bounded byte queue - read without the thread pool, stderr into the tail, prompts to the handler. A native connection + read without the thread pool, stderr into the tail, prompts to the handler. Between events it waits + in `nova_ssh_wait_event` (up to 100 ms, woken by an event or a close) instead of sleeping, which took + the ~15 ms floor off every request round trip (Phase 5 Task 9). A native connection lives exactly as long as its channel, so an exec never shares a pane's connection. #### Install flow (`Shell/Mux/Remote/RemoteMuxInstaller.cs`, `Views/Ssh/RemoteMuxInstallDialog.cs`) @@ -583,8 +726,10 @@ Each step is an exec over the same transports, with the same prompts as a connec 1. **Probe**: `uname -sm`, the libc line and `$HOME`. `RemoteHostProbe.Parse` (pure) maps the host to linux-x64, linux-arm64 or osx-arm64, and refuses musl, glibc older than 2.34, Intel Macs and anything else, with the reason. -2. **Asset** (`IMuxDaemonAssetSource`): the GitHub release's `ntilde-mux-`, verified against its - `.sha256` and cached at `/cache/ntilde-mux//`; or a local file, whose ELF +2. **Asset** (`IMuxDaemonAssetSource`): the GitHub release's `ntilde-mux-`, verified against the + SHA-256 built into the app (`MuxAssetPins`: the release embeds the hashes `publish_mux_daemon` + computed; a build without them, dev or CI, falls back to the release's `.sha256`) and cached at + `/cache/ntilde-mux//`; or a local file, whose ELF or Mach-O header must name the probed RID. `MuxDaemonRid` reads the header. 3. **Upload, validate, commit** (`RemoteMuxInstallCommands`): two `sh -c` execs that name the upload by a fresh token. `UploadForTrial` has the binary on stdin: it sweeps upload temps over an hour old, @@ -597,13 +742,14 @@ Each step is an exec over the same transports, with the same prompts as a connec 4. **Record**: `SshConnectionService.RecordRemoteMuxInstall` writes only `RemoteDaemonPath`, `RemoteDaemonVersion`, `RemoteDaemonRid` and, when the user ticks it, `PersistRemoteSessions`, on the store's own copy of the profile. Compatibility is decided by the handshake; - `RemoteDaemonVersion` only drives the editor's status line and the notice's wording. + `RemoteDaemonVersion` drives the editor's status line, the install dialog's first line, and whether + the *Multiplexer* notice offers a running remote daemon a restart or the update. #### The two AOT binaries | Binary | Project | Built and shipped | |---|---|---| -| `ntilde-mux` | `src/Ntilde.Mux.Daemon` | NativeAOT, one file per RID: linux-x64, linux-arm64, osx-arm64 (about 6 MB). `librusty_pty.a` is linked statically (`DirectPInvoke` + `NativeLibrary`; `native/Cargo.toml` builds `cdylib` and `staticlib`), so the only dynamic dependencies are libc, libm, libgcc_s and the loader; the highest `GLIBC_` symbol is 2.34. CI's `mux_daemon_aot` builds the three RIDs (ubuntu:22.04 containers, macos-latest), asserts one file and the glibc ceiling, and smokes `--version --json`, `serve`, `ls` and `kill-server`. The release's `publish_mux_daemon` uploads `ntilde-mux-` and `ntilde-mux-.sha256`. Not bundled with the app: the install flow downloads it. | +| `ntilde-mux` | `src/Ntilde.Mux.Daemon` | NativeAOT, one file per RID: linux-x64, linux-arm64, osx-arm64 (about 6 MB). `librusty_pty.a` is linked statically (`DirectPInvoke` + `NativeLibrary`; `native/Cargo.toml` builds `cdylib` and `staticlib`), so the only dynamic dependencies are libc, libm, libgcc_s and the loader; the highest `GLIBC_` symbol is 2.34. CI's `mux_daemon_aot` builds the three RIDs (ubuntu:22.04 containers, macos-latest), asserts one file and the glibc ceiling, and smokes `--version --json`, `serve`, `ls` and `kill-server`. The release's `publish_mux_daemon` signs and notarizes the osx-arm64 binary (notarytool's status must be `Accepted`), uploads `ntilde-mux-` and `ntilde-mux-.sha256`, and hands the hashes to the app builds, which embed them (`MuxAssetPins`). Not bundled with the app: the install flow downloads it. | | `ntilde.com` | `src/Ntilde.Launcher` | NativeAOT win-x64 (about 0.9 MB), no project or package references, kernel32 P/Invokes only. The release copies it into the win-x64 bundle beside `Ntilde.exe` before the zip and `vpk pack`; CI's `aot_gate` and the release smoke it with a `cmd.exe` stand-in (`/d /c exit 7` must exit 7). Section 8.3. | `ntilde-mux` must run on libc alone. On Linux, .NET loads OpenSSL at run time for @@ -640,7 +786,8 @@ PATHEXT makes `ntilde` resolve to it first. `Ntilde.Launcher`'s `Program`: in `Main`'s IL. - **PATH.** The installer put nothing on `PATH` before. Velopack's `OnAfterInstall` and `OnAfterUpdate` fast callbacks call `UserPathRegistration.Ensure()`, and - `OnBeforeUninstall` calls `Remove`, where the install dir is the directory of + `OnBeforeUninstall` calls `MuxUninstall.Run` (section 8.1: it stops the daemon that runs from outside + the install root) and then `Remove`, where the install dir is the directory of `Environment.ProcessPath` (Velopack's stable `current` folder). `Merge` is pure: case-insensitive, tolerant of trailing separators, and it keeps order and `%VAR%` spellings. The write is HKCU `Environment\Path` as `REG_EXPAND_SZ`, only when the value changed, followed by a diff --git a/docs/CONFIG_STORAGE_CONTRACT.md b/docs/CONFIG_STORAGE_CONTRACT.md index 512db8776..619e9f8d0 100644 --- a/docs/CONFIG_STORAGE_CONTRACT.md +++ b/docs/CONFIG_STORAGE_CONTRACT.md @@ -19,6 +19,17 @@ directory: *"any data stored within it, such as settings or logs, will be lost"* narrower — only the `current\` subdirectory is replaced — so **uninstall is the destructive path, not update.** +One exception reaches into the config root, by design: on Windows the local multiplexer daemon runs +from its own copy under `bin\` (see the inventory), outside the install root, so an update leaves +it running. Uninstall therefore stops it in Velopack's uninstall hook (`MuxUninstall`, as +`ntilde mux kill-server --force` would: it is asked to shut down, and terminated by pid if it cannot +be asked or has not exited within 5 s; its shells end with it), then deletes the daemon's copies in +`bin\` and `bin\` itself once empty. Only folders that are copies are ever deleted - one named for a +version that holds the `.complete` list, or a staging folder named `..<32-hex guid>.tmp` - +so anything else found in `bin\` (which could be a junction into a shared folder) is left alone, as +is any junction or link. A copy a daemon still runs from is kept, and one whose delete fails right +after a forced stop is tried once more. Nothing else in the config root is touched. + This is why `--packId` in [`.github/workflows/release.yml`](../.github/workflows/release.yml) is `NtildeApp` and not `Ntilde`. Aligning it with the app name would make the install root and the config root the same folder, and uninstalling would silently destroy every setting, @@ -87,6 +98,7 @@ All paths are properties of |---|---| | `settings.json` | all app settings | | `command-palette-usage.json` | command-palette ranking data | +| `mux-close-choice` | `keep` or `close`: the first-close dialog's "Don't ask again" answer (`AppPaths.MuxCloseChoiceFilePath`, written by `MuxCloseChoiceStore`). Deleted when Settings saves, imports or restores a changed session persistence; excluded from backups (`BackupCatalog.ExcludedRelativePaths`); safe to delete (the next close with live shells asks again) | | `themes\` | user-installed themes | | `sessions\last_session.json` | restored tab/pane layout | | `workspaces\`, `workspace_templates\` | saved and templated workspaces | @@ -98,6 +110,17 @@ All paths are properties of | `backups\` | automatic configuration snapshots written by the backup subsystem | | `recordings\` | terminal recordings | | `logs\` | `debug.log`, `startup_error.txt`, `workspace_audit.log`, … | +| `bin\\` | Windows installs only: the local multiplexer daemon's own copy of `Ntilde.exe`, the DLLs beside it and `\OpenConsole.exe` (`MuxDaemonImage`, not an `AppPaths` member). A Velopack update kills every process whose image is under the install root, so the daemon runs from here instead. One folder per version (`-` after another build under the same version), each with a `.complete` list written last: its files' sizes and the executable's SHA-256; `...tmp\` folders are copies being staged. About 75 MB per version (0.11.0's AOT bundle). A launch deletes other versions' folders once no daemon runs from them; uninstall stops the daemon and deletes them all. Only copy-shaped folders are ever deleted (a version's name plus `.complete`, or `..<32-hex guid>.tmp`); anything else here, and any junction or link, is left alone. Not backed up; safe to delete when no daemon runs | + +## Environment variables + +Two variables change where Ntilde reads and writes. Both are read from the process environment +only, never from `settings.json` or any other file. + +| Variable | Effect | +|---|---| +| `NTILDE_APPDATA_ROOT` | Replaces the config root (see Constraints). Tests, portable setups and sandboxed verification runs use it to point the app, its multiplexer daemon and the MCP server at a scratch folder. Every process of such a run must see it, including the ones Velopack starts. On Windows, Velopack's install, update and uninstall hooks and the restart after an apply all inherit the environment of whatever started `Setup.exe`, `Update.exe` or the app; on Linux the restart after an apply does too (both measured with Velopack 1.2.0 in Phase 5 Task 24). **Not on macOS:** Velopack 1.2.0 restarts the app there with `/usr/bin/open -n` (`src/bins/src/shared/util_osx.rs`, `start_package`), and LaunchServices starts it without the caller's environment, so a restarted app runs against the real config root. A run on macOS must never let Velopack restart the app (`scripts/mux-update-survival.sh` applies with `UpdateMac apply --norestart` and starts the new build itself). | +| `NTILDE_UPDATE_SOURCE_DIR` | **A verification hook.** When it names a directory and the running install was packed under the verification id `NtildeSurvival` (`UpdateSourceOverride.VerificationAppId`, compared ordinally), the updater reads that local Velopack feed (`releases..json` and the packages `vpk pack` writes beside it) instead of this repository's GitHub releases (`UpdateSourceOverride`, `VelopackUpdateService`). Every other install ignores it - the released `NtildeApp` included, and a process that is not a Velopack install - so for users the feed's origin stays the compiled-in repository. It is an allow-list on purpose: a list of ids to refuse would fail open the next time the packId is renamed, as it was once (NovaTerminalApp to NtildeApp). For the verification install, a value that is blank or names no directory is ignored too, and updates come from GitHub. When the variable is set, the app logs which source it uses and why, at startup and again when the updater is built; unset, it logs nothing. `scripts/mux-update-survival.ps1` and `.sh` pack under that id and use it to apply a real update to a sandboxed install; a test pins that both scripts use the id. | ## Constraints diff --git a/docs/MODULE_OWNERSHIP.md b/docs/MODULE_OWNERSHIP.md index 119d01b8e..eac670ca8 100644 --- a/docs/MODULE_OWNERSHIP.md +++ b/docs/MODULE_OWNERSHIP.md @@ -203,12 +203,18 @@ invariant changes. - The multiplexer wire protocol: frame kinds, framing, binary payload codecs, source-generated JSON DTOs, error codes, version negotiation - Daemon discovery (`MuxDiscovery.cs`): the app-data root, the `mux/mux-endpoint.json` descriptor (`MuxEndpointDescriptor`), and the per-user, per-root default endpoint name - `Sha256.cs`: a managed SHA-256 (internal) for the endpoint name's root hash +- `PrivateDirectory.cs`: creates a missing directory owner-only (`0700`) off Windows, for the descriptor + directory and the App's askpass record folder; an existing directory is never chmodded (the daemon + judges it) **Invariants** - Frame = `u8 kind` + `u32` LE length + payload, payload ≤ `MaxFrameBytes`, header validated before any payload byte is read - Every JSON type goes through `MuxJsonContext` - Binary parsers length-check before slicing -- Version negotiation picks the highest common version or refuses (Min 1 / Max 2); Phase 4's additions (`HelloParams.ClientInstanceId`, `InteractiveClients` on `sessionChanged` and `sessionInfo`, an empty `SpawnParams.Command` meaning the daemon's login shell, and `SpawnParams.SessionId`: the id a remote spawn names for its session, refused when malformed (`protocol_error`) or in use (`session_exists`)) are optional members a Phase 3 peer skips +- Version negotiation picks the highest common version or refuses (Min 1 / Max 2); Phase 4's additions (`HelloParams.ClientInstanceId`, `InteractiveClients` on `sessionChanged` and `sessionInfo`, an empty `SpawnParams.Command` meaning the daemon's login shell, and `SpawnParams.SessionId`: the id a remote spawn names for its session, refused when malformed (`protocol_error`) or in use (`session_exists`)) are optional members a Phase 3 peer skips. Phase 5's are optional on any negotiated version: the + `readScreen` method (`ReadScreenParams` / `ReadScreenResult`, `MuxReadScreenLimits`: rows clamped to + 2000, a snapshot over 4 MiB refused with `snapshot_too_large`), `WelcomeResult.ServerVersion` and + `MuxEndpointDescriptor.AppVersion`; a daemon that predates `readScreen` answers `protocol_error` - A descriptor is live only when its pid is alive, **and** runs under the recorded process name, **and** carries the start token the daemon recorded (pid-reuse guard; on Linux the token is the `/proc//stat` start time, which a wall-clock step cannot move). A descriptor without a token keeps the name check. Writes are atomic (temp file + move); repair is create-new for a missing file and compare-then-replace for a dead one, so it never overwrites a descriptor another daemon wrote meanwhile - **No `System.Security.Cryptography`.** `ntilde-mux` runs this assembly and must need nothing but libc, and on Linux .NET loads OpenSSL for every crypto call. The managed `Sha256` is byte-identical to `SHA256.HashData`, so endpoint names still match between the GUI and the daemon on the same host (`LayeringTests.Nothing_ntilde_mux_runs_references_an_OpenSSL_backed_assembly`) @@ -224,10 +230,13 @@ invariant changes. **Owns** - Multiplexer core: headless authoritative sessions (one parse thread each), the server, the client (`MuxClientSession : ITerminalSession`), and an in-memory transport +- `MuxScreenRead.cs`: what `MuxClient.ReadScreenAsync` returns (Phase 5 §3): the decoded snapshot and the status taken with it; null from the call means the daemon does not support `readScreen` - Local transports (`Transport/`): `NamedPipeMuxListener`, `UnixSocketMuxListener`, `MuxListeners.Create` (the platform's listener) and `MuxEndpointConnector` (the client end) - The client end of the stdio proxy (`Transport/StdioMuxTransport.cs`, Phase 4 spec §8.1): skips whatever a remote shell prints until the `NTILDE-MUX-PROXY 1 ` preamble (at most 64 KiB, then the captured text is the error) and returns a duplex stream over an exec channel's stdout and stdin - The daemon host: `MuxDaemonHost` / `MuxDaemonOptions` (lock file, descriptor, reaper, idle exit, `shutdown`), and the `ClientInstanceId` eviction in `MuxServer`: a hello carrying an id closes every other connection with the same id before `welcome` (Phase 4 spec §3) - The daemon process (`Daemon/`, namespace `Ntilde.Mux.Daemon`, moved from the App in Phase 4): `MuxServeHost` (the `serve` process: listener, `MuxDaemonHost`, `mux.log` through the rotating `MuxLogFile`), `MuxDaemonLauncher` (connect to a live descriptor or spawn the host's serve arguments; `EnsureEndpointStreamAsync` for the proxy, `EnsureConnectedAsync` with the hello), `ProcessMuxDaemonSpawner` / `IMuxDaemonSpawner`, `MuxDaemonExit`, `MuxStartupProbe` (whether a descriptor's daemon is really live, for the App's startup auto-apply gate: only a genuine refusal or a missing socket counts as dead, and a Windows connect timeout falls back to enumerating `\\.\pipe\`), `MuxPaths` (root, descriptor, endpoint, log folder, injected by the executable), and `LocalShellSessionFactory`, the remote daemon's shells (an empty command is the default login shell with `-l`; `~` is `$HOME`; SSH is refused) +- `Daemon/MuxDaemonStop.cs` (Phase 5 Task 21): the steps of `kill-server [--force]` - ask for `shutdown`, wait, terminate a re-verified pid - shared by `MuxCli` and the App's uninstall hook +- `Daemon/AgentSocketLink.cs` (Phase 5 Task 8, Unix): the stable `agent.sock` link a remote daemon's shells get as `SSH_AUTH_SOCK`, which each `proxy --stdio` repoints at its own connection's agent once that socket is live and owned by this user (`SO_PEERCRED` / `LOCAL_PEERCRED`) - Every `mux` verb (`Cli/`, namespace `Ntilde.Mux.Cli`): `MuxCli.Execute` (serve, ls, kill, kill-server, attach, probe-console, proxy, `--version`, each offered only when the host's `MuxCliVerbs` include it), `MuxCliHost` (what an executable supplies: paths, usage prefix, serve arguments, shell factory, verbs, console binding), `MuxProxyCommand` (`ntilde-mux proxy --stdio`) and `MuxVersionInfo` / `MuxCliJsonContext` (`--version --json`, which the App's installer reads) - The `ntilde mux attach` text client (`TextClient/`, Phase 3 spec §6): `TextClientSession` (lifecycle, threads, exit codes), `TextClientModel` (its own buffer + parser), `TextClientRenderer` (dirty-row @@ -244,6 +253,8 @@ invariant changes. - Attach limits are rejection ceilings, never clamps (#473) - **Endpoints are current-user only and never TCP:** a `CurrentUserOnly` pipe on Windows (the client checks it too); a `0600` socket in a `0700` directory elsewhere, and the listener refuses a directory with any other mode or a live socket at the path. The protocol has no authentication by design - Per-session input writer: `SendInput` and parser replies share one byte-capped (16 MiB) queue per session, so a child that stops reading stdin cannot stall the shared connection +- **No `MuxClient` send blocks its caller** (Phase 5 spec R6): a frame that finds the send queue full waits in a per-client overflow that one pump drains in call order; more than `MuxClientOptions.MaxOverflowBytes` (8 MiB) behind a stalled link ends the connection (`send overflow`) +- **`readScreen` replies are charged to the snapshot account**, never the stream budget, so reads never make a connection look like a slow client; an unsupported `readScreen` is null at the client, never an exception - Exited, unattached sessions are reaped after `ReapGrace` (60 s); the daemon exits after `IdleExitAfter` (10 min) with no running session and no connection - **Attach modes are decided on the parse thread.** `IfUnattached` is checked inside the same attach control item that subscribes the sink, on the session's one parse thread — never on the @@ -274,7 +285,7 @@ invariant changes. `ntilde-mux` runs) **Test authority** -- `tests/Ntilde.Mux.Tests/` (`Daemon/`, `Cli/`, `Transport/StdioMuxTransportTests`, `Server/ClientInstanceIdTests`; + `MuxRealShellSmokeTests` and the real-daemon `MuxDaemonSmokeTests` in App.Tests) +- `tests/Ntilde.Mux.Tests/` (`Daemon/`, `Cli/`, `Transport/StdioMuxTransportTests`, `Server/ClientInstanceIdTests`; Phase 5: `Client/MuxClientNonBlockingSendTests`, `Client/MuxClientReadScreenTests`, `Server/MuxServerReadScreenTests`, `Daemon/AgentSocketLinkTests`, `Daemon/MuxDaemonStopTests`; + `MuxRealShellSmokeTests` and the real-daemon `MuxDaemonSmokeTests` in App.Tests) --- @@ -550,18 +561,28 @@ requires public test classes. - `MuxEndpointId.cs`: `PaneNode.MuxEndpoint` parsed and formatted: `local` or `ssh:` (null = `local`) - `MuxTerminalSessionFactory.cs` / `PersistentSessionFactory.cs` / `SessionPersistenceMode.cs`: local panes go through the mux when `SessionPersistence` is `KeepOnClose`; SSH panes whose profile has `PersistRemoteSessions` go to their `ssh:` host (`RoutesRemote`); other SSH panes and local failures fall back to the default factory. Restore attaches `IfUnattached` on a v2 daemon (server-decided) or falls back to the pane's own `AttachedClients == 0` check on v1; a reattach after a drop opens `Shared` - `MuxOrphans.cs` / `PaneDisposition.cs`: orphan adoption on launch, `local` endpoint only (skips sessions with `DetachedByUser`, Phase 3 spec §7.7); a user close kills the session, window teardown detaches - - `MuxSessionPicker.cs`: the "Attach to Session…" picker's rows (title, command, cwd, size, attached count, running/exited), sorted running-first; local sessions only + - `MuxSessionPicker.cs`: the "Attach to Session…" picker's rows (title, command, cwd, size, attached count, running/exited), grouped by host - this computer first, then each connected remote host (`[user@host]`) - and sorted running-first within each; a connect row (`Connect to user@host…`) per persistent profile with no connection, and a disabled error row worded from `MuxPickerHostError` (Phase 5 §5) - `MuxCommandMatch.cs`: whether a daemon session runs the program a restoring pane expects, compared by executable file name so a path difference alone does not start a spurious fresh shell (local restores only; a remote session's command is the remote login shell, which the GUI does not know) - `SharedCloseChoice.cs`: the shared-close prompt's three-way answer (Cancel / Close / Detach) - - `RemoteMuxStatusText.cs`: the connection editor's install status line + - `RemoteMuxStatusText.cs`: the connection editor's install status line, and the persistent-tab limits hint under its checkbox + - `MuxCloseChoiceStore.cs` (Phase 5 R1): the first-close question's remembered answer, `/mux-close-choice` (`keep` / `close`), forgotten when Settings changes `SessionPersistence` + - `MuxRestoreExpectations.cs` (R2): whether a restore should expect its local shells gone, from the session file's save time and `Shell/Native/SessionStartBoundary.cs` (the later of boot and, on Windows, logon start; null when unreadable) + - `MuxDaemonImage.cs` (R9): where the local daemon runs from - on a Windows Velopack install, its own copy at `\bin\\` (staged, verified by `.complete`, pruned) - and the resolver the spawner takes; `MuxUninstall.cs`: Velopack's uninstall hook stops that daemon and removes the copies + - `MuxPreviousBuildNotice.cs` (Phase 5 Task 23): the wording and once-per-launch bookkeeping of the "from the previous build" notice and its restart; `MainWindow.MuxRestart.cs` runs the restart + - `MuxWindowlessSessions.cs` (R4, R5): the window's `IWindowlessSessionSource` (`AgentHost/IWindowlessSessionSource.cs`) - daemon sessions no pane shows, listed, read, typed into and killed for the agent host over `CurrentClient` only, one 7 s deadline per operation + - `MuxLsAll.cs` (Phase 5 §5): `ntilde mux ls --all [--json]`, taken by `MuxCommand` before `MuxCli`: this computer's sessions plus every persistent profile's, through `Remote/RemoteMuxLister.cs` (non-interactive connectors, 15 s per host, all at once); `MuxCommandJsonContext` for the JSON - Remote persistence (`Shell/Mux/Remote/`, namespace `Ntilde.Shell.Mux.Remote`, Phase 4 spec §7-§9): - `RemoteMuxConnector.cs`: one connect attempt: the remote command, the exec transport off the UI thread, the stdio preamble, the hello with the host's `ClientInstanceId`, and the classified failure; `RemoteMuxCommand.cs` builds the remote command lines, `RemoteMuxFailureClassifier.cs` / `RemoteFailureKind.cs` the failure kinds (`NotInstalled`, `Unsupported`, `VersionMismatch`, `SshFailed`, `ProxyFailed`, `NeedsUser`) - `RemoteMuxHostFactory.cs`: builds a remote `MuxConnectionHost` from a profile (construction only) and the per-attempt OpenSSH or native exec transport - `RemoteMuxInteractionHandler.cs`: a remote host's native prompts: the in-memory secret record, host-key trust for automatic attempts, and the abort that replaces an empty answer - `MuxReconnectLoop.cs` / `IMuxTimerScheduler.cs`: the backoff loop and its one-shot timers (a fake scheduler in tests) - `RemoteHostProbe.cs`, `MuxDaemonRid.cs`, `IMuxDaemonAssetSource.cs` + `GitHubReleaseMuxAssetSource.cs` / `LocalFileMuxAssetSource.cs`, `RemoteMuxInstallCommands.cs`, `RemoteMuxInstaller.cs`, `RemoteOutputText.cs`: the install flow (probe, verified asset, upload script, version check), UI-free + - `MuxAssetPins.cs` (Phase 5 Task 10): the released `ntilde-mux-` SHA-256s the release build embeds, which `GitHubReleaseMuxAssetSource` requires a download to match (none in a dev or CI build, which falls back to the release's `.sha256`) + - `RemoteInstallDir.cs` (Phase 5 Task 6): the install directory, `$XDG_DATA_HOME/ntilde/bin` when set and absolute, else `~/.local/share/ntilde/bin`, as the sh snippet the remote commands share and as UI text - `Views/Ssh/RemoteMuxInstallDialog.cs` (namespace `Ntilde.Views.Ssh`): the code-built install dialog, opened from the connection editor's Reliability tab and from the *Persistent SSH unavailable* notice -- Windows launch support (`Shell/`): `LauncherRelease.cs` (sets the `ntilde.com` release event on the GUI path; discards it in the mux branch) and `UserPathRegistration.cs` (the install folder on the user `PATH`, from Velopack's install, update and uninstall hooks only); `AppVersionInfo.cs` (the app's version, for the About window, the editor and the release download) +- Windows launch support (`Shell/`): `LauncherRelease.cs` (sets the `ntilde.com` release event on the GUI path; discards it in the mux branch) and `UserPathRegistration.cs` (the install folder on the user `PATH`, from Velopack's install, update and uninstall hooks only); `AppVersionInfo.cs` (the app's version, for the About window, the editor and the release download); `VelopackHookLog.cs` (where a Velopack hook's lines go: Velopack's own log, since a hook exits before `AppLogger` starts) +- `Shell/SshAskPassVaultPolicy.cs` (Phase 5 Task 2): whether the OpenSSH askpass helper may fill the vault password on a user's attempt through a jump host (not with ssh before 8.4, a hop named like the target, or a proxy in the extra arguments) +- Updates and the multiplexer (`Update/`): `MuxUpdateCompatibility.cs` (R10: the protocol marker in the staged release notes, whether an apply keeps the daemon, and the startup auto-apply gate) and `UpdateSourceOverride.cs` (`NTILDE_UPDATE_SOURCE_DIR`, a local feed honoured only for the `NtildeSurvival` verification package id) - The UI thread's lock-wait policy: `App.Initialize` registers `Shell/Native/NonPumpingSynchronizationContext` as VT's `BlockingWriteWaitScope`, so a UI-thread wait for a buffer's write lock dispatches no messages. A pumping wait let a WM_PAINT deadlock against its own resize (`TerminalViewResizeReentrancyTests`) **Non-responsibilities** @@ -572,8 +593,13 @@ requires public test classes. **Invariants** - App is allowed to depend on all production assemblies; nothing depends on App except Cli and the App.Tests project (and Architecture.Tests, which references everything for inspection) - Renderer-side bugs ("the pixels look wrong") are diagnosed by chasing back through Rendering → VT, not by patching App -- With `SessionPersistence` off no daemon is ever spawned; the daemon mode never initialises Avalonia; a daemon spawn never inherits the caller's std handles -- **Remote commands are `sh -c` scripts with no single quote inside**, or a recorded absolute path made only of characters no shell treats specially (`RemoteMuxCommand.IsSafeAbsolutePath`). sshd hands them to the user's login shell, which may be bash, fish, tcsh or nushell; both shapes parse alike in all of them, and neither looks anything up on `PATH`. The install scripts (`RemoteMuxInstallCommands.UploadForTrial`, `CommitUpload`, `DiscardUpload`) follow the same rule; an installed `ntilde-mux` is replaced only by the commit, by rename, after a byte-count check, a trial run and the app's check of the version it reported +- With `SessionPersistence` explicitly off nothing of the multiplexer appears: no daemon is ever spawned, no first-close question, no mux commands, and updates ask and shut down any live daemon as before Phase 5. The daemon mode never initialises Avalonia; a daemon spawn never inherits the caller's std handles +- **A designer or test window never starts a daemon** (Phase 5 R3): `AppServices.BuildForDesigner`'s `MuxHostFactory` refuses, whatever the window's settings say +- **The first close never ends a shell another client shows**, and an OS shutdown never ends any (R1); a remembered answer lives in `mux-close-choice`, not `settings.json` +- **Only copy-shaped folders under `\bin\` are ever deleted** (a version-named folder holding `.complete`, or `...tmp`), and never a junction or link (`docs/CONFIG_STORAGE_CONTRACT.md`) +- **The agent path never connects or prompts:** windowless sessions are asked only over a host's `CurrentClient` (R4) +- **Text from a daemon or a remote host never reaches a toast or a pane line raw:** notices quote it (`RemoteOutputText.Quote`), and pane status lines and picker/`ls --all` reasons are worded from cause enums +- **Remote commands are `sh -c` scripts with no single quote inside**, or a recorded absolute path made only of characters no shell treats specially (`RemoteMuxCommand`: a recorded path that can sit in sh double quotes, `IsQuotableAbsolutePath`, runs as `sh -c 'exec "" …'`; any other falls back to the default install dir, `RemoteInstallDir`). sshd hands them to the user's login shell, which may be bash, fish, tcsh or nushell; both shapes parse alike in all of them, and neither looks anything up on `PATH`. The install scripts (`RemoteMuxInstallCommands.UploadForTrial`, `CommitUpload`, `DiscardUpload`) follow the same rule; an installed `ntilde-mux` is replaced only by the commit, by rename, after a byte-count check, a trial run and the app's check of the version it reported - **Automatic attempts never prompt.** The reconnect loop's attempts and the kill-delivery attempt are non-interactive: OpenSSH in batch mode with no askpass, native answering only from the host's in-memory secret record and trusting only host keys the known-hosts store already trusts. With nothing to answer, a native attempt aborts (closes the session) rather than submit an empty password, and the failure is `NeedsUser`, which stops the loop. A password is never replayed for a profile with jump hops. Only a user's request (a pane opening, Enter) may show a dialog, and it cancels any automatic attempt in flight - **No remote connect on the UI thread.** `MuxTerminalSessionFactory.CreatePersistent` for a remote request and a remote host's `GetClient` may wait the 120 s connect timeout behind prompts that themselves need the UI thread; the pane runs them off it, with a generation counter that discards a stale result - **A remote pane's kill is never lost silently.** Every close of a non-local pane goes through `MuxConnectionHost.KillWhenConnected`: sent now, or queued (kept across a give-up) and sent first on the next connect; dropped only when the daemon stopped (its sessions are gone) or the host is disposed, with a log line @@ -585,6 +611,7 @@ requires public test classes. - `tests/Ntilde.App.Tests/` (the largest suite) - Remote persistence: `tests/Ntilde.App.Tests/Shell/Mux/Remote/` (connector, classifier, interaction handler, host factory, reconnect loop, installer, probe, asset sources, all over `FakeRemoteHost` and a fake scheduler), `Controls/MuxRemotePaneTests.cs`, `Core/MainWindowMuxRemoteTests.cs`, `Core/RemoteMuxInstallDialogTests.cs`; the Docker E2E `Shell/Mux/Remote/RemoteMuxDockerE2eTests.cs` (`Category=DockerE2E`) - Windows launch support: `tests/Ntilde.App.Tests/Shell/LauncherReleaseTests.cs`, `Shell/UserPathRegistrationTests.cs` +- Phase 5 (`tests/Ntilde.App.Tests/`): `Core/MainWindowFirstCloseTests`, `Core/MainWindowQuitAndCloseAllTests`, `Core/SettingsWindowSessionPersistenceTests`, `Core/MainWindowMuxUpdateTests`, `Core/MainWindowAgentWindowlessTests`, `Core/MainWindowAgentActivityLineTests`; `Shell/Mux/` (`MuxCloseChoiceStoreTests`, `MuxRestoreExpectationsTests`, `MuxDaemonImageTests`, `MuxUninstallTests`, `MuxPreviousBuildNoticeTests`, `MuxWindowlessSessionsTests`, `MuxCommandLsAllTests`, `MuxLocalCloseStalledLinkTests`, `Remote/MuxAssetPinsTests`); `Shell/Native/SessionStartBoundaryTests`, `Shell/SshAskPassVaultPolicyTests`, `Update/MuxUpdateCompatibilityTests`; `AgentHost/AgentHostWindowlessProtocolTests`, `AgentHost/AgentHostWindowlessCaptureTests`. Update survival against a sandboxed Velopack install: `scripts/mux-update-survival.ps1` / `.sh` (manual) - xunit.v3 + `Avalonia.Headless.XUnit 12.0.4`; **do not downgrade** the Avalonia stack below 12.0.4 — earlier versions leak the headless dispatcher and hang `dotnet test` --- diff --git a/docs/SSH_ROADMAP.md b/docs/SSH_ROADMAP.md index fcb3eae3b..69aa5cd4a 100644 --- a/docs/SSH_ROADMAP.md +++ b/docs/SSH_ROADMAP.md @@ -132,7 +132,8 @@ An SSH tab whose profile opts in (`SshMuxOptions.PersistRemoteSessions`, the edi its shell inside `ntilde-mux`, Ntilde's multiplexer daemon, on the remote host. The GUI reaches the daemon through an SSH exec channel running `ntilde-mux proxy --stdio`, so the shell survives a network drop, a closed window and an app restart, and a reconnect reattaches with a snapshot. The -user-facing description is `docs/USER_MANUAL.md` §3.3; the design is `docs/ARCHITECTURE.md` §8.2. +user-facing description is `docs/USER_MANUAL.md` chapter 12 (12.6 for SSH tabs); the design is +`docs/ARCHITECTURE.md` §8.2. ### Both backends @@ -143,8 +144,10 @@ user-facing description is `docs/USER_MANUAL.md` §3.3; the design is `docs/ARCH `-M`, `-W`/`-O`/`-Q` with their argument, and the `-o` keywords `RequestTTY`, `SessionType`, `ForkAfterAuthentication`, `StdinNull`, `RemoteCommand` and `PermitLocalCommand`. A user-started connect uses Ntilde as `SSH_ASKPASS`, so prompts appear as Ntilde dialogs (the vault password is offered only to a - prompt that names the target's `user@host`). An automatic reconnect runs in batch mode with no - askpass at all, so a password-only OpenSSH profile reconnects on Enter. + prompt that names the target's `user@host`, and not through a jump host it cannot tell from the + target). An automatic reconnect never prompts: it runs in batch mode, or, when the profile's password + is saved in the vault and ssh is 8.4 or newer, with the askpass helper in its vault-only mode, which + answers the target's password once and refuses everything else. - **Native** (`NativeSshExecTransport`) uses rusty_ssh's exec mode: `nova_ssh_exec(args, command)` takes the same hop, auth and prompt path as a shell session (jump chains, identity files, agent, known hosts, keepalive), then opens a session channel and `exec`s the command with no PTY and no @@ -166,30 +169,35 @@ user-facing description is `docs/USER_MANUAL.md` §3.3; the design is `docs/ARCH The connection editor's **Install ntilde-mux on this host…** probes the host over an exec channel (`uname`, the C library, `$HOME`), takes the matching `ntilde-mux-` from the GitHub release (SHA-256-verified and cached) or from a file the user picks, and streams it over another exec into -`~/.local/share/ntilde/bin/ntilde-mux` (upload by `cat` to a temp file, size check, trial run, then -rename: this replaces a running binary safely, answers prompts, and sets the mode, none of which -`RunSftpTransfer` does). Supported hosts: Linux x64/arm64 with glibc 2.35 or newer, macOS arm64. +`~/.local/share/ntilde/bin/ntilde-mux`, or under `$XDG_DATA_HOME` when the host sets it (upload by +`cat` to a temp file, size check, trial run, then rename: this replaces a running binary safely, +answers prompts, and sets the mode, none of which `RunSftpTransfer` does). The release's hashes are +built into the app, so a download must match them. Supported hosts: Linux x64/arm64 with glibc 2.34 +or newer, macOS arm64. musl, BSD, Intel Macs and Windows hosts are refused with the reason, and the tab stays plain SSH. On the host, `ntilde-mux` keeps its descriptor, socket, lock and log under a root of its own, -`~/.local/share/ntilde/ntilde-mux` (`~/Library/Application Support/ntilde/ntilde-mux` on macOS), +`~/.local/share/ntilde/ntilde-mux` (under `$XDG_DATA_HOME` when set; +`~/Library/Application Support/ntilde/ntilde-mux` on macOS), never the Ntilde app's: on a host that also runs the app, the app's daemon and `ntilde-mux` stay apart, so neither serves (or adopts, or shuts down) the other's shells. ### Deferred -- SFTP sidebar, remote files and port forwards on a persistent remote tab: such a tab is not an - `ActiveSshSessionRegistry` native session, and the exec channel carries no forwards. The - follow-up runs forwards on the exec connection and registers the pane for the sidebar. -- Remote sessions in the "Attach to session…" picker, and adoption of remote orphans. +- Port forwards on a persistent remote tab: the exec channel carries no forwards (Phase 5 spec R8: + they would share the mux link's event queue, drop on every reconnect and collide with plain tabs + of the profile). Since Phase 5 the SFTP sidebar and transfers work there for native profiles: the + pane registers in `ActiveSshSessionRegistry` with its remote host's password scope. OpenSSH + persisted tabs get the palette's transfers (scp on its own connection), not the sidebar. +- Adoption of remote orphans: a remote shell no saved tab names is listed by "Attach to session…" + (Phase 5) but not reopened on its own. - Password memory keyed by (kind, host, user), so a jump-chain profile with passwords can reconnect on its own; this needs the native `PasswordPrompt` to carry the host and user. -- The native exec channel polls with a 10 ms idle sleep, which puts a floor of about 15 ms under - each request round trip (OpenSSH: about 2 ms); an event-driven wakeup from rusty_ssh would remove - it. -- Refreshing `SSH_AUTH_SOCK` in long-lived remote shells (tmux's `update-environment`). -- Embedding the release's `ntilde-mux` SHA-256s in the app at build time instead of trusting the - `.sha256` next to each asset. + +Done in Phase 5 (`docs/superpowers/specs/2026-10-08-ntilde-mux-phase5.md`): remote hosts in the +"Attach to session…" picker and in `ntilde mux ls --all`; a stable `SSH_AUTH_SOCK` link in daemon +shells that every reconnect repoints; the native exec channel waits for events instead of a 10 ms +sleep; the release's `ntilde-mux` SHA-256s built into the app. --- diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 14139c3a5..3ce67281c 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -108,437 +108,12 @@ Panes allow you to split a single tab into multiple terminal windows. - **Find/Search:** `Ctrl+Shift+F` opens the search overlay for the active pane. ### 3.3 Persistent sessions (multiplexer) -Local shells can keep running when Ntilde's window closes, and come back when Ntilde starts -again. They run inside a small background process, the *multiplexer daemon* -(`Ntilde mux serve`), which Ntilde starts on demand. SSH tabs can persist too, on the remote -host, through a daemon installed there: see [Persistent SSH tabs (remote)](#persistent-ssh-tabs-remote) -below. - -- **Turning it on:** Settings → Appearance → *Scrollback* → **Keep shells running when the - window closes** → *Keep running*. The default is *Off*, and with it off no daemon is ever - started. The setting applies to panes opened after you save it. Panes that are already open - stay as they are. -- **What persists:** local shells, with their screen and scrollback. They survive closing the - window, an Ntilde crash and a restart. On the next launch each saved pane reattaches to its - shell. A running shell that no saved pane refers to (for example one left over from a - crash) opens as a new background tab, and a toast reads "Reattached N detached sessions". -- **A second Ntilde window** (a second instance started while the first is open) never takes - over shells the first one is showing: its panes start new shells instead. -- **Workspaces, templates and bundles** save a layout, not live shells. Loading one starts new - shells in its panes, and exported bundles contain no session ids. Only Ntilde's own saved - session reattaches to running shells. -- **What closes a shell:** closing its pane or tab, or the shell exiting. Closing the *window* - only detaches: the shells keep running in the daemon. A shell that has exited and has no - window attached is cleaned up after 60 seconds. -- **If the daemon cannot be reached:** a *new* pane starts a normal shell instead and the window - shows a "Session not persistent" notification: - `[Multiplexer unavailable — this session will not persist]`. Ntilde tries the daemon again - for panes opened 30 seconds later. If the running daemon is from a different Ntilde version, - the notification adds a second line telling you to run `ntilde mux kill-server --force` to - replace it. - A pane that is *reattaching* to a saved shell (at startup, say, while the daemon is slow to - answer) does not start a stand-in shell, because its shell may still be running in the daemon. - It shows `[Multiplexer not reachable — press Enter to retry]` and keeps the shell's id, so - Enter tries again and your session file still names the shell. (For a version mismatch the - same kill-server hint appears under it.) - If a running daemon goes away, attached panes show - `[Multiplexer disconnected] [Press Enter to reconnect]`. Enter reconnects, starting a new - daemon if needed. When the old shell is gone the window shows a "Previous session lost" - notification: `[Previous session was lost — started a new shell]`. When several panes hit - the same thing at once (e.g. restoring after the daemon crashed) they share one notification. - If the daemon stops tracking a shell's screen (its terminal parser failed; `ntilde mux ls` - shows it as *faulted*), the pane shows - `[Multiplexer session failed — press Enter to start a new shell]`. That shell cannot be - shown again: Enter ends it and starts a new one. -- **One pane per shell:** if a saved session names the same shell in two panes (for example a - hand-edited session file), only the first reattaches; the others start new shells. -- **If the daemon's endpoint breaks:** the daemon keeps retrying it (logging to `logs/mux.log`) - and keeps serving every window that is already connected, so their shells are never ended - because of it. Only when it has not accepted a connection for 60 seconds *and* no window is - connected - so nobody can reach its shells - does it exit, ending those shells. Ntilde then - starts a fresh daemon the next time it needs one. -- **If the daemon's files are deleted while it runs** (for example the whole data folder): the - daemon rewrites its endpoint file `mux/mux-endpoint.json` within a second, so windows and - `ntilde mux` commands find it again. On macOS and Linux a deleted socket cannot be restored; the - daemon logs that it is unreachable until restarted. While it still holds its lock file but - cannot be found, a new window cannot start a daemon of its own (`ntilde mux serve` exits with - code 3: another multiplexer holds the lock), starts normal shells, and shows a *Multiplexer* - notification once: - `[Another multiplexer is running but cannot be reached. Shells in this window are not kept. Close other ntilde windows or end the old multiplexer.]` - (On macOS and Linux, deleting the whole `mux` folder also removes the lock file's name, so a new - window starts a fresh daemon instead; the old one keeps running unreachable, with its shells.) - To end an unreachable daemon, end the process whose pid `logs/mux.log` shows, or use - `ntilde mux kill-server --force` where it still applies (its endpoint file names a live daemon - this Ntilde cannot talk to). -- **Command line** (from the Ntilde executable, e.g. `ntilde` or `Ntilde.exe`; on Windows, - `ntilde` in PowerShell or cmd runs `ntilde.com`, see [On Windows: `ntilde.com`](#on-windows-ntildecom)): - - | Command | What it does | - |---|---| - | `ntilde mux ls` | Lists sessions: id, state (running / exited *code* / faulted), attached windows, size, title. | - | `ntilde mux ls --json` | The same list as JSON. | - | `ntilde mux kill ` | Ends one session. | - | `ntilde mux kill-server` | Ends every session and stops the daemon. Waits up to 5 seconds for it to exit; if it has not, prints "Multiplexer did not stop within 5 s." and exits with code 1. | - | `ntilde mux kill-server --force` | Also stops a daemon this Ntilde cannot talk to at all (no protocol version in common), once its pid and process name are re-verified — see below. A daemon from the previous version still talks to it, so plain `kill-server` stops that one. | - | `ntilde mux attach [--read-only]` | Shows a session in this terminal — see below. | - - These commands never start a daemon. With none running they print "No multiplexer is - running." and exit with code 1 (`attach` exits with code 2, its code for any connection - error). The daemon writes its log to `logs/mux.log` in Ntilde's data folder. -- **Limitations:** - - SSH panes persist only when their profile opts in, on the remote host (see - [Persistent SSH tabs (remote)](#persistent-ssh-tabs-remote)). Every other SSH pane works - exactly as before and does not persist. - - Inline images (sixel, kitty graphics) are not shown in persistent panes. - - Applying an update closes persistent sessions. Ntilde asks first ("N multiplexed sessions - will be closed by the update", buttons *Close sessions and update* / *Cancel*) and leaves - the update unapplied if you decline. While a daemon is running, a downloaded update is not - applied automatically when Ntilde starts; apply it from the update toast or the command - palette so Ntilde can ask first. - - If the daemon crashes, or is killed, its shells are gone. - - A shell inherits the daemon's environment, not the window's. The daemon's environment is - the one Ntilde had when it first started the daemon. - - Turning the setting off does not stop sessions that are already running. Panes open with - normal shells from then on, and the next time Ntilde saves your session it forgets which - panes the running sessions belonged to. Use `ntilde mux kill-server` to end them. - - The daemon exits by itself 10 minutes after its last session and its last connection - close. -- **Security:** the daemon listens only on a local endpoint that only your user account can - open (a per-user named pipe on Windows, a socket in a private `0700` folder on macOS and - Linux). It never opens a network port. - -#### Sharing a shell between windows - -A persistent shell can be attached in more than one pane at once, so every attached window shows -the same screen and any of them can send input. - -- **"Session: Attach to Session…"** in the command palette opens a picker. The entry appears only - while persistent sessions are on. It has no default shortcut; bind one under Settings → - Shortcuts (`attach_session`). -- The picker lists every session the daemon knows about: title, command, folder, size, how many - windows are attached, and running (or exited *code*). -- The chosen shell opens in a new tab — from this window, a second Ntilde window, or a second - instance. A "shared with N" badge appears over the pane and its tab carries a ⧉ marker. N counts - every *other* attached window, including a read-only `ntilde mux attach --read-only` viewer. -- If that session is already open as a tab in this window, Ntilde focuses the existing tab instead - of opening a second copy of it — one session cannot be shown in two tabs of the same window yet. -- An already-exited session can be attached too: it opens showing its last screen and the exit - banner, and nothing closes it for you. -- If the session is gone by the time the attach completes (another window, or `ntilde mux kill`, - beat you to it), no shell is started: the tab closes, and an "Attach to session" notification - reads `[The shell you chose has ended]`. -- **Restoring a shell at launch** is never a share: the pane only gets its shell back if no other - window has it open, and the daemon decides that. Otherwise Ntilde starts a fresh shell in that - pane and shows a "Previous shell in use" notification: `[Your previous shell is open in another - window — started a new shell]`. A crashed shell reopened automatically as a background tab that - another window claims first just closes its tab, with no new shell and no notification. Against - a daemon from before this version, Ntilde still makes that check on its own side first, the - Phase 2 behavior. - -#### Detach versus close - -- **"Pane: Detach"** (no default shortcut; bind `detach_pane` under Settings → Shortcuts) closes - only this pane and leaves the shell running in the daemon. Bring it back with "Attach to - session…". -- Closing a pane or tab normally ends its shell. -- When another window is attached to the same shell, closing asks **Close** (ends the shell for - every window), **Detach** (closes just this pane, keeps the shell running), or **Cancel**. -- An agent closing a pane through MCP cannot answer that prompt, so when another window is - attached the pane detaches instead, and the shell keeps running for the other window. -- A shell ended from another window shows `[Shell ended from another window]`. -- **Detached shells stay detached.** A shell you detach on purpose — "Pane: Detach", Detach from - the close prompt, or **Ctrl+\ then d** in `ntilde mux attach` — is *not* reopened the next time - Ntilde starts; reopen it yourself with - "Attach to session…". Once per launch, if any such shells exist, a toast reads "N detached - shells are running — Attach to session… to reopen them" (singular for one: "1 detached shell is - running — Attach to session… to reopen it"). `ntilde mux ls` marks them `running, detached`, and - `--json` adds `"detachedByUser": true`. Shells orphaned by a crash are unaffected — they are - still reopened automatically as background tabs. Against a daemon from before this version, a - detached shell is still reopened at the next launch, until you replace the daemon: stop it with - `ntilde mux kill-server` (it still talks to this version, so no `--force` is needed) and Ntilde - starts a current one when it next needs one. - -#### Size - -The latest resize wins, from whichever attached window sent it last. A window that is not in -control — its own size does not match the shared grid — letterboxes: its content is clipped or -padded to fit, rather than resizing the shared shell out from under whoever it just took the size -from. It takes the size back when you focus or activate it, for every attached window. A read-only -`ntilde mux attach --read-only` viewer never resizes the session. - -#### `ntilde mux attach [--read-only]` - -Shows a multiplexer session in this terminal, without going through the GUI. - -- `` is the session id from `ntilde mux ls`, or a unique prefix of at least 4 - characters. -- It renders from its own copy of the session's screen, so the shell's own escape-sequence - queries (cursor position, device attributes, terminal capabilities) never reach the terminal - you ran `mux attach` from. -- Detach with **Ctrl+\ then d**. Ctrl may stay held for the d (Ctrl+\ then Ctrl+D), as in GNU - screen; the one thing this costs is that a literal Ctrl+\ followed by Ctrl+D cannot be sent. - **Ctrl+\ Ctrl+\** sends a literal Ctrl+\, and a paste that contains Ctrl+\ then d (or Ctrl+D) - detaches the same way. -- When the session is bigger than your terminal, the view is clipped and a status line reads - "session is WxH, this terminal is WxH — resize to fit", with the detach hint kept alongside it. -- `--read-only` shows the session without sending input or resizing it, and the status line reads - "read-only". **This is a convenience, not a security boundary** — the endpoint has no - authentication beyond the same-user check, so anyone who can run programs as you can attach - normally anyway. -- It refuses (exit code `2`) to attach to the session you are typing in: every shell the daemon - starts has `NTILDE_MUX_SESSION` set to its own session id, and attaching a session to itself - would loop. Attaching to a *different* session from inside one works. -- Exit codes: `0` you detached, `1` the session exited or was killed, `2` a usage or connection - error. -- On Windows, run it straight from PowerShell or cmd: `ntilde mux attach `. `ntilde` there is - `ntilde.com`, which keeps the prompt waiting until you detach (see - [On Windows: `ntilde.com`](#on-windows-ntildecom)). -- On a remote host, `ntilde-mux attach ` does the same for the shells of a persistent SSH tab - (see [Persistent SSH tabs (remote)](#persistent-ssh-tabs-remote)). - -#### `ntilde mux kill-server --force` - -Stops a daemon this Ntilde has no protocol version in common with — a much older or a newer one — -once its pid and process name are re-verified against the endpoint descriptor, ending its shells. -Without `--force` against such a daemon, `kill-server` reports the mismatch and exits 1 instead of -guessing. A daemon from the previous version (v1) negotiates with this one, so a plain -`kill-server` stops it and `--force` changes nothing. - -#### Limits - -- A daemon from before this version (v1) keeps working for spawn, attach, detach and kill, but - shows no sharing indicator, and `--read-only` needs a daemon from this version or later. - Deliberately detached shells are re-adopted at the next launch against a v1 daemon (see above). - A plain `ntilde mux kill-server` replaces it: it stops the v1 daemon, and the next launch - starts a current one. - -#### Persistent SSH tabs (remote) - -An SSH tab can survive a network drop, a laptop going to sleep, or Ntilde closing. Its shell runs -on the remote host inside `ntilde-mux`, a multiplexer daemon installed in your home folder there, -and Ntilde talks to that daemon through the SSH connection itself (it runs -`ntilde-mux proxy --stdio` over SSH; neither side opens a port). When the connection drops, the -shell and the programs in it keep running on the host. When the connection comes back, the tab -reattaches and shows the current screen and scrollback. - -**Turning it on** takes two switches: - -1. persistent sessions: Settings → Appearance → *Scrollback* → **Keep shells running when the - window closes** → *Keep running*; -2. per SSH profile: **Keep remote sessions running (ntilde-mux)** in the connection editor's - *Reliability* tab. - -With either one off, the profile opens plain SSH tabs, exactly as before. Native and OpenSSH -profiles both work. The change applies to tabs opened after you save it. Edits to a persistent -profile's host, port, user or jump hosts apply to its persistent tabs once all of that profile's -tabs in the window are closed: until then they, and new tabs of the profile, keep connecting where -they first connected, since that is where their shells run. Other edits - a new key, extra SSH -arguments, the backend - apply from the next reconnect. - -**Installing `ntilde-mux` on a host.** Each host needs it once. **Install ntilde-mux on this -host…**, next to the checkbox, saves the profile and opens the install dialog (the -**Install ntilde-mux on …** button on a *Persistent SSH unavailable* notification opens -the same dialog for that host). The dialog connects with the profile's usual prompts, checks the host's system, and puts -the binary at `~/.local/share/ntilde/bin/ntilde-mux`. It needs no root and changes nothing else on -the host: not your `PATH`, not your shell's startup files. It has three ways to get the binary: - -- **Install** downloads `ntilde-mux-` for this Ntilde version from the project's GitHub - release, checks it against the release's `.sha256` file, and keeps the checked copy in Ntilde's - data folder (`cache/ntilde-mux//`), so the next host of the same platform - installs without downloading. -- **Choose file…** uploads an `ntilde-mux` you already have. Ntilde shows the file's SHA-256, and - refuses the file before uploading it when it is built for another platform than the host's. -- **Copy install command** copies a one-line command that you run in a shell on the host - yourself: it downloads the same release file with `curl` on the host, checks it with - `sha256sum -c` (`shasum -a 256 -c` on macOS), and installs it to the same place. Use it when your - machine cannot reach GitHub but the host can, or when you would rather install by hand. - -When this Ntilde version has no release (a development build), **Install** says so and the dialog -leaves only **Choose file…**. The upload replaces an installed `ntilde-mux` only once the new file -has arrived complete and has run once on the host, so a cancelled or broken upload leaves the old -one in place. A daemon that is already running keeps running the binary it started with. When the -install succeeds, the dialog offers to tick the profile's checkbox. The editor's status line shows -what was installed: `ntilde-mux 0.11.0 installed`, `ntilde-mux not installed`, or -`ntilde-mux 0.10.0 installed — this app is 0.11.0`. The versions do not have to match: Ntilde and -the daemon agree on a protocol version when they connect, and only a daemon with no protocol -version in common asks for an update. - -**Supported hosts:** Linux on x86-64 or arm64 with glibc 2.34 or newer (for example Ubuntu 22.04, -Debian 12 and later), and macOS on Apple silicon. The dialog refuses, with the reason, musl-based -systems such as Alpine, glibc older than 2.34, FreeBSD, OpenBSD and other systems, and Intel Macs. -Windows hosts are not supported. - -**What survives a disconnect:** the remote shell and every program running in it (an editor, a -build, `top`), and the screen and scrollback the daemon keeps for it. Closing Ntilde's window only -detaches, as for local shells: on the next launch each saved tab connects to its host again and -reattaches to its shell, under the same rule as local tabs (a shell open in another window is not -taken over). - -**What you see:** - -- While a tab connects: `[Connecting to …]`. Keys are ignored until it is connected, - because the connection may be waiting for you to answer a prompt (for up to two minutes). Tabs of - the same profile share one connection, so you answer its prompts once. -- When the connection drops: `[Connection to lost — reconnecting…]`. The tab keeps its - last screen. A connection that breaks is noticed at once; one that just goes silent is noticed - within about 25 seconds (a ping every 15 seconds, 10 seconds to answer). -- While it reconnects, **what you type is not sent** to the shell, and nothing is queued to arrive - later. The first key you press writes `[Input is not sent while reconnecting]`. Enter tries to - reconnect at once. -- Ntilde retries on its own, waiting about 1, 2, 4, 8 and 16 seconds and then about 30 seconds - between attempts, for up to 10 minutes after the drop. When the host answers, the tab reattaches - and the current screen replaces the banners. -- After 10 minutes it stops: `[Connection to lost] [Press Enter to reconnect]`. -- If the daemon itself ended (it was killed, or the host rebooted): - `[ntilde-mux on stopped] [Press Enter to reconnect]`. Its shells ended with it. Enter - starts a new daemon and a new shell, and a *Previous session lost* notification reads - `[Previous session was lost — started a new shell]`. -- If a new tab cannot reach the host over SSH, or a restored tab's host is down: - `[ not reachable — press Enter to retry]`. A restored tab keeps its shell's id, so - Enter reattaches once the host is back. -- If SSH works but `ntilde-mux` cannot be used (it is not installed, the host is not supported, it - has no protocol version in common with this Ntilde, or the proxy failed), a *new* tab opens as a - plain SSH tab, and a *Persistent SSH unavailable* notification reads - `[: — this tab will not survive a disconnect]`. When `ntilde-mux` is missing - or incompatible, the notification has an **Install ntilde-mux on …** or **Update - ntilde-mux on …** button. When several hosts' notices arrive together they share one - notification, with a line per host and the button of the last one, named for its host. - -**Reconnecting on its own never asks you anything.** The automatic retries use only what needs no -answer: your keys, your SSH agent, the profile's password if it is saved in the vault (*Remember -password* at a password prompt; the Connection Manager shows it), and, on native profiles, a -password or key passphrase that already signed this window in to the host (typed, or taken from the -vault). Ntilde keeps the last in memory only, and forgets it when the window closes or when the -server rejects it. Host keys must already be trusted. - -If the host's key is unknown or has changed, the retries stop after one attempt and send no -password. The tab shows `[Connection to lost] [Press Enter to reconnect]` with -`[Host key for is unknown or has changed — press Enter to review]` under it, and Enter -shows the usual host key question. OpenSSH refuses a *changed* key outright, so there the line is -`[Host key for has changed — if you trust the new key, remove the old one from known_hosts, then press Enter]`: -fix `known_hosts` first, then press Enter. - -With OpenSSH older than 8.4 (Windows 10's built-in `ssh` is 8.1, Ubuntu 20.04's is 8.2), the -retries use only keys and the agent, never the saved password: a password-only host waits for -Enter after every drop. Put a newer OpenSSH first on your PATH, or use the native SSH backend, to -have the saved password tried on its own. - -A saved password is tried **once**. If the server refuses it, the retries stop at once and the tab -shows `[Connection to lost] [Press Enter to reconnect]` with -`[The saved password was refused — press Enter to sign in]` under it. Ntilde does not use that -password on its own again, for that host in this window: Enter asks you for the password straight -away; tick *Remember password* to replace the saved one, and Ntilde uses the new one from then on. -Signing in with a typed password without ticking it leaves the refused one unused. A connection -that drops *after* you are signed in is not a refusal: the retries go on and try the saved -password again. (Each window keeps its own record, so a second window with tabs on the same host -tries it once too.) At any other time, when you connect or press Enter Ntilde fills in the saved password at most -once per connection: if the server refuses it, you are asked. - -If the host asks for more than the password - a one-time code, a second factor - the saved password -alone cannot sign in. The retries stop at once with -`[Automatic reconnect can't sign in without you — press Enter]`, and later ones do not try the saved -password again. Enter fills in the saved password for you and asks only for the code. -Ntilde fills only a prompt that ends in `password:` or reads `Password for :`. A password -prompt in other words is not filled; the retries stop with -`[Automatic reconnect can't sign in without you — press Enter]`, and Enter then asks you for it. - -When signing in would need a password or another typed answer, the retries stop at once rather than -fail again and again (failed logins that fail2ban and account lockouts count), and the tab shows -`[Connection to lost] [Press Enter to reconnect]` with -`[Automatic reconnect can't sign in without you — press Enter]` under it. Enter connects with the -usual prompts. This happens for: - -- profiles that sign in with a password that is not saved in the vault (and, on native profiles, - has not been used in this window yet), or whose OpenSSH is older than 8.4: OpenSSH retries run - `ssh` with `BatchMode=yes`; -- native profiles whose key has a passphrase that has not been used in this window yet, when - neither the SSH agent nor another key signs in instead; -- profiles that go through jump hosts and sign in with a password: a password prompt may come from - the jump host, so Ntilde never sends a saved or remembered password along a jump chain on its own. - -**Closing, detaching, and turning it off:** - -- Closing a tab or pane ends its remote shell. If the host cannot be reached at that moment, the - kill waits and is sent the next time Ntilde connects to that host; Ntilde also tries once in the - background, without prompting. -- Once no tab of the window uses a host any more (none shows a shell there, none waits to reattach - one), Ntilde closes its connection to that host, after any such kill has gone out: it stops - checking the link and reconnecting, and forgets a password it remembered for the host. If a kill - cannot be sent because reconnecting gave up, the connection waits, idle, and sends it the next - time you open a tab on that host. The daemon then exits on its own 10 minutes after its last - shell has ended. -- **Pane: Detach** leaves the remote shell running, but *Attach to session…* lists local shells - only. Reattach a detached remote shell on the host with `ntilde-mux attach `: the *Shell - detached* notification names the host and gives the command with the shell's id, the one - `ntilde-mux ls` lists. -- Unticking the profile's checkbox makes its new tabs, and its saved tabs at the next launch, open - plain SSH. Their shells keep running on the host, and Ntilde keeps their ids in its saved - session, so once the checkbox is ticked again they reattach at the following launch. Closing such - a tab ends its shell on the host when Ntilde can sign in there without asking you anything (your - keys or SSH agent): it connects once in the background, without prompting, to send the kill. For - the profiles listed above that need a password, that attempt fails and the shell keeps running: the - kill waits until Ntilde next connects to that host in this window (tick the checkbox again and open - a tab on it), and is dropped when the window closes. To end them all at once, run - `ntilde-mux kill-server` on the host. - -**Limitations:** - -- No SFTP sidebar (*Remote Files*), no SFTP transfers and no port forwards on a persistent remote - tab: *Remote Files*, its transfers and the palette's *SFTP: Upload…* and *SFTP: Download…* - commands show a notification, "Not available on a persistent remote tab", and the profile's - forwards are not set up. Use a plain SSH tab of the same host (from a profile with the checkbox off) for those. -- Windows hosts are not supported, nor are the hosts the installer refuses (see *Supported hosts*). -- Linux hosts where systemd-logind ends a user's processes at logout (`KillUserProcesses=yes` in - `/etc/systemd/logind.conf`) end the daemon and its shells when your last SSH session closes. Run - `loginctl enable-linger $USER` on the host once to keep them. -- Remote shells inherit the environment of the SSH connection that started the daemon. If you - forward your SSH agent (for example `-A` in the profile's extra SSH arguments), `SSH_AUTH_SOCK` in - those shells names that connection's agent socket, which goes away when that connection drops: - after a reconnect, `git` or `ssh` inside them cannot reach your agent. A new daemon - (`ntilde-mux kill-server`, which ends its shells) starts with a fresh environment. -- The daemon exits by itself 10 minutes after its last shell has ended and its last connection has - closed. - -**On the host.** `ntilde-mux` is not on the host's `PATH`: run it by its path, -`~/.local/share/ntilde/bin/ntilde-mux`, or add that folder to your `PATH`. - -| Command | What it does | -|---|---| -| `ntilde-mux ls [--json]` | Lists the daemon's sessions, as `ntilde mux ls` does. | -| `ntilde-mux attach [--read-only]` | Shows a session in the terminal you run it from, as `ntilde mux attach` does (detach with **Ctrl+\ then d**). It works while the Ntilde tab is attached too: both show the same shell, and the tab shows a "shared with 1" badge. | -| `ntilde-mux kill ` | Ends one session. | -| `ntilde-mux kill-server [--force]` | Ends every session and stops the daemon. | -| `ntilde-mux --version [--json]` | Prints the version. | - -`serve` and `proxy --stdio` are what Ntilde runs; you do not need them. The daemon keeps its files -in a folder of its own: `~/.local/share/ntilde/ntilde-mux` on Linux, -`~/Library/Application Support/ntilde/ntilde-mux` on macOS. Its log is `logs/mux.log` there. If you -also run the Ntilde app on that host, its own multiplexer uses `~/.local/share/ntilde` (or -`~/Library/Application Support/ntilde`) itself, so the two never share a daemon: `ntilde-mux ls` -lists only the shells of your persistent SSH tabs, and `ntilde mux ls` only the app's own. As on -your own machine, only your user can open the daemon's socket, and the daemon never opens a network -port. - -#### On Windows: `ntilde.com` - -`Ntilde.exe` is a windowed program, so PowerShell and cmd do not wait for it, and a command-line -mode run straight from the prompt would compete with the prompt for your keys. Ntilde therefore -ships a small console launcher, `ntilde.com`, next to `Ntilde.exe`. Windows tries `.com` before -`.exe`, so typing `ntilde` runs the launcher, which: - -- starts `Ntilde.exe` in the same console, with your arguments passed through unchanged; -- for a command-line mode (`ntilde mux …`, `ntilde backup …` and the others), waits for it to finish - and returns its exit code. So `ntilde mux attach ` works straight from PowerShell, and - `$LASTEXITCODE` holds its exit code; -- leaves Ctrl+C and Ctrl+Break to the program instead of ending itself: in `mux attach`, Ctrl+C - reaches the attached shell; -- for a plain `ntilde` that opens a window, returns at once with exit code 0, so the prompt is not - held while the window is open. - -The installer adds Ntilde's install folder to your user `PATH`, each update keeps it there, and -uninstalling removes it, so `ntilde` works in any terminal opened afterwards (a terminal that was -already open keeps its old `PATH`). The release's `.zip` bundle contains `ntilde.com` too, but -adds nothing to `PATH`. +Local shells can keep running in a background process, the multiplexer, when Ntilde's window +closes, and come back when Ntilde starts again; SSH tabs can keep running on their host. It is on by +default for local shells. Chapter 12, +[Persistent sessions and the multiplexer](#12-persistent-sessions-and-the-multiplexer), covers +closing and reopening, detaching and attaching, remote tabs, updates, agents, the `ntilde mux` +command line and how to turn it off. --- @@ -636,8 +211,8 @@ arguments that only the OpenSSH backend understands. See `docs/SSH_ROADMAP.md` f the full capability matrix. A profile can also keep its tabs' shells running on the host across network drops, -with both backends: see [Persistent SSH tabs (remote)](#persistent-ssh-tabs-remote) in -§3.3. +with both backends: see [Remote SSH tabs that keep running](#126-remote-ssh-tabs-that-keep-running) +in chapter 12. ### 6.2 Built-in SFTP Transfers Access the following commands via the palette to transfer files and folders between your local machine and the SSH host: @@ -738,21 +313,26 @@ at all, and the server still answers the offline repo/documentation tools. Settings → **Agent Access**: - **Agent access (observe)** — the master switch. Lets an agent list sessions, read - the screen and scrollback, query status, and wait for events. -- **Replay export** — additionally lets an agent save a session's recent output as a - replay file. Exports contain output and window resizes only, never anything you - type. Requires observe. -- **Agent screenshots** — additionally lets an agent render a pane to a PNG. A - picture shows everything drawn in the pane, inline images included. Requires - observe; every capture is journaled. + the screen and scrollback, query status, wait for events, and capture a pane as a + PNG (`capture_screen`). A picture shows everything drawn in the pane, inline images + included; the pane's agent indicator lights when it is captured, as it does for + any read. +- **Agent replay export** — additionally lets an agent save a session's recent + output as a replay file. Exports contain output and window resizes only, never + anything you type. Requires observe. - **Agent access (act)** — additionally lets an agent type into, open and close sessions. SSH connections must *also* be allowlisted individually. Requires observe; every action is journaled. +With persistent sessions on, agents also see the shells the multiplexer keeps that +no pane of the window shows: see +[Agents and windowless sessions](#128-agents-and-windowless-sessions). + ### 9.1 Seeing what an agent did -Panes carry a live indicator while an agent is observing or acting on them, and -every acting call — allowed or denied — plus every screenshot is recorded in the -**agent activity journal**. Open it from the title-bar menu → **Agent Activity...**, +Panes carry a live indicator while an agent is observing (reading or capturing) or +acting on them, and every acting call — allowed or denied — is recorded in the +**agent activity journal**, as is every read of a windowless session (12.8): reads of +a pane show on its indicator instead. Open it from the title-bar menu → **Agent Activity...**, or put the Agent Activity button on the title bar from Settings → Appearance. For an agent's own view of a session, `export_replay` plus @@ -813,3 +393,689 @@ How Ntilde updates depends on how you installed it: From the palette: `Update: Check for updates`, and once a version is staged, `Update: Restart to apply `. Automatic checks can be turned off in Settings. + +Shells the multiplexer keeps running survive an update when the new version can +use the running multiplexer: see [Updates](#127-updates) in chapter 12. + +--- + +## 12. Persistent sessions and the multiplexer + +Ntilde can keep your shells running when its window closes and give them back when it starts +again. The shells run in a background process, the *multiplexer*, instead of in the window, so +closing the window, an Ntilde crash or an update leaves them running. SSH tabs can keep running +too, on the remote host, through a multiplexer installed there (12.6). + +The setting is Settings → Appearance → *Scrollback* → **Keep shells running when the window +closes**: *Keep running* or *Off*. It applies to tabs and panes opened after you save it; the ones +already open stay as they are. This chapter describes *Keep running*; 12.11 says what *Off* +changes. + +*Keep running* is the default for local shells. A settings file that never set it - every install +that did not change it, a new one included - gets *Keep running*; one that says *Off* stays *Off*, +updates included. SSH tabs keep running on their host only for the connections you opt in (12.6). + +### 12.1 What runs + +- **The multiplexer** is a background process, `Ntilde mux serve`, which Ntilde starts the first + time a pane needs it. One runs per user (per data folder), and every Ntilde window, and + `ntilde mux attach`, connects to it. Installed with the Windows installer, it runs from a copy of + Ntilde in the data folder, `bin\\`, so that an update can replace the installed one + under it (12.7). +- **What it keeps:** each local shell, with its screen and scrollback. A shell keeps running when + its window closes, when Ntilde crashes and across an update. It ends when it exits, when you + close its pane or tab, or when the multiplexer ends, which a restart of the computer does (12.3). +- **Seeing it:** `ntilde mux ls` lists the shells (12.9), and **Session: Attach to Session…** in the + command palette lists them in Ntilde (12.4). The multiplexer writes its log to `logs/mux.log` in + Ntilde's data folder. +- **It exits by itself** 10 minutes after its last shell has ended and its last connection has + closed. A shell that has exited with no window attached is cleaned up after 60 seconds. +- **Security:** the multiplexer listens only on a local endpoint that only your user account can + open (a per-user named pipe on Windows, a socket in a private `0700` folder on macOS and Linux). It + never opens a network port. +- **Environment:** a shell inherits the multiplexer's environment, not the window's: the one Ntilde + had when it first started the multiplexer. +- **Limits:** inline images (sixel, kitty graphics) are not shown in persistent panes. If the + multiplexer crashes or is killed, its shells are gone. + +#### When the multiplexer cannot be reached + +- A *new* pane starts a normal shell instead, and the window shows a *Session not persistent* + notification: `[Multiplexer unavailable — this session will not persist]`. Ntilde tries the + multiplexer again for panes opened 30 seconds later. If the running multiplexer is from an Ntilde + version it has no protocol in common with, the notification adds + `[The running multiplexer is a different version — run 'ntilde mux kill-server --force' to replace it]` + and a **Restart multiplexer now** button that does it for you (12.7). +- A pane that is *reattaching* to a saved shell (at startup, say, while the multiplexer is slow to + answer) does not start a stand-in shell, because its shell may still be running. It shows + `[Multiplexer not reachable — press Enter to retry]` and keeps the shell's id, so Enter tries again + and your session file still names the shell. +- If a running multiplexer goes away, attached panes show + `[Multiplexer disconnected] [Press Enter to reconnect]`. Enter reconnects, starting a new + multiplexer if needed. When the old shell is gone, the pane starts a new one (12.3). +- If the multiplexer stops tracking a shell's screen (its terminal parser failed; `ntilde mux ls` + shows it as *faulted*), the pane shows + `[Multiplexer session failed — press Enter to start a new shell]`. That shell cannot be shown + again: Enter ends it and starts a new one. +- **If the multiplexer's endpoint breaks**, it keeps retrying it (logging to `logs/mux.log`) and + keeps serving every window that is already connected, so their shells are never ended because of + it. Only when it has not accepted a connection for 60 seconds *and* no window is connected - so + nobody can reach its shells - does it exit, ending those shells. Ntilde then starts a fresh + multiplexer the next time it needs one. +- **If its files are deleted while it runs** (for example the whole data folder), it rewrites its + endpoint file `mux/mux-endpoint.json` within a second, so windows and `ntilde mux` commands find it + again. On macOS and Linux a deleted socket cannot be restored; the multiplexer logs that it is + unreachable until restarted. While it still holds its lock file but cannot be found, a new window + cannot start a multiplexer of its own (`ntilde mux serve` exits with code 3: another multiplexer + holds the lock), starts normal shells, and shows a *Multiplexer* notification once: + `[Another multiplexer is running but cannot be reached. Shells in this window are not kept. Close other ntilde windows or end the old multiplexer.]` + (On macOS and Linux, deleting the whole `mux` folder also removes the lock file's name, so a new + window starts a fresh multiplexer instead; the old one keeps running unreachable, with its + shells.) To end an unreachable multiplexer, end the process whose pid `logs/mux.log` shows, or use + `ntilde mux kill-server --force` where it still applies (its endpoint file names a live + multiplexer this Ntilde cannot talk to). + +### 12.2 Closing the window + +Closing a pane or a tab ends its shell, as in any terminal (12.4 has the exceptions for shells +another window shares). Closing the *window* only detaches: its shells keep running in the +multiplexer, and the next launch reattaches them (12.3). + +**The first-close question.** The first time you close a window with shells running in it, Ntilde +asks before the window goes. The dialog, *Close Ntilde*, reads "Your shells keep running in the +background." and "Reopen Ntilde to get them back." (followed by the number of shells when there are +several): + +- **Keep running** closes the window and leaves the shells running. Enter answers this, wherever the + focus is. +- **Close them** ends this window's shells, then closes the window. A shell that another window, or + an `ntilde mux attach` without `--read-only`, also has open keeps running for it. +- Escape, or closing the dialog, leaves the window open. + +Tick **Don't ask again** and Ntilde remembers the answer and applies it from then on without +asking. The answer is kept in the file `mux-close-choice` in Ntilde's data folder, not in your +settings. Changing **Keep shells running when the window closes** in Settings, or importing or +restoring settings that change it, forgets it, so you are asked again. + +- Ntilde asks only when local shells would be left running. Remote shells never ask: they keep + running on their host either way. +- The shells of tabs you have not opened since Ntilde restored them count as this window's too, and + *Close them* ends them - except one you joined through **Attach to session…**, or one another + window or `ntilde mux attach` has open. Ntilde asks the multiplexer which those are first; if it + does not answer, they keep running. +- On macOS, quitting with Cmd+Q never asks: it applies a remembered answer, and otherwise keeps the + shells. A remembered *Close them* then ends only the shells of tabs you have opened. +- When the computer shuts down or you sign out, Ntilde ends no shell itself, whatever you chose + (the shutdown ends them anyway, 12.3). +- Shells that *Close them* ended are not looked for at the next launch: their panes start fresh + shells, with no "previous session lost" notice. + +**Quit and close all shells.** To end everything at once, use **Session: Quit and Close All +Shells** in the command palette, or the **Quit and close all shells…** link under the setting in +Settings, which closes Settings without saving. Both ask first when shells are running, in a +*Quit Ntilde* dialog ("Close +every shell?", "N shells running in the background will be closed, including detached and shared +ones.", with "Remote shells keep running." added when the window has remote persistent tabs, and a +**Quit and close** button). Then Ntilde ends every shell in the multiplexer - this window's, other +windows', and detached ones - stops the multiplexer and closes the window. Remote shells keep +running. The next launch starts fresh shells, with no "previous session lost" notice. The command +has no default shortcut; bind `quit_close_all_shells` under Settings → Shortcuts. + +### 12.3 Reopening Ntilde + +- **What comes back:** each pane of your saved session reattaches to its shell, with its screen and + scrollback, tabs in the background included. No notification is shown for that. +- **Shells no saved pane names** - left behind by a crash, say - open as new background tabs, and a + *Sessions restored* notification reads "Reattached N detached sessions". +- **Shells you detached on purpose** are not reopened (12.4). Once per launch, if any are running, a + *Detached shells* notification reads "N detached shells are running — Attach to session… to + reopen them" ("1 detached shell is running — Attach to session… to reopen it" for one). +- **After a restart of the computer** the shells are gone: they ended with the multiplexer. Ntilde + starts a fresh shell in each saved pane, where it was, with no warning. On Windows the same goes + after you sign out and back in, and after *Shut down* with Fast Startup on, which signs you out. + Ntilde tells by the time your session was saved: a session saved before the computer started (on + Windows, before the later of the start and your sign-in) names shells that cannot be running any + more. When the system does not say when it started, Ntilde does not assume a restart, and a lost + shell is announced as below. Remote SSH tabs reattach as usual: their shells ran on their host. +- **When a shell is gone for another reason** (the multiplexer crashed or was killed since the + computer started), its pane starts a fresh shell and writes + `[Previous session was lost — started a new shell]`, and a *Previous session lost* notification + says so. Panes that hit this together share one notification. A tab you opened from *Attach to + session…* is not given a new shell: it closes, and an *Attach to session* notification reads + `[The shell you chose has ended]`, after a restart of the computer too. +- **Reopening never takes a shell from another window.** A pane gets its shell back only if no other + window has it open; otherwise it starts a fresh shell, and a *Previous shell in use* notification + reads `[Your previous shell is open in another window — started a new shell]`. A crash leftover + that another window claims first just closes its tab, with no new shell and no notification. So a + second Ntilde (a second instance started while the first is open) never takes over the first one's + shells: its panes start new ones. +- **One pane per shell:** if a saved session names the same shell in two panes (for example a + hand-edited session file), only the first reattaches; the others start new shells. +- **Workspaces, templates and bundles** save a layout, not live shells. Loading one starts new + shells in its panes, and exported bundles contain no session ids. Only Ntilde's own saved session + reattaches to running shells. + +### 12.4 Detach, close, and Attach to session… + +**Closing.** Closing a pane or tab ends its shell, with these exceptions: + +- When another window is attached to the same shell, closing asks **Close** (ends the shell for + every window), **Detach** (closes just this pane, keeps the shell running), or **Cancel**. +- An agent closing a pane through MCP cannot answer that question, so when another window is + attached the pane detaches instead, and the shell keeps running for the other window. +- A tab you opened from *Attach to session…* whose connection is down or reconnecting, or whose + attach has not finished yet, cannot tell whether another window still uses the shell. Closing it + detaches, without asking, and a *Shell detached* notification says the shell kept running. +- A shell ended from another window shows `[Shell ended from another window]`. + +**Detaching.** **Pane: Detach** (no default shortcut; bind `detach_pane` under Settings → +Shortcuts) closes the pane and leaves its shell running. A *Shell detached* notification says how to +get it back: "Shell kept running — Attach to session… to get it back", or for a shell on a remote +host "Shell kept running on user@host — Attach to session… reopens it". When that host's profile no +longer keeps its remote sessions, the picker does not offer the host, and the notification gives +the command to run there instead: "Shell kept running on user@host — run 'ntilde-mux attach ' +on that host to get it back". + +**Detached shells stay detached.** A shell you detach on purpose - **Pane: Detach**, Detach in the +close question, or **Ctrl+\ then d** in `ntilde mux attach` - is *not* reopened the next time Ntilde +starts: reopen it with *Attach to session…*. `ntilde mux ls` marks such shells `running, detached`, +and `--json` adds `"detachedByUser": true`. Shells left behind by a crash are different: they are +reopened as background tabs (12.3). + +**Attach to session….** **Session: Attach to Session…** in the command palette (no default +shortcut; bind `attach_session` under Settings → Shortcuts) opens the *Attach to Session* dialog: +"Attach to a running session. It opens in a new tab and stays shared with its other windows." + +- Each line is a shell: its title (or its command when it has none), its command and folder, its + size, how many windows have it open (or `detached`), and `running` or `exited `. `open here` + marks one this window already shows. Running shells come first. Shells whose screen the + multiplexer lost (*faulted*) are not listed. +- This computer's shells come first, then those of every remote host the window is connected to, + each line starting with the host: `[user@host]`. +- For each SSH profile with **Keep remote sessions running (ntilde-mux)** ticked whose host the + window has no connection to now, the picker offers *Connect to user@host…*. Choosing it connects, + asking for a password if the host needs one, with a *Connecting to user@host…* notification + meanwhile, and then reopens the picker with that host's shells. If it cannot connect, a + notification reads "Could not connect to user@host." +- A host whose shells cannot be listed shows as `[user@host] not reachable: `, where the + reason is "the connection failed", "it did not answer in time", "its multiplexer is not usable" or + "the multiplexer is restarting", and it does not hold up the others. When there is nothing to + choose, a notification gives those lines, or "No sessions are running in the multiplexer." +- The chosen shell opens in a new tab, shared with the windows that already show it (12.5). If this + window already shows it, Ntilde switches to that tab instead: one shell is not shown in two tabs of + the same window. +- An exited shell can be chosen too: it opens on its last screen and its exit banner, and nothing + closes it for you. +- If the shell is gone by the time the attach completes (another window, or `ntilde mux kill`, beat + you to it), no shell is started: the tab closes, and an *Attach to session* notification reads + `[The shell you chose has ended]`. If a remote shell's profile stopped keeping its sessions while + the picker was open, the notification reads "The profile for user@host no longer keeps remote + sessions running." + +### 12.5 Several windows and `ntilde mux attach` + +A shell can be open in more than one window at once - another Ntilde window, a second instance, or +`ntilde mux attach` in any terminal. Every one of them shows the same screen, and any of them can +type. + +- A pane whose shell another window also shows has a "shared with N" badge, and its tab carries a ⧉ + marker. N counts every *other* attached window, a read-only `ntilde mux attach --read-only` viewer + included. +- **Size:** the latest resize wins, from whichever window sent it last. A window that is not in + control - its own size does not match the shared grid - letterboxes: its content is clipped or + padded to fit, instead of resizing the shared shell out from under whoever just took the size from + it. It takes the size back when you focus or activate it, for every attached window. A read-only + viewer never resizes the shell. + +#### `ntilde mux attach [--read-only]` + +Shows a shell of the multiplexer in this terminal, without going through Ntilde's window. + +- `` is the session id from `ntilde mux ls`, or a unique prefix of at least 4 + characters. +- It renders from its own copy of the shell's screen, so the shell's own escape-sequence queries + (cursor position, device attributes, terminal capabilities) never reach the terminal you ran + `mux attach` from. +- Detach with **Ctrl+\ then d**. Ctrl may stay held for the d (Ctrl+\ then Ctrl+D), as in GNU + screen; the one thing this costs is that a literal Ctrl+\ followed by Ctrl+D cannot be sent. + **Ctrl+\ Ctrl+\** sends a literal Ctrl+\, and a paste that contains Ctrl+\ then d (or Ctrl+D) + detaches the same way. +- When the shell is bigger than your terminal, the view is clipped and a status line reads + "session is WxH, this terminal is WxH — resize to fit", with the detach hint kept alongside it. +- `--read-only` shows the shell without sending input or resizing it, and the status line reads + "read-only". **This is a convenience, not a security boundary**: the endpoint has no + authentication beyond the same-user check, so anyone who can run programs as you can attach + normally anyway. +- It refuses (exit code `2`) to attach to the shell you are typing in: every shell the multiplexer + starts has `NTILDE_MUX_SESSION` set to its own session id, and attaching a shell to itself would + loop. Attaching to a *different* shell from inside one works. +- Exit codes: `0` you detached, `1` the shell exited or was killed, `2` a usage or connection + error. +- On Windows, run it straight from PowerShell or cmd: `ntilde mux attach `. `ntilde` there is + `ntilde.com`, which keeps the prompt waiting until you detach (12.10). +- On a remote host, `ntilde-mux attach ` does the same for the shells of a persistent SSH tab + (12.6). + +### 12.6 Remote SSH tabs that keep running + +An SSH tab can survive a network drop, a laptop going to sleep, or Ntilde closing. Its shell runs +on the remote host inside `ntilde-mux`, a multiplexer installed in your home folder there, and +Ntilde talks to it through the SSH connection itself (it runs `ntilde-mux proxy --stdio` over SSH; +neither side opens a port). When the connection drops, the shell and the programs in it keep running +on the host. When the connection comes back, the tab reattaches and shows the current screen and +scrollback. + +**Turning it on** takes two switches: + +1. Ntilde's own setting, **Keep shells running when the window closes**, on *Keep running* (the + start of this chapter); +2. per SSH profile: **Keep remote sessions running (ntilde-mux)** in the connection editor's + *Reliability* tab. + +With either one off, the profile opens plain SSH tabs, exactly as before. Native and OpenSSH +profiles both work. The change applies to tabs opened after you save it. Edits to a persistent +profile's host, port, user or jump hosts apply to its persistent tabs once all of that profile's +tabs in the window are closed: until then they, and new tabs of the profile, keep connecting where +they first connected, since that is where their shells run. Other edits - a new key, extra SSH +arguments, the backend - apply from the next reconnect. + +**Installing `ntilde-mux` on a host.** Each host needs it once. **Install ntilde-mux on this +host…**, under the checkbox, saves the profile and opens the install dialog (the +**Install ntilde-mux on …** button on a *Persistent SSH unavailable* notification opens +the same dialog for that host). The dialog connects with the profile's usual prompts, checks the +host's system, and puts the binary at `~/.local/share/ntilde/bin/ntilde-mux`, or at +`$XDG_DATA_HOME/ntilde/bin/ntilde-mux` when the host sets `XDG_DATA_HOME` to an absolute path. It +needs no root and changes nothing else on the host: not your `PATH`, not your shell's startup files. +It has three ways to get the binary: + +- **Install** downloads `ntilde-mux-` for this Ntilde version from the project's GitHub + release, checks it against the hash built into this Ntilde (a development build checks it against + the release's `.sha256` file instead), and keeps the checked copy in Ntilde's data folder + (`cache/ntilde-mux//`), so the next host of the same platform installs without + downloading. The macOS `ntilde-mux` is signed and notarized; a copy you drag in through Finder + from a browser download still gets Gatekeeper's prompt the first time, because of the quarantine + attribute the browser sets, not because of the binary. +- **Choose file…** uploads an `ntilde-mux` you already have. Ntilde shows the file's SHA-256, and + refuses the file before uploading it when it is built for another platform than the host's. +- **Copy install command** copies a one-line command that you run in a shell on the host yourself: + it downloads the same release file with `curl` on the host, checks it with `sha256sum -c` + (`shasum -a 256 -c` on macOS), installs it to the same place, and prints where it put it. Use it + when your machine cannot reach GitHub but the host can, or when you would rather install by hand. + If you set `XDG_DATA_HOME` only in your interactive shell's startup files, the command installs + under it while Ntilde's own connection, which does not read them, looks in `~/.local/share`: then + install from the dialog instead. + +When this Ntilde version has no release (a development build), **Install** says so and the dialog +leaves only **Choose file…**. The upload replaces an installed `ntilde-mux` only once the new file +has arrived complete and has run once on the host, so a cancelled or broken upload leaves the old +one in place. A multiplexer that is already running keeps running the binary it started with, and +so do its shells: installing never stops it. Updating Ntilde itself changes nothing on your hosts. +Once per launch, when Ntilde connects to an `ntilde-mux`, a *Multiplexer* notification may say one +of two things: + +- The running `ntilde-mux` is older than the version installed on the host (you installed a newer + one while it ran): "ntilde-mux on is from a previous version (0.12.0); restart it when + convenient — this closes its 2 shells." (The last clause is left out when it runs no shells.) Its + **Restart ntilde-mux on ** button asks first, then stops that multiplexer - and only it; + if the connection is down, or by then it is no longer behind the installed version, nothing is + sent and a notification says so. Its tabs show + `[ntilde-mux on stopped] [Press Enter to reconnect]`, and Enter starts the installed + version once the old one has stopped. Once a restart has stopped it, the notice is not offered + again for that host until the next launch or the next install. +- The running `ntilde-mux` is older than this Ntilde and not behind the installed one - the same + version, or Ntilde has no record of what is installed (an install by hand, say): "ntilde-mux on + is older (0.12.0) than this app (0.12.1); update it when convenient. Updating replaces + the binary; your shells keep running until you restart it." Its **Update ntilde-mux on + …** button opens this dialog. No restart is offered then, since it would start the same + old version again; once the update is installed, the restart notice replaces it, at once while + Ntilde is connected to the host. + +What counts as installed is what this connection recorded when Ntilde installed `ntilde-mux`. A +running version newer than that record (installed from another computer or another connection to +the same host) is never offered a restart. + +When the install succeeds, the dialog offers to tick the +profile's checkbox. The editor's status line shows what was installed: +`ntilde-mux 0.12.1 installed`, `ntilde-mux not installed`, or `ntilde-mux 0.12.0 installed — this app is 0.12.1`. The +versions do not have to match: Ntilde and `ntilde-mux` agree on a protocol version when they +connect, and only one with no protocol version in common asks for an update. + +**Supported hosts:** Linux on x86-64 or arm64 with glibc 2.34 or newer (for example Ubuntu 22.04, +Debian 12 and later), and macOS on Apple silicon. The dialog refuses, with the reason, musl-based +systems such as Alpine, glibc older than 2.34, FreeBSD, OpenBSD and other systems, and Intel Macs. +Windows hosts are not supported. + +**What survives a disconnect:** the remote shell and every program running in it (an editor, a +build, `top`), and the screen and scrollback `ntilde-mux` keeps for it. Closing Ntilde's window only +detaches, as for local shells: on the next launch each saved tab connects to its host again and +reattaches to its shell, under the same rule as local tabs (a shell open in another window is not +taken over). A restart of your own computer does not end them: they run on the host. + +**What you see:** + +- While a tab connects: `[Connecting to …]`. Keys are ignored until it is connected, + because the connection may be waiting for you to answer a prompt (for up to two minutes). Tabs of + the same profile share one connection, so you answer its prompts once. +- When the connection drops: `[Connection to lost — reconnecting…]`. The tab keeps its + last screen. A connection that breaks is noticed at once; one that just goes silent is noticed + within about 25 seconds (a ping every 15 seconds, 10 seconds to answer). +- While it reconnects, **what you type is not sent** to the shell, and nothing is queued to arrive + later. The first key you press writes `[Input is not sent while reconnecting]`. Enter tries to + reconnect at once. +- Ntilde retries on its own, waiting about 1, 2, 4, 8 and 16 seconds and then about 30 seconds + between attempts, for up to 10 minutes after the drop. When the host answers, the tab reattaches + and the current screen replaces the banners. +- After 10 minutes it stops: `[Connection to lost] [Press Enter to reconnect]`. +- If `ntilde-mux` itself ended (it was killed, or the host rebooted): + `[ntilde-mux on stopped] [Press Enter to reconnect]`. Its shells ended with it. Enter + starts a new `ntilde-mux` and a new shell, and a *Previous session lost* notification reads + `[Previous session was lost — started a new shell]`. +- If a new tab cannot reach the host over SSH, or a restored tab's host is down: + `[ not reachable — press Enter to retry]`. A restored tab keeps its shell's id, so + Enter reattaches once the host is back. +- If SSH works but `ntilde-mux` cannot be used (it is not installed, the host is not supported, it + has no protocol version in common with this Ntilde, or the proxy failed), a *new* tab opens as a + plain SSH tab, and a *Persistent SSH unavailable* notification reads + `[: — this tab will not survive a disconnect]`. When `ntilde-mux` is missing + or incompatible, the notification has an **Install ntilde-mux on …** or **Update + ntilde-mux on …** button. When several hosts' notices arrive together they share one + notification, with a line per host and the button of the last one, named for its host. + +**Reconnecting on its own never asks you anything.** The automatic retries use only what needs no +answer: your keys, your SSH agent, the profile's password if it is saved in the vault (*Remember +password* at a password prompt; the Connection Manager shows it), and, on native profiles, a +password or key passphrase that already signed this window in to the host (typed, or taken from the +vault). Ntilde keeps the last in memory only, and forgets it when the window closes or when the +server rejects it. Host keys must already be trusted. + +If the host's key is unknown or has changed, the retries stop after one attempt and send no +password. The tab shows `[Connection to lost] [Press Enter to reconnect]` with +`[Host key for is unknown or has changed — press Enter to review]` under it, and Enter +shows the usual host key question. OpenSSH refuses a *changed* key outright, so there the line is +`[Host key for has changed — if you trust the new key, remove the old one from known_hosts, then press Enter]`: +fix `known_hosts` first, then press Enter. + +With OpenSSH older than 8.4 (Windows 10's built-in `ssh` is 8.1, Ubuntu 20.04's is 8.2), the +retries use only keys and the agent, never the saved password: a password-only host waits for +Enter after every drop. Put a newer OpenSSH first on your PATH, or use the native SSH backend, to +have the saved password tried on its own. + +A saved password is tried **once**. If the server refuses it, the retries stop at once and the tab +shows `[Connection to lost] [Press Enter to reconnect]` with +`[The saved password was refused — press Enter to sign in]` under it. Ntilde does not use that +password on its own again, for that host in this window: Enter asks you for the password straight +away; tick *Remember password* to replace the saved one, and Ntilde uses the new one from then on. +Signing in with a typed password without ticking it leaves the refused one unused. A connection +that drops *after* you are signed in is not a refusal: the retries go on and try the saved +password again. (Each window keeps its own record, so a second window with tabs on the same host +tries it once too.) At any other time, when you connect or press Enter Ntilde fills in the saved +password at most once per connection: if the server refuses it, you are asked. + +If the host asks for more than the password - a one-time code, a second factor - the saved password +alone cannot sign in. The retries stop at once with +`[Automatic reconnect can't sign in without you — press Enter]`, and later ones do not try the saved +password again. Enter fills in the saved password for you and asks only for the code. +Ntilde fills only a prompt that ends in `password:` or reads `Password for :`. A password +prompt in other words is not filled; the retries stop with +`[Automatic reconnect can't sign in without you — press Enter]`, and Enter then asks you for it. + +When signing in would need a password or another typed answer, the retries stop at once rather than +fail again and again (failed logins that fail2ban and account lockouts count), and the tab shows +`[Connection to lost] [Press Enter to reconnect]` with +`[Automatic reconnect can't sign in without you — press Enter]` under it. Enter connects with the +usual prompts. This happens for: + +- profiles that sign in with a password that is not saved in the vault (and, on native profiles, + has not been used in this window yet), or whose OpenSSH is older than 8.4: OpenSSH retries run + `ssh` with `BatchMode=yes`; +- native profiles whose key has a passphrase that has not been used in this window yet, when + neither the SSH agent nor another key signs in instead; +- profiles that go through jump hosts and sign in with a password: a password prompt may come from + the jump host, so Ntilde never sends a saved or remembered password along a jump chain on its own. + When you connect yourself through a jump host with the OpenSSH backend, Ntilde fills in the saved + password only where the prompt cannot be the jump host's. With an OpenSSH older than 8.4, a jump + host given in the profile's extra SSH arguments, or a jump host with the target's user and host + name, you type it. + +**Closing, detaching, and turning it off:** + +- Closing a tab or pane ends its remote shell (with the exceptions in 12.4). If the host cannot be + reached at that moment, the kill waits and is sent the next time Ntilde connects to that host; + Ntilde also tries once in the background, without prompting. +- Once no tab of the window uses a host any more (none shows a shell there, none waits to reattach + one), Ntilde closes its connection to that host, after any such kill has gone out: it stops + checking the link and reconnecting, and forgets a password it remembered for the host. If a kill + cannot be sent because reconnecting gave up, the connection waits, idle, and sends it the next + time you open a tab on that host. `ntilde-mux` then exits on its own 10 minutes after its last + shell has ended. +- **Pane: Detach** leaves the remote shell running, and *Attach to session…* brings it back: the + picker lists the shells of every host the window is connected to, and offers *Connect to + user@host…* for the others (12.4). +- Unticking the profile's checkbox makes its new tabs, and its saved tabs at the next launch, open + plain SSH. Their shells keep running on the host, and Ntilde keeps their ids in its saved + session, so once the checkbox is ticked again they reattach at the following launch. Closing such + a tab ends its shell on the host when Ntilde can sign in there without asking you anything (your + keys or SSH agent): it connects once in the background, without prompting, to send the kill. For + the profiles listed above that need a password, that attempt fails and the shell keeps running: the + kill waits until Ntilde next connects to that host in this window (tick the checkbox again and open + a tab on it), and is dropped when the window closes. To end them all at once, run + `ntilde-mux kill-server` on the host. + +**What works on a persistent tab:** + +- *Remote Files* and the palette's *SFTP: Upload…* and *SFTP: Download…* work as on a plain SSH tab, + each on a connection of its own. That connection signs in without asking, as on a plain tab; with + the native backend it can also use a password you typed to sign this window in to that host (not + on a profile that goes through jump hosts). With the OpenSSH backend only the palette's transfers + work: there is no *Remote Files* sidebar, as on a plain OpenSSH tab. +- After you change the profile's host, port, user or jump hosts, a tab opened before still runs on + the old host, and *Remote Files*, its transfers and the palette's SFTP commands are refused there + with "Not available while this tab still runs on the host it was opened on — reopen the tab to use + the new host" (an open *Remote Files* sidebar closes). +- **No port forwards**: the profile's forwards are not set up on a persistent tab. Use a plain SSH + tab of the same host (from a profile with the checkbox off) for those. The connection editor says + so under the checkbox. + +**Limitations:** + +- Windows hosts are not supported, nor are the hosts the installer refuses (see *Supported hosts*). +- Linux hosts where systemd-logind ends a user's processes at logout (`KillUserProcesses=yes` in + `/etc/systemd/logind.conf`) end `ntilde-mux` and its shells when your last SSH session closes. Run + `loginctl enable-linger $USER` on the host once to keep them. +- Remote shells inherit the environment of the SSH connection that started `ntilde-mux`, with one + exception: if you forward your SSH agent (for example `-A` in the profile's extra SSH arguments), + the shells' `SSH_AUTH_SOCK` names a link in `ntilde-mux`'s own folder, which every reconnect + points at the new connection's agent, so `git` and `ssh` in them keep reaching your agent after a + drop. On a host where that folder's path is too long for a socket, the link is skipped and the log + says so. (An `ntilde-mux` from a development build before 0.12.0 has no link: its shells keep the + first connection's socket, which goes away when it drops. `ntilde-mux kill-server`, which ends + them, lets a current one start.) +- `ntilde-mux` exits by itself 10 minutes after its last shell has ended and its last connection + has closed. + +**On the host.** `ntilde-mux` is not on the host's `PATH`: run it by its path, +`~/.local/share/ntilde/bin/ntilde-mux` (under `$XDG_DATA_HOME` when the install put it there), or +add that folder to your `PATH`. + +| Command | What it does | +|---|---| +| `ntilde-mux ls [--json]` | Lists its sessions, as `ntilde mux ls` does. (`--all` needs the Ntilde app: `ntilde-mux ls --all` exits with code 2.) | +| `ntilde-mux attach [--read-only]` | Shows a session in the terminal you run it from, as `ntilde mux attach` does (detach with **Ctrl+\ then d**). It works while the Ntilde tab is attached too: both show the same shell, and the tab shows a "shared with 1" badge. | +| `ntilde-mux kill ` | Ends one session. | +| `ntilde-mux kill-server [--force]` | Ends every session and stops `ntilde-mux`. | +| `ntilde-mux --version [--json]` | Prints the version. | + +`serve` and `proxy --stdio` are what Ntilde runs; you do not need them. `ntilde-mux` keeps its files +in a folder of its own: `~/.local/share/ntilde/ntilde-mux` on Linux (under `$XDG_DATA_HOME` when it +is set), `~/Library/Application Support/ntilde/ntilde-mux` on macOS. Its log is `logs/mux.log` +there. If you also run the Ntilde app on that host, its own multiplexer uses +`~/.local/share/ntilde` (or `~/Library/Application Support/ntilde`) itself, so the two never share +anything: `ntilde-mux ls` lists only the shells of your persistent SSH tabs, and `ntilde mux ls` +only the app's own. As on your own machine, only your user can open `ntilde-mux`'s socket, and it +never opens a network port. + +### 12.7 Updates + +**Your shells survive an update** when the new version can talk to the running multiplexer: Ntilde +restarts and reattaches to them, as after any launch. The update itself says which multiplexer +protocol versions it speaks, so Ntilde knows before it applies it. + +Installed with the Windows installer, the multiplexer runs from a copy of Ntilde in the data folder, +`bin\\` (`Ntilde.exe`, the files beside it and the console hosts its shells use; about +75 MB), because the installer's update stops every program running from the install folder. A +launch removes other versions' copies once no multiplexer runs from them. Uninstalling Ntilde stops +the multiplexer, ending its shells, and removes the copies. + +**A multiplexer from the previous build.** A multiplexer an update kept is still the previous +version's. Once per launch a *Multiplexer* notification says so: "The multiplexer is from the +previous build (0.12.0); restart it when convenient — this closes its 3 shells." (One newer than +this Ntilde is "from a newer build", one whose version cannot be compared "from a different build", +and the last clause is left out when it runs no shells.) It keeps working meanwhile. Its **Restart +multiplexer now** button asks first, in a *Restart Multiplexer* dialog ("Restart the +multiplexer?", "N shells running in the multiplexer will be closed.", **Restart**). Then it stops +the multiplexer (ending its process if it has not exited within 5 seconds) and starts one of this +version. The panes keep their place: each shows +`[Multiplexer disconnected] [Press Enter to reconnect]`, and Enter starts a new shell; pressed before +the restart is over, it writes +`[The multiplexer is restarting — the new shell starts once it is back]` and starts the shell once it +is. If the multiplexer is already this version's by then, or does not answer, nothing is stopped and +a *Multiplexer* notification says so. `ntilde-mux` on your remote hosts has a notice of its own, +judged against the version installed on each host rather than this Ntilde's (12.6). + +**An update that cannot keep the multiplexer.** When the new version speaks no multiplexer protocol +in common with the running one, or, on Windows, the multiplexer runs from the install folder (which +the update replaces), Ntilde asks first. The *Apply Update* dialog reads "Multiplexed sessions are +still running." and "N multiplexed sessions will be closed by the update", then the reason: "(the +new version cannot keep them)", "(the multiplexer is running from the install folder, so the update +has to stop it)", or, when Ntilde cannot tell where it runs from, "(the multiplexer could not be +checked, so the update has to stop it)". The buttons are **Close sessions and update** and +**Cancel**; Cancel leaves the update unapplied. A multiplexer runs from the install folder when an +Ntilde without the copy above started it, or when Ntilde could not make its copy (a full disk, say). +While one does, a downloaded update is not applied automatically when Ntilde starts (`debug.log` +says why): apply it from the update notification or the command palette, so Ntilde can ask first. + +**A multiplexer this Ntilde cannot talk to.** If one is running when Ntilde starts (an update applied +while Ntilde started, say), new panes start normal shells with the *Session not persistent* +notification and its **Restart multiplexer now** button (12.1). + +**With the setting off**, an update never keeps a running multiplexer: Ntilde asks first ("N +multiplexed sessions will be closed by the update.") and stops it, and while any multiplexer is +running a downloaded update is not applied automatically when Ntilde starts. + +### 12.8 Agents and windowless sessions + +With **Agent access (observe)** on (chapter 9), an agent sees more than the panes of the window: +`ntilde.list_sessions` also lists the shells the multiplexer keeps that no pane of this window +shows. Those are detached shells, shells another window or `ntilde mux attach` shows, and shells on +the remote hosts the window is connected to. They are marked *windowless*, with their host in the +profile column, and their id is the multiplexer's session id, the one `ntilde mux ls` prints. + +- An agent can read a windowless shell's screen and scrollback (its newest 2000 rows), ask its + status (always the heuristic tier) and capture it as a picture (`mode=render` only). +- With **Agent access (act)** on, it can also type into one and close it, which ends the shell. On a + remote host both need that connection's **Allow AI agent access to this connection** too. +- Windowless shells send no events, so `ntilde.wait_for_events` never mentions them, and + `ntilde.export_replay` does not take them. +- Asking about them never connects anywhere and never asks you anything: only the multiplexers the + window is already connected to are asked, and one that does not answer within 7 seconds counts as + not found. A multiplexer too old to read screens answers `unsupported`. +- There is no pane to light up, so the window's agent indicator shows these reads - a listing that + includes windowless shells counts as one - and the + **Agent Activity** journal records every read of a windowless shell, as + `windowless · · session `; repeated reads fold into one line with a count (`×N`). Every + act is recorded too, as for panes. + +### 12.9 The command line + +Run these from the Ntilde executable (`ntilde`, or `Ntilde.exe`; on Windows, `ntilde` in PowerShell +or cmd runs `ntilde.com`, 12.10): + +| Command | What it does | +|---|---| +| `ntilde mux ls` | Lists the shells: id, state (`running`, `running, detached`, `exited ` or `faulted`), attached windows, size, title. | +| `ntilde mux ls --json` | The same list as JSON. | +| `ntilde mux ls --all [--json]` | The same list with a HOST column (`this computer`, or `user@host`), followed by the shells on the host of every SSH profile that keeps its remote sessions. It connects to those hosts on its own, all at once, and never asks for anything: it signs in with keys, the SSH agent, a saved password or an existing shared connection. A host it cannot list within 15 seconds gets an `unreachable:` line, and the reason goes to `logs/mux-ls-all.log`. See below for how it differs from the other commands. | +| `ntilde mux attach [--read-only]` | Shows a shell in this terminal (12.5). | +| `ntilde mux kill ` | Ends one shell. | +| `ntilde mux kill-server` | Ends every shell and stops the multiplexer. Waits up to 5 seconds for it to exit; if it has not, prints "Multiplexer did not stop within 5 s." and exits with code 1. | +| `ntilde mux kill-server --force` | Also stops a multiplexer this Ntilde cannot talk to at all (no protocol version in common), once its pid and process name are re-verified — see below. | + +These commands never start a multiplexer. With none running they print "No multiplexer is +running." and exit with code 1 (`attach` exits with code 2, its code for any connection error). +With no shells, `ls` prints "No sessions." + +`ls --all` differs in four ways: + +- It never starts a multiplexer on this computer, but on a host where none runs yet, connecting + starts one, as opening a persistent tab there does. With no shells to keep, that one exits after + 10 idle minutes. +- With no multiplexer running on this computer, it still lists the remote hosts. This computer's + line then reads `this computer unreachable: no multiplexer is running`, and the exit code is 1, + as for `ls`. Remote hosts it cannot reach do not change the exit code. A host with no shells gets a + `No sessions.` line, and with no profile keeping its remote sessions the list ends with + "No remote hosts keep sessions." +- On Linux and macOS it does not sign in through a jump host with the OpenSSH backend. A profile + that goes through a jump host or a proxy command gets an `unreachable:` line instead, because a + jump host's password prompt would appear in your terminal. *Attach to session…* in Ntilde still + lists such hosts, and profiles on the native SSH backend are listed as usual. +- With `--json` it prints one object, each host's shells as `ls --json` writes them, or its + `unreachable` reason in their place: + `{"endpoints":[{"endpoint":"local","host":"this computer","sessions":[…]},{"endpoint":"ssh:","host":"user@host","error":"…"}]}`. + +**`kill-server --force`** stops a multiplexer this Ntilde has no protocol version in common with - +a much older or a newer one - once its pid and process name are re-verified against its endpoint +file, ending its shells. Without `--force` against such a multiplexer, `kill-server` reports the +mismatch and exits 1 instead of guessing. A multiplexer that still talks to this Ntilde (one from +the previous build, say) stops with plain `kill-server`, and `--force` changes nothing. + +**A multiplexer from an early development build** (protocol v1) keeps working for starting, +attaching, detaching and ending shells, but shows no sharing indicator, `--read-only` needs a newer +one, and deliberately detached shells are reopened at the next launch anyway. A plain +`ntilde mux kill-server` replaces it: it stops the old one, and the next launch starts a current +one. + +### 12.10 `ntilde.com` on Windows + +`Ntilde.exe` is a windowed program, so PowerShell and cmd do not wait for it, and a command-line +mode run straight from the prompt would compete with the prompt for your keys. Ntilde therefore +ships a small console launcher, `ntilde.com`, next to `Ntilde.exe`. Windows tries `.com` before +`.exe`, so typing `ntilde` runs the launcher, which: + +- starts `Ntilde.exe` in the same console, with your arguments passed through unchanged; +- for a command-line mode (`ntilde mux …`, `ntilde backup …` and the others), waits for it to finish + and returns its exit code. So `ntilde mux attach ` works straight from PowerShell, and + `$LASTEXITCODE` holds its exit code; +- leaves Ctrl+C and Ctrl+Break to the program instead of ending itself: in `mux attach`, Ctrl+C + reaches the attached shell; +- for a plain `ntilde` that opens a window, returns at once with exit code 0, so the prompt is not + held while the window is open. + +If you capture its output (`ntilde | Out-Null`, `$x = ntilde`), the caller waits until the window +closes; start it plainly or with `Start-Process ntilde`. + +The installer adds Ntilde's install folder to your user `PATH`, each update keeps it there, and +uninstalling removes it, so `ntilde` works in any terminal opened afterwards (a terminal that was +already open keeps its old `PATH`). The release's `.zip` bundle contains `ntilde.com` too, but adds +nothing to `PATH`. + +### 12.11 Turning it off + +Set Settings → Appearance → *Scrollback* → **Keep shells running when the window closes** to +*Off*. It is on (*Keep running*) by default; once you save *Off*, it stays off, updates included. +From then on: + +- New tabs and panes run normal shells, which end when their window closes, and no multiplexer is + started for them. +- Closing a window asks nothing, and **Session: Attach to Session…**, **Pane: Detach** and + **Session: Quit and Close All Shells** leave the command palette. +- An update never keeps a running multiplexer (12.7). +- SSH tabs open plain, whatever their profile says. + +Turning it off does not stop shells that are already running, and panes already open keep theirs. +The next time Ntilde saves your session it forgets which panes the running shells belonged to. To end +them, use **Quit and close all shells** before you turn the setting off, or run +`ntilde mux kill-server` afterwards. Saving the change also forgets a remembered first-close answer +(12.2), so turning it back on asks again. + +To keep local shells running but stop one SSH profile from keeping its remote shells, untick +**Keep remote sessions running (ntilde-mux)** in its connection editor instead (12.6). diff --git a/docs/agent-host/DIRECTION.md b/docs/agent-host/DIRECTION.md index 92f020c32..7f185ca54 100644 --- a/docs/agent-host/DIRECTION.md +++ b/docs/agent-host/DIRECTION.md @@ -241,6 +241,26 @@ Each milestone is independently shippable and announceable. Estimates assume - [x] An unrecognized mode is a malformed request rather than being coerced to the default +### Windowless sessions (multiplexer) + +With session persistence on, the agent host also serves **windowless** sessions: sessions in the +multiplexer that no pane of this window shows, including ones another process attaches to. The +observe and act tools take their id (the multiplexer session id) like a pane id, and the pane +registry wins when both name the same id. + +- **Reach (R4).** Only endpoints the window is already connected to are asked, so the agent path + never connects or prompts. Every windowless operation has a 7 s budget inside the MCP round trip; + an unreachable daemon gives `sessionNotFound`, and a daemon too old for `readScreen` gives + `unsupported`. +- **Acting (R4).** `send_input` and `close_session` on a remote endpoint's session need that SSH + profile's agent allowlist. +- **Journal (R5).** Windowless reads are journaled and light the window-level agent light, because + there is no pane indicator to show them; repeated reads fold into one "×N" entry. Pane reads stay + unjournaled. +- **Limits.** Scrollback is limited to the newest 2000 rows. Windowless sessions emit no events, so + `wait_for_events` never mentions them. Status is heuristic. Render capture borrows an open pane's + font metrics, and live capture is refused (`captureUnavailable`). + ### Parallel track — Distribution - [ ] Homebrew cask/formula (macOS, Linux) diff --git a/docs/announcements/2026-10-09-persistent-sessions.md b/docs/announcements/2026-10-09-persistent-sessions.md new file mode 100644 index 000000000..af0630de5 --- /dev/null +++ b/docs/announcements/2026-10-09-persistent-sessions.md @@ -0,0 +1,103 @@ + + +# Ntilde 0.12.0: shells that outlive the window + +Close Ntilde's window and your shells can keep running. Open it again and every tab is back where it +was, with its screen and scrollback - the build still compiling, the editor still open, the SSH +session still signed in. Your SSH tabs can do the same on the remote host, and survive a dropped +Wi-Fi or a laptop lid on the way. + +## What it is + +Ntilde 0.12.0 can run your shells in a background process, Ntilde's own *multiplexer*, instead of +in the window. The window shows them; it no longer owns them. With *Keep shells running when the +window closes* on: + +- **Closing the window, a crash or an update** leaves the shells running, and reopening Ntilde + reattaches each tab to its shell. +- **The first time you close a window** with shells in it, Ntilde asks: *Keep running* or *Close + them*, with *Don't ask again*. **Quit and close all shells** (in the command palette, and under + the setting in Settings) ends everything at once. +- **After a restart of the computer** the shells are gone with it, and Ntilde quietly starts fresh + ones in the same tabs - no warning, because nothing went wrong. +- **Several windows can share a shell.** *Pane: Detach* puts a tab's shell aside, *Attach to + session…* brings any running shell back into a tab, and `ntilde mux attach ` shows one in any + terminal. +- **From the command line:** `ntilde mux ls` lists the shells, `--all` adds those on your SSH hosts, + and `ntilde mux kill` / `kill-server` end them. + +## On by default + +Local shells keep running by default from 0.12.0 on. If you never changed the setting, updating +turns it on; if you set it to *Off*, it stays off. SSH tabs keep running only for the connections +you opt in. + +## Turning it on or off + +One setting: Settings → Appearance → *Keep shells running when the window closes*, *Keep running* or +*Off*. With *Off*, Ntilde treats shells exactly as 0.11 did: they end with their window, no +background process is started, and nothing new appears. + +## SSH tabs that survive the network + +Tick *Keep remote sessions running (ntilde-mux)* on an SSH connection and install `ntilde-mux` on the +host from the same tab of the connection editor (no root, and nothing else on the host changes). The +tab's shell then runs on the host. When the connection drops, the tab keeps its screen and the shell +carries on as if nothing happened. The tab reconnects by itself when it can sign in without you - +with your keys, your agent or the saved password, never by asking you - and otherwise waits for you +to press Enter. *Attach to session…* lists your hosts' shells too, and connects to a host for you. + +Supported hosts: Linux on x64 or arm64 with glibc 2.34 or newer, and macOS on Apple silicon. Native +and OpenSSH profiles both work. + +## Updates + +Updating Ntilde no longer closes your shells when the new version can talk to the running +multiplexer: Ntilde restarts and reattaches. The multiplexer then is still the previous build's, +and a notification offers **Restart multiplexer now** when it suits you. An update that cannot keep +it says so and asks before it closes anything. + +## Agents + +With *Agent access* on, MCP agents also see the shells no window shows - a detached build, a shell +on a remote host - and can read them; with *Agent access (act)* they can type into them and close +them. Every such read is in the activity journal, since there is no pane to light up. + +## Known limits + +- A restart of the computer ends local shells (SSH tabs' shells live on their host). +- Inline images (sixel, kitty graphics) are not shown in persistent panes. +- A shell inherits the multiplexer's environment, not the window's. +- Persistent SSH tabs run no port forwards, and with the OpenSSH backend have no *Remote Files* + sidebar (the palette's transfers work). Windows hosts are not supported. +- A persistent SSH tab reconnects only when you press Enter if signing in needs a typed answer (a + password that is not saved, a key passphrase or a one-time code; native profiles reuse a password + or passphrase already typed in the same window), if it signs in with a password through a jump + host, or if it uses an OpenSSH older than 8.4 with a password-only host (Windows 10's built-in + `ssh` is 8.1). +- `ntilde mux ls --all` on Linux and macOS skips OpenSSH profiles that go through a jump host or a + proxy command; *Attach to session…* still lists those hosts. +- *Attach to session…* lists remote shells that no saved tab names, but they do not reopen on their + own. +- On Linux hosts that end a user's processes at logout (`KillUserProcesses=yes`), run + `loginctl enable-linger $USER` once. +- On macOS, Cmd+Q never asks the first-close question: it applies a remembered answer, and otherwise + keeps the shells. +- An update that changes the multiplexer protocol still has to close the shells (it asks first): + handing running shells over to a new multiplexer is not there yet. + +The user manual's chapter 12, *Persistent sessions and the multiplexer*, has the details. diff --git a/docs/mcp/tools.md b/docs/mcp/tools.md index f6209bd77..bc318634f 100644 --- a/docs/mcp/tools.md +++ b/docs/mcp/tools.md @@ -66,6 +66,30 @@ targets additionally require a per-profile allowlist. | `ntilde.spawn_session` | `profile?` | Opens a new tab running the default local profile, a named local profile, or an *allowlisted* SSH profile; returns the new `paneId`. | | `ntilde.close_session` | `paneId` | Closes a live pane (no confirmation dialog — the act opt-in plus the journal entry is the consent surface). | +### Windowless sessions + +With session persistence on, `list_sessions` also lists **windowless** sessions: sessions in the +multiplexer that no pane of this window shows (including ones another process attaches to). They +are marked `local (windowless)` / `ssh (windowless)`, and the profile column names the host. Every +tool above except `spawn_session`, `export_replay` and `wait_for_events` accepts their id. + +- **Ids.** The id is the multiplexer session id, passed as `paneId`. +- **Reach.** Only endpoints the window is already connected to are asked, so the agent path never + connects or prompts. Every windowless operation has a 7 s budget; an unreachable daemon gives + `sessionNotFound` ("did not answer in time"). +- **Scrollback.** `read_scrollback` sees the newest 2000 rows. +- **Status.** `get_session_status` is `heuristic`, from the daemon's child-process probe and the + alternate screen; it is never stalled. +- **Events.** Windowless sessions emit no events, so `wait_for_events` never mentions them. +- **Capture.** `capture_screen` supports `mode=render` only: it borrows an open pane's font + metrics. `mode=live` is refused (`captureUnavailable`). +- **Acting.** `send_input` and `close_session` on a windowless session on a remote endpoint need + that SSH profile's agent allowlist (`close_session` on a pane does not). +- **Journal.** Windowless reads are journaled (pane reads are not), and repeated reads fold into + one entry with a count ("×N"). +- **Old daemons.** A daemon too old to read screens gives `unsupported` for `read_screen`, + `read_scrollback` and `capture_screen`. + ## Notes - `get_architecture_map`, `list_docs`, `read_doc`, and `get_vt_conformance_summary` read files from diff --git a/docs/superpowers/plans/2026-10-08-ntilde-mux-phase5-manual-checklist.md b/docs/superpowers/plans/2026-10-08-ntilde-mux-phase5-manual-checklist.md new file mode 100644 index 000000000..74ad54f1f --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-ntilde-mux-phase5-manual-checklist.md @@ -0,0 +1,1008 @@ +# ntilde multiplexer Phase 5: manual checklist (Windows, Linux, macOS) + +One checklist for the whole multiplexer, run before Phase 5 merges. It merges: + +- PR #489's eight steps (Phase 2); +- Phase 3's extensions ([spec §8 item 7](../specs/2026-09-29-ntilde-mux-phase3.md), written out in the Phase 3 plan's Task 21); +- Phase 4's Windows `ntilde.com` steps ([plan Task 22 step 7](2026-10-05-ntilde-mux-phase4.md)) and the remote drop and + reconnect scenario ([spec §12.3](../specs/2026-10-05-ntilde-mux-phase4.md)); +- Phase 5's new steps: the first-close dialog, Quit and close all shells, reboot quiet restore, update survival + (Tasks 20-24), the picker with remote hosts, `ls --all`, SFTP on a persistent native tab, and the agent host's + `list_sessions` with a windowless session; +- two carries from Task 24: the no-overlap update path, and the macOS update-survival run. + +UI strings are quoted from `docs/USER_MANUAL.md` chapter 12, "Persistent sessions and the multiplexer". + +**Results.** Each step has a result line per OS, and each line is one of: + +- `PASS (run by implementer, , )`: run by the Task 30 implementer and observed; +- `FAIL (...)`: run, and the product did not do what the step expects; +- `OPEN — maintainer`: not run yet. These are the GUI steps (automation by SendKeys is unreliable on the maintainer's + Windows machine), every macOS step, Linux GUI steps that could not be driven headlessly, and anything that needs a + saved password, an installer or a reboot; +- `N/A (why)`. + +**Logs.** `` is `t30-smoke\logs\` in the Task 30 session's scratchpad +(`C:\Users\behna\AppData\Local\Temp\claude\D--projects-nova2\fac44242-4e5b-4e84-9d28-dd71bfdd700a\scratchpad\`). Task 31 +pastes them into the PR. The scripts that produced them are in `t30-smoke\scripts\`. + +**What the implementer ran (2026-10-09, tree `de724c3`).** + +- **Windows 11** (the maintainer's machine): a dev build (`scripts/build.ps1 build src/Ntilde.App`, Debug) copied into the + sandbox, with `ntilde.com` published from `src/Ntilde.Launcher` (`-c Release -r win-x64`) beside it. Every process ran + with `NTILDE_APPDATA_ROOT` set to the sandbox. The CLI cells passed 30 of 30 checks (`\10-win-cli-cells.log`). + `ls --all` against the Docker sshd passed 12 of 12, including a dropped link (`\40-win-ls-all-cells.log`), plus + a rotated host key (`\41-win-ls-all-changed-hostkey.log`). +- **Linux**: Ubuntu 24.04 in Docker (`ntilde-linux-build:local`), with `git archive` of `de724c3` built by + `scripts/build.sh`. The CLI and daemon cells passed 29 of 29 (`\30-linux-cli-cells.log`). `ls --all` and the + agent host passed 7 of 7 (`\50-linux-ls-all-agent-cells.log`), and the dropped link passed too + (`\53-linux-drop-cells.log`). Kill-the-GUI under Xvfb passed 4 of 4 (`\52-linux-gui-kill-cells.log`). +- **The remote host** for every remote cell: `novaterm-native-ssh-e2e:v5` (Debian 12, glibc 2.36), user `nova`, key + authentication only, with `ntilde-mux` 0.12.0 built by the release recipe (`scripts/docker-publish-mux-daemon.sh + linux-x64`: Ubuntu 22.04, highest symbol `GLIBC_2.34`, `\21-publish-ntilde-mux-ubuntu2204.log`). +- **Hidden test verbs**: sessions were started with `ntilde mux spawn-for-test` (setup only), in the local daemon and, + through the dev App's CLI copied into the sshd container, in the remote `ntilde-mux`. Every step that uses it says so. + +## Setting up a sandbox + +Run every step in a sandbox, never against your real Ntilde. + +**1. A data folder of its own.** Set `NTILDE_APPDATA_ROOT` in the shell you start Ntilde from. Everything started from +that shell inherits it: `Ntilde.exe`, `ntilde.com`, the multiplexer the GUI spawns, and Velopack's hooks. + +```powershell +# Windows (PowerShell) +$env:NTILDE_APPDATA_ROOT = "$env:TEMP\ntilde-smoke" +& \Ntilde.exe # or \ntilde.com mux ls +``` + +```sh +# Linux / macOS +export NTILDE_APPDATA_ROOT="${TMPDIR:-/tmp}/ntilde-smoke" +/Ntilde # macOS: /Contents/MacOS/Ntilde, started directly so the variable reaches it +``` + +The multiplexer's pipe or socket, its descriptor, its `bin\\` copies, settings, the session file, SSH profiles and +the native known-hosts file all move with it. **Four things do not**, so check them before you start: + +- **The agent host's pipe on Windows**, `ntilde-agent-`, is one per user. A sandbox Ntilde with *Agent access + (observe)* on joins your real Ntilde's pipe, and your MCP clients may reach either. Keep agent access off in the + sandbox, except for the agent steps (44-46): for those, quit your real Ntilde first. On Linux and macOS the agent socket + is under the data folder. +- **The quake-mode hotkey** is global. Turn *Quake mode* off in the sandbox settings. +- **The system credential store is shared with your real install**: the Windows Credential Manager, the macOS Keychain, + or the Linux Secret Service. `NTILDE_APPDATA_ROOT` does not isolate it. *Remember password* in a sandbox writes a + real entry. An automatic OpenSSH attempt (a reconnect, or `ntilde mux ls --all`) reads the vault for the profile's saved + password: first by the profile's id, then by older name-based keys that can match a real profile of yours. Use key + authentication in the sandbox, never tick *Remember password*, and test the saved-password paths only with a + throwaway account. +- **The Windows update-survival harness** (`scripts/mux-update-survival.ps1`, steps 36-38 and 50) installs a real + Velopack package (`NtildeSurvival`, never `NtildeApp`). That edits machine state outside the sandbox: your user `PATH` + in `HKCU\Environment`, the `HKCU\...\Uninstall\NtildeSurvival` key, and (if a pack ever makes them) Start Menu and + Desktop shortcuts. The script snapshots each one first and restores and checks it at the end, even when a step fails. + While it runs, do not open new terminals that read `PATH` or change your `PATH` yourself. If a run is killed before + its cleanup, run it again with `-CleanupOnly` and compare `PATH` with the snapshot it printed. + +**2. A sandbox `settings.json`** (in `$NTILDE_APPDATA_ROOT`): + +```json +{ + "SessionPersistence": "KeepOnClose", + "AgentAccessObserveEnabled": false, + "QuakeModeEnabled": false, + "AutomaticUpdateChecks": false +} +``` + +Delete `mux-close-choice` in the same folder to be asked the first-close question again. + +**3. The Docker sshd for the remote steps.** It uses its own network with an explicit subnet, because Docker's address +pools have run out on this machine before. It is published on 127.0.0.1 only, on a port other than 2222. + +```sh +docker network create --driver bridge --subnet 10.230.30.0/24 t30-net +docker run -d --name t30-ssh --hostname t30-ssh --network t30-net -p 127.0.0.1:2230:22 novaterm-native-ssh-e2e:v5 + +# A throwaway key, authorized for nova (the image's nova-pass password exists too: do not save it anywhere). +ssh-keygen -q -t ed25519 -N '' -C ntilde-smoke -f /sshkey/id_ed25519 +docker exec -i t30-ssh sh -c 'cat > /home/nova/.ssh/authorized_keys && chown nova:nova /home/nova/.ssh/authorized_keys && chmod 600 /home/nova/.ssh/authorized_keys' < /sshkey/id_ed25519.pub + +# Check: key only, known_hosts kept in the sandbox. +ssh -F /dev/null -i /sshkey/id_ed25519 -o IdentitiesOnly=yes -o UserKnownHostsFile=/sshkey/known_hosts \ + -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o PasswordAuthentication=no -p 2230 nova@127.0.0.1 'echo key-auth-ok' + +# ntilde-mux for the host: the image is Debian 12 (glibc 2.36), so build it with the release recipe (glibc 2.34 floor). +# A build from ntilde-linux-build:local (Ubuntu 24.04) needs GLIBC_2.39 and does not start there. +scripts/docker-publish-mux-daemon.sh linux-x64 +docker exec t30-ssh install -d -o nova -g nova /home/nova/.local/share/ntilde/bin +docker cp /linux-x64/ntilde-mux t30-ssh:/home/nova/.local/share/ntilde/bin/ntilde-mux +docker exec t30-ssh chown nova:nova /home/nova/.local/share/ntilde/bin/ntilde-mux +# (or install it from the GUI: the profile editor's "Install ntilde-mux on this host…", then "Choose file…") +``` + +In the sandbox Ntilde, make an SSH profile for `nova@127.0.0.1`, port 2230, with the key as its identity file and +**Keep remote sessions running (ntilde-mux)** ticked; make one native and one OpenSSH. The host key prompt appears the +first time: accept it. For the native backend the key goes into the sandbox's `ssh\native_known_hosts.json`. **OpenSSH +uses your own `~/.ssh/known_hosts`**: the config Ntilde generates (`\ssh\ssh_config.generated`) names no +`UserKnownHostsFile`, and OpenSSH's `UpdateHostKeys` may add the host's other keys there too (seen in the Linux run). Put +`-o UserKnownHostsFile=/sshkey/known_hosts` in the OpenSSH profile's extra SSH arguments to keep it in the +sandbox. + +Drop the link with `docker network disconnect t30-net t30-ssh`, and restore it with `docker network connect t30-net +t30-ssh`. If the published port does not come back, use `docker pause t30-ssh` / `docker unpause t30-ssh` instead. + +**4. Afterwards.** `ntilde mux kill-server` under the sandbox's `NTILDE_APPDATA_ROOT`, then `docker rm -f t30-ssh` and +`docker network rm t30-net`. + +--- + +## A. Local persistence + +### 1. Two shells keep running when the window closes +*PR #489 step 1.* +- **Preconditions:** a sandbox with *Keep running* (the default); `mux-close-choice` holds `keep`, or you answer + **Keep running** at the first-close question (step 11). +- **Actions:** open two tabs. Run `Start-Sleep 1000` (Windows) or `sleep 1000` in one and `vim` in the other. Close the + window. +- **Expected:** the window closes; nothing ends the shells. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 2. `ntilde mux ls` shows them, and one multiplexer owns them +*PR #489 step 2.* +- **Preconditions:** step 1. +- **Actions:** run `ntilde mux ls`. On Windows look at Task Manager → Details (and Process Explorer's tree); elsewhere + `pgrep -af 'mux serve'` and `ps -o pid,ppid,args --ppid `. +- **Expected:** 2 sessions, `running`, `0` attached. Exactly one `Ntilde mux serve` process, and both shells are its + children. +- **Result / log:** + - Windows: OPEN — maintainer (the GUI-made sessions). Step 9 shows the CLI half with `spawn-for-test` sessions: + "shell processes ...: pid 43276 parent 67264; pid 70132 parent 67264", where 67264 is `Ntilde.exe mux serve`. + - Linux: OPEN — maintainer (the GUI-made sessions). Step 9 shows the CLI half: "the daemon's children: 5091 5007 sleep + 100000; 5120 5007 sleep 100000". + - macOS: OPEN — maintainer + +### 3. A relaunch brings back the same shells +*PR #489 step 3.* +- **Preconditions:** step 1. +- **Actions:** start Ntilde again. +- **Expected:** both tabs come back with their screen and scrollback, and vim redraws. No notification is shown. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer. (The reattach half ran headlessly in step 4: the same session id, 1 attached, after a + relaunch. The screen comparison needs eyes.) + - macOS: OPEN — maintainer + +### 4. Killing the GUI leaves the shells running +*PR #489 step 4.* +- **Preconditions:** a sandbox window with a shell. +- **Actions:** end the GUI process: Task Manager → End task on Windows, `kill -9 ` elsewhere. Run `ntilde mux ls`. + Start Ntilde again. +- **Expected:** after the kill, the session is `running` with `0` attached. The relaunch reattaches it: the same id, `1` + attached, and no second session. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: PASS (run by implementer, 2026-10-09, `\52-linux-gui-kill-cells.log`). The GUI ran under Xvfb in + Docker and was not otherwise driven. "after kill -9 of the GUI the session is still running, 0 attached"; "a + relaunched GUI reattached the same session (1 attached), and started no second one"; SIGTERM likewise. (After the + GUI died, `kill-server` in that container printed "Multiplexer did not stop within 5 s.". The daemon had exited, but + as a zombie: the container's PID 1 is `sleep infinity`, which reaps nothing (`\51-linux-zombie-daemon-note.log`). + A desktop's init reaps it.) + - macOS: OPEN — maintainer + +### 5. Killing the multiplexer +*PR #489 step 5.* +- **Preconditions:** a sandbox window with a shell. +- **Actions:** end the `mux serve` process (Task Manager, or `kill -9`). Press Enter in the pane. +- **Expected:** the pane shows `[Multiplexer disconnected] [Press Enter to reconnect]`. Enter starts a new multiplexer + and a new shell, writes `[Previous session was lost — started a new shell]`, and a *Previous session lost* + notification says so. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 6. Closing a pane ends its shell +*PR #489 step 6.* +- **Preconditions:** a sandbox window with two panes. +- **Actions:** close one pane. Run `ntilde mux ls`. +- **Expected:** its shell ends, and `ntilde mux ls` no longer lists it. +- **Result / log:** + - Windows: OPEN — maintainer (the CLI equivalent, `ntilde mux kill `, passed in step 9) + - Linux: OPEN — maintainer (the same) + - macOS: OPEN — maintainer + +### 7. `ntilde mux kill-server` ends everything +*PR #489 step 7.* +- **Preconditions:** a sandbox window with shells. +- **Actions:** run `ntilde mux kill-server`. +- **Expected:** "Multiplexer stopped." with exit code 0. The `mux serve` process and every shell end, and the window's + panes show `[Multiplexer disconnected] [Press Enter to reconnect]`. +- **Result / log:** + - Windows: OPEN — maintainer (the panes). The CLI half passed in step 9: "kill-server: "Multiplexer stopped.", exit 0", + "the daemon (pid 67264) has exited", "its remaining shell has exited". + - Linux: OPEN — maintainer (the panes). The CLI half passed in step 9. + - macOS: OPEN — maintainer + +### 8. A second instance takes nobody's shells +*PR #489 step 8.* +- **Preconditions:** a sandbox window with shells, still open. +- **Actions:** start a second Ntilde (a second instance) with the same `NTILDE_APPDATA_ROOT`. +- **Expected:** it does not attach the first window's shells. Its restored panes start new shells, and a *Previous shell + in use* notification reads `[Your previous shell is open in another window — started a new shell]`. No *Previous + session lost* notification is shown. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 9. The command line, scripted: `ls`, `ls --json`, `kill`, `kill-server [--force]`, `attach` errors +*Phase 2/3 CLI. Setup uses the hidden `ntilde mux spawn-for-test` to start sessions.* +- **Preconditions:** a sandbox with no multiplexer running. +- **Actions:** + 1. With no multiplexer: `ntilde mux ls`, `ls --json`, `kill-server`, `attach abcd1234`. + 2. Start the multiplexer as the product does: `Ntilde mux serve`, detached from the caller's stdio. (Windows: + `Start-Process ... -WindowStyle Hidden`, which inherits no handles; Unix: `setsid ... /dev/null 2>&1 &`.) + Start two sessions with `spawn-for-test` (setup). + 3. `ls`, `ls --json`; `kill `, then `ls`; `kill `; `kill not-a-guid`. + 4. `attach zzzz`, `attach ab`, `attach --help`. + 5. `kill-server`, then `ls` and `kill-server` again. + 6. Start it again with one session, then `kill-server --force`; `kill-server --bogus`. +- **Expected:** + - The verbs never start a multiplexer. With none running they print "No multiplexer is running." and exit 1 (`attach` + exits 2). + - With an empty one, `ls` prints "No sessions." and exits 0. + - `ls` prints `ID STATE ATTACHED SIZE TITLE` with both sessions `running`, `0` attached. `--json` prints + `{"sessions":[...]}`. + - `kill ` prints "Killed ." (exit 0), and its shell process ends; an unknown id prints "No session ." + (exit 1); a malformed one prints the usage (exit 2). + - `attach` exits 2 for "No session starts with 'zzzz'." and for a prefix under 4 characters. `--help` names + **Ctrl+\ then d** and no `cmd /c`. + - `kill-server` prints "Multiplexer stopped." (exit 0), and the daemon and its shells are gone. Afterwards `ls` exits + 1. + - Against a multiplexer this build talks to, `kill-server --force` stops it as plain `kill-server` does. + - On Unix the endpoint is a `0600` socket in a `0700` folder; on Windows it is the root-keyed pipe + `ntilde-mux--`. +- **Result / log:** + - Windows: PASS (run by implementer, 2026-10-09, `\10-win-cli-cells.log`, 30 of 30). Run through `ntilde.com`, + plus `Ntilde.exe` with its output captured. "started: Ntilde.exe mux serve (pid 67264); descriptor: pid 67264, + endpoint ntilde-mux-behna-21db78c2"; "Killed 417d373a-b558-46a7-96af-f5cba87d473e."; "the killed session's shell + process (pid 43276) is gone"; "kill-server --force on a compatible daemon ...: "Multiplexer stopped.", exit 0". + (`--force` against a multiplexer of another protocol version was not run: no such build exists. The unit tests in + `MuxCliTests` cover it.) + - Linux: PASS (run by implementer, 2026-10-09, `\30-linux-cli-cells.log`, 29 of 29). "endpoint + /w/sbx/data/mux/mux.sock: 600 root socket; its folder /w/sbx/data/mux: 700 root"; "the daemon (pid 5007) has exited, + with its shells". + - macOS: OPEN — maintainer (run the same commands; the Linux script, `t30-smoke/scripts/linux-cli-cells.sh`, needs + only `APP=` changed) + +### 10. Version: `ntilde-mux --version [--json]`; `ntilde mux` has no version verb +- **Preconditions:** none. +- **Actions:** `ntilde-mux --version`, `ntilde-mux --version --json`, `ntilde-mux ls --all`; `ntilde mux version`. +- **Expected:** + - `ntilde-mux --version` prints one line, `ntilde-mux (protocol 1-2, )`. + - `--json` prints `{"version":...,"protocolMin":1,"protocolMax":2,"rid":...,"path":...}`. + - `ntilde-mux ls --all` exits 2 ("--all needs the ntilde app"). + - `ntilde mux version` is not a verb of the app: it prints the usage and exits 2. +- **Result / log:** + - Windows: PASS (run by implementer, 2026-10-09, `\10-win-cli-cells.log`) for `ntilde mux version` (usage, exit + 2). `ntilde-mux` does not ship for Windows (the release has linux-x64, linux-arm64 and osx-arm64), so its binary is + checked in the Linux cell. + - Linux: PASS (run by implementer, 2026-10-09, `\30-linux-cli-cells.log` and + `\23-remote-ntilde-mux-version.log`). "ntilde-mux 0.12.0 (protocol 1-2, linux-x64)". The release-recipe binary + on the Debian 12 host printed + `{"version":"0.12.0","protocolMin":1,"protocolMax":2,"rid":"linux-x64","path":"/home/nova/.local/share/ntilde/bin/ntilde-mux"}`, + and `ntilde-mux ls --all` there printed "--all needs the ntilde app (it connects to your SSH profiles)" (exit 2). + - macOS: OPEN — maintainer (`ntilde-mux` osx-arm64 from the release, and the signed, notarized check: + `codesign -dv`, `spctl -a -vv -t install`) + +## B. The first close and quit + +### 11. The first-close question +*Phase 5 Task 16.* +- **Preconditions:** a sandbox with no `mux-close-choice` file, and a window with two shells. +- **Actions:** close the window. Press Escape. Close it again and look at the dialog; press Enter. +- **Expected:** + - A *Close Ntilde* dialog reads "Your shells keep running in the background." and "Reopen Ntilde to get them back.", + with the number of shells, **Keep running**, **Close them** and **Don't ask again**. + - Escape, or closing the dialog, leaves the window open. + - Enter answers **Keep running**, wherever the focus is: the window closes, and `ntilde mux ls` shows both shells with + 0 attached. + - The question appears only when local shells would be left running. A window with only remote persistent tabs closes + without asking. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 12. **Close them**, and **Don't ask again** +*Phase 5 Task 16.* +- **Preconditions:** as step 11. +- **Actions:** + 1. Answer **Close them**. Run `ntilde mux ls`, and relaunch. + 2. Open shells, close the window, tick **Don't ask again** and answer **Keep running**. Close another window with + shells. + 3. Change **Keep shells running when the window closes** in Settings and save, then close a window with shells. +- **Expected:** + 1. This window's shells end, and the next launch starts fresh shells with no "previous session lost" notice. + 2. `mux-close-choice` appears in the data folder, and the next close does not ask. + 3. Saving the setting forgets the answer: the question comes back. Importing or restoring settings that change it + does the same. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 13. **Close them** keeps a shell another window shares +*Phase 5 Task 16, ruling I1.* +- **Preconditions:** a shell open in this window and in another (step 19), or in `ntilde mux attach` without + `--read-only`. +- **Actions:** close this window and answer **Close them**. +- **Expected:** this window's unshared shells end. The shared one keeps running for the other window. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 14. Cmd+Q on macOS, and shutdown or sign-out +*Phase 5 Task 16.* +- **Preconditions:** shells running; no `mux-close-choice`. +- **Actions:** on macOS, Cmd+Q. On any OS, sign out or shut down with Ntilde open. Save your work first: signing out + or shutting down also ends your real Ntilde, if it is running, and whatever else you have open. +- **Expected:** Cmd+Q never asks: it applies a remembered answer, and otherwise keeps the shells. A shutdown or + sign-out never makes Ntilde end a shell itself. +- **Result / log:** + - Windows: OPEN — maintainer (sign-out / shutdown part) + - Linux: OPEN — maintainer (logout / shutdown part) + - macOS: OPEN — maintainer + +### 15. Quit and close all shells +*Phase 5 Task 17.* +- **Preconditions:** this window with local shells, plus a detached shell (step 20) and, optionally, a remote persistent + tab. +- **Actions:** + 1. Command palette → **Session: Quit and Close All Shells**. Read the dialog, then choose **Quit and close**. + 2. Relaunch. + 3. Repeat from Settings: the **Quit and close all shells…** link under the setting. +- **Expected:** + 1. A *Quit Ntilde* dialog reads "Close every shell?" and "N shells running in the background will be closed, including + detached and shared ones.", with "Remote shells keep running." added when the window has remote persistent tabs, + and a **Quit and close** button. Afterwards every local shell has ended (this window's, other windows' and detached + ones), the multiplexer has stopped, and the window closes. Remote shells keep running (`ntilde mux ls --all`). + 2. The next launch starts fresh shells, with no "previous session lost" notice. + 3. The Settings link closes Settings without saving, then does the same. The command has no default shortcut; + `quit_close_all_shells` can be bound under Settings → Shortcuts. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +## C. Reboot quiet restore + +### 16. After a restart of the computer, fresh shells and no warning +*Phase 5 Task 19 (spec R2/R3).* +- **Preconditions:** a sandbox with shells in two tabs, one tab never shown since launch, and the window closed with + **Keep running**. +- **Actions:** save your work (the restart also ends your real Ntilde and its local shells), then restart the + computer. Start the sandbox Ntilde. +- **Expected:** each saved pane starts a fresh shell where it was. No notification is shown, the never-shown tab + included. On Windows, the same after signing out and in, and after *Shut down* with Fast Startup on. +- **Result / log:** + - Windows: OPEN — maintainer (reboot; and sign-out / Fast Startup shutdown) + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 17. Without a restart, a lost shell is announced +*Phase 5 Task 19 (the contrast case).* +- **Preconditions:** as step 16. +- **Actions:** instead of restarting, end the `mux serve` process. Start Ntilde. +- **Expected:** each pane starts a fresh shell and writes `[Previous session was lost — started a new shell]`, and one + *Previous session lost* notification covers them all. A tab opened from *Attach to session…* closes instead, with an + *Attach to session* notification reading `[The shell you chose has ended]`. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 18. Remote tabs reattach after a local restart +*Phase 5 Task 19.* +- **Preconditions:** a persistent SSH tab running `top -d 1` on a host that is **not** this computer: another machine, + or a VM that stays up. The Docker container does not do here, because it restarts with Docker, and that ends its + `ntilde-mux`. +- **Actions:** note `top`'s pid on the host. Save your work (the restart also ends your real Ntilde and its local + shells), then restart this computer, and start Ntilde. +- **Expected:** the remote tab reattaches to the same `top` (the same pid), with no notification. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +## D. Several windows and `attach` + +### 19. The same shell in two windows +*Phase 3 extension, step 9.* +- **Preconditions:** window A with a shell; window B, a second instance with the same `NTILDE_APPDATA_ROOT`. +- **Actions:** + 1. In B, command palette → **Session: Attach to Session…**, and choose A's shell. + 2. Type `echo from-A` in A, then type in B. + 3. Resize B's window, then click into A. +- **Expected:** + 1. The *Attach to Session* dialog reads "Attach to a running session. It opens in a new tab and stays shared with its + other windows.", and lists A's shell with `1` attached. + 2. B gets a new tab with the same screen. Both panes show the "shared with 1" badge, and both tabs carry ⧉ in + vertical-tab mode. Each window's typing appears in the other at once. + 3. After B's resize, A letterboxes. Clicking into A takes the size back. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 20. Detach in one window; the other keeps the shell +*Phase 3 extension, step 10.* +- **Preconditions:** step 19. +- **Actions:** in B, **Pane: Detach** (bind `detach_pane` first; it has no default shortcut). Run `ntilde mux ls`. +- **Expected:** a *Shell detached* notification reads "Shell kept running — Attach to session… to get it back". A's + badge disappears and A's shell still works. `ntilde mux ls` shows the session with 1 attached. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 21. A detached shell stays detached across a restart +*Phase 3 extension, step 10b.* +- **Preconditions:** a shell detached with **Pane: Detach** and open in no window. Close every window. +- **Actions:** run `ntilde mux ls`, relaunch, then **Attach to session…**. +- **Expected:** `ls` shows it `running, detached` (`--json`: `"detachedByUser": true`). The relaunch does not reopen it + as a tab. Once, a *Detached shells* notification reads "1 detached shell is running — Attach to session… to reopen it". + *Attach to session…* reopens it, and `ls` then shows `running`. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 22. `ntilde mux attach` from a terminal +*Phase 3 extension, step 12; Phase 4 removed the `cmd /c`.* +- **Preconditions:** a GUI pane with a shell. +- **Actions:** + 1. From a terminal, run `ntilde mux ls`, then `ntilde mux attach `. + 2. Type `dir` (or `ls`) and Enter. + 3. Press **Ctrl+\ then d**, and read the exit code. + 4. Repeat with `--read-only`. +- **Expected:** + 1. The screen is painted, with colours and the cursor, and the GUI pane shows "shared with 1". + 2. The command runs in both. + 3. You get `[detached from ]`, the prompt is back, typing echoes, and the exit code is 0. After detaching, Linux + and macOS `stty -a` shows `icanon echo`. + 4. With `--read-only` the status line reads "read-only", typing does nothing in the session, and detaching works. +- **Result / log:** + - Windows: OPEN — maintainer (PowerShell and cmd, straight through `ntilde.com`; see step 47) + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +## E. The picker with remote hosts + +### 23. The picker lists this computer, then every connected host, and offers the others +*Phase 5 Task 25.* +- **Preconditions:** the Docker sshd and two persistent profiles (setup 3). A local shell. One persistent tab open on + profile 1; profile 2 not connected. +- **Actions:** **Session: Attach to Session…**. +- **Expected:** + - This computer's shells come first, then lines starting `[nova@127.0.0.1]` for the connected host. + - For profile 2, a *Connect to nova@127.0.0.1…* row. Choosing it shows a *Connecting to nova@127.0.0.1…* notification, + then reopens the picker with that host's shells. + - With the container stopped (`docker stop t30-ssh`), choosing it gives "Could not connect to nova@127.0.0.1.". +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 24. A host that cannot be listed does not hold the others up +*Phase 5 Task 25.* +- **Preconditions:** step 23, with a tab on the host. +- **Actions:** `docker network disconnect t30-net t30-ssh`, then open the picker at once. +- **Expected:** the local shells are listed, and the host shows `[nova@127.0.0.1] not reachable: `, the reason + being one of "the connection failed", "it did not answer in time", "its multiplexer is not usable" or "the multiplexer + is restarting". With nothing to choose, the notification gives those lines, or "No sessions are running in the + multiplexer.". Reconnect the network afterwards. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 25. Choosing a remote shell +*Phase 5 Task 25.* +- **Preconditions:** a remote shell listed in the picker. Window B is a second instance. +- **Actions:** choose the remote shell in B. Then, in the window that already shows it, choose it again. +- **Expected:** B gets a new tab shared with the window that shows it ("shared with 1"). Choosing a shell this window + already shows switches to that tab. If the profile stops keeping its sessions while the picker is open, the + notification reads "The profile for nova@127.0.0.1 no longer keeps remote sessions running.". +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +## F. Remote drop and reconnect (Docker `network disconnect/connect`) + +### 26. The tab survives a dropped link +*Phase 4 spec §12.3, manually.* +- **Preconditions:** a persistent tab on t30-ssh (native, then OpenSSH) running `top -d 1`. Note the pid with + `docker exec t30-ssh pgrep -x top`. +- **Actions:** + 1. `docker network disconnect t30-net t30-ssh`. Wait; type a key. + 2. `docker network connect t30-net t30-ssh`. +- **Expected:** + 1. Within about 25 seconds: `[Connection to nova@127.0.0.1 lost — reconnecting…]`, with the last screen kept. The + first key writes `[Input is not sent while reconnecting]`, and nothing typed is sent later. + 2. The tab reattaches by itself: the current screen replaces the banners, and `top` has the same pid. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 27. The remote multiplexer and its sessions outlive a dropped link (scripted, through `ls --all`) +*Phase 5; setup uses `spawn-for-test` in the remote `ntilde-mux`.* +- **Preconditions:** the sandbox profile keeps its remote sessions (native, key). Two sessions run in the host's + `ntilde-mux` (setup). +- **Actions:** `ntilde mux ls --all`; `docker network disconnect t30-net t30-ssh`; `ls --all`; `docker network connect + t30-net t30-ssh`; `ls --all`. +- **Expected:** while the host is off the network, its line reads `unreachable: ...`, within the 15 s per-host wait, + and this computer is still listed (exit 0). Afterwards the same two remote session ids are listed, still `running`, + and the host's `ntilde-mux serve` kept its pid. +- **Result / log:** + - Windows: PASS (run by implementer, 2026-10-09, `\40-win-ls-all-cells.log`). "nova@127.0.0.1 unreachable: it + did not answer in time (exit 0, 15.5 s)"; "after the host is back: the same two remote session ids, still running"; + "the remote ntilde-mux kept its pid across the drop - before 375, after 375". + - Linux: PASS (run by implementer, 2026-10-09, `\53-linux-drop-cells.log`). From the t30-linux container over + the Docker network: "nova@t30-ssh unreachable: the connection failed" while the host was off the network (8 s; the + host name no longer resolves), then the same two ids `374cb101-…` and `3f0ccbcc-…`, and "remote ntilde-mux serve pid + after: 375". (Its closing `kill-server` met the container's zombie again; see step 4.) + - macOS: OPEN — maintainer + +### 28. The proxy killed; the remote multiplexer killed +*Phase 4 spec §12.3.* +- **Preconditions:** step 26's tab, reattached. +- **Actions:** + 1. `docker exec t30-ssh pkill -9 -f 'ntilde-mux proxy'`. + 2. `docker exec t30-ssh pkill -9 -f 'ntilde-mux serve'`, then Enter in the tab. +- **Expected:** + 1. The tab reattaches, and the daemon's pid is unchanged. + 2. `[ntilde-mux on nova@127.0.0.1 stopped] [Press Enter to reconnect]`. Enter starts a new `ntilde-mux` and a new + shell, and a *Previous session lost* notification reads `[Previous session was lost — started a new shell]`. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 29. Reconnecting gives up after 10 minutes, and on a host key it cannot trust +*Phase 4 §7.3; Phase 4 after-merge saved-password rules.* +- **Preconditions:** step 26's tab. +- **Actions:** + 1. Disconnect the network for more than 10 minutes, then reconnect it and press Enter. + 2. Give the host new host keys without restarting it, so `ntilde-mux` keeps running: `docker exec t30-ssh sh -c 'rm + -f /etc/ssh/ssh_host_* && ssh-keygen -A && kill -HUP 1'` (sshd is PID 1 and re-execs on SIGHUP). Recreating the + container does not change them: the image carries host keys from its build. Then drop and restore the link, and + press Enter in the tab. (The command was checked: a new key is served and `ntilde-mux` keeps its pid, + `\54-hostkey-rotation-check.log`.) An OpenSSH profile without the sandbox `UserKnownHostsFile` override + leaves the old key in your real `~/.ssh/known_hosts`; remove it afterwards with `ssh-keygen -R "[127.0.0.1]:2230"`. +- **Expected:** + 1. After 10 minutes, `[Connection to nova@127.0.0.1 lost] [Press Enter to reconnect]`; Enter reconnects. + 2. The retries stop after one attempt, with `[Host key for nova@127.0.0.1 is unknown or has changed — press Enter to + review]` (native), or the OpenSSH line telling you to remove the old key from `known_hosts`. Enter shows the usual + host-key question. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +## G. Shared tabs + +### 30. Closing a shared shell asks +*Phase 3 extension, step 11.* +- **Preconditions:** a local shell shared by windows A and B (step 19). +- **Actions:** in A, close the pane (Ctrl+Shift+W). Choose Cancel; close it again and choose **Close**. +- **Expected:** the question offers **Close** (ends the shell for every window), **Detach** and **Cancel**. Cancel + changes nothing. After **Close**, A's pane closes and B's pane shows `[Shell ended from another window]`. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 31. A shared remote tab stays shared and never kills while its sharing is unknown +*Phase 5 Task 26.* +- **Preconditions:** a remote shell open in window A and, through the picker, in window B. +- **Actions:** + 1. In B, close the tab. Choose **Detach**. + 2. Share it again. Disconnect the network, and close B's tab while it shows `[Connection to … lost — reconnecting…]`. +- **Expected:** + 1. The question appears as for a local share, and Detach leaves A's tab working. + 2. Closing detaches without asking, the shell keeps running, and a *Shell detached* notification reads "Shell kept + running on nova@127.0.0.1 — Attach to session… reopens it". +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 32. Two windows on one shared remote session keep their own registrations +*Phase 5 Task 28 fix (`a9cf7c4`).* +- **Preconditions:** a native persistent tab in window A, shared into window B. +- **Actions:** close B's share. In A, open *Remote Files*. +- **Expected:** A's sidebar opens and lists the remote home. The password A typed (if any) still works for it: no second + prompt. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +## H. SFTP on a persistent native tab + +### 33. Remote Files and SFTP transfers on a persistent tab +*Phase 5 Task 28.* +- **Preconditions:** a native persistent tab on t30-ssh (key authentication). An OpenSSH persistent tab of the same host. +- **Actions:** + 1. On the native tab, open *Remote Files*; browse, download a file, upload one. + 2. Use the palette's *SFTP: Upload…* and *SFTP: Download…*. + 3. Do the same on the OpenSSH tab. +- **Expected:** + 1. The sidebar works as on a plain SSH tab, on a connection of its own, without asking for anything again. + 2. The palette transfers work. + 3. On the OpenSSH tab only the palette's transfers work, and there is no *Remote Files* sidebar. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 34. A tab still on the old host refuses the new host's files +*Phase 5 Task 28.* +- **Preconditions:** step 33's native tab, with *Remote Files* open. +- **Actions:** edit the profile's port or user (for example, a second container on port 2231) and save. +- **Expected:** the open sidebar closes. *Remote Files*, its transfers and the palette's SFTP commands are refused on that + tab with "Not available while this tab still runs on the host it was opened on — reopen the tab to use the new host". + A new tab uses the new host once every tab of the profile in the window is closed. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 35. No port forwards on a persistent tab +*Phase 4 §8.4, Phase 5 R8.* +- **Preconditions:** a persistent profile with a local forward defined. +- **Actions:** open a persistent tab and try the forward. +- **Expected:** the forward is not set up, and the connection editor says so under the checkbox. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +## I. Updates + +### 36. Shells survive an update (the update-survival harness) +*Phase 5 Tasks 20-24 (R9, R10).* +- **Preconditions:** the repository; nothing else. The harness makes its own sandbox and installs as `NtildeSurvival`, + never as `NtildeApp`. On Windows it edits your real user `PATH`, the `Uninstall\NtildeSurvival` key and any + shortcuts, and restores them at the end (the setup block's fourth "do not move" item): open no new terminals while it + runs. +- **Actions:** + - Windows: `scripts/mux-update-survival.ps1 -Sandbox `. + - Linux: `zsh scripts/mux-update-survival.sh --sandbox `, with zsh, FUSE and Xvfb (Task 24 ran it in + `ntilde-linux-build:local` with `--device /dev/fuse --cap-add SYS_ADMIN`). + - macOS: `zsh scripts/mux-update-survival.sh --sandbox `, with the .NET SDK, the Xcode command-line tools and git + (**the Task 24 carry**). +- **Expected:** every check passes: + - the daemon keeps its pid; + - both heartbeat sessions keep beating across the apply; + - `ntilde mux ls` lists the same ids; + - the new GUI logs "the multiplexer on this computer is from another build (… this is …); offering a restart". + - On Windows also: the uninstall hook stops the daemon and removes `data\bin\`, and the user PATH comes back + byte-identical. + - On macOS the startup auto-apply is not exercised: the script applies with `--norestart` and starts .2 itself. +- **Result / log:** + - Windows: OPEN — maintainer. Not re-run at `de724c3` by Task 30. Task 24 ran it on 2026-10-09 against its own + builds: 34 of 34 checks after fix round 1. + - Linux: OPEN — maintainer. The same: Task 24 ran it in Docker, 15 of 15. + - macOS: OPEN — maintainer (never run; the script's Portable `.app` layout and `~/Library` state paths are unverified) + +### 37. "From the previous build" and **Restart multiplexer now** +*Phase 5 Task 23.* +- **Preconditions:** `scripts/mux-update-survival.ps1 -Sandbox -LeaveRunning` (Windows) or `--leave-running` + (Linux) has finished. It leaves the .2 GUI running on the .1 daemon. +- **Actions:** in the sandbox window, read the *Multiplexer* notification. Click **Restart multiplexer now**, then + **Restart**. Press Enter in a pane right away, and again once the restart is over. Clean up with `-CleanupOnly` / + `--cleanup-only`. +- **Expected:** + - The notification reads "The multiplexer is from the previous build (0.12.0-survival.1); restart it when convenient + — this closes its N shells.". + - A *Restart Multiplexer* dialog reads "Restart the multiplexer?" and "N shells running in the multiplexer will be + closed.", with **Restart**. + - The panes show `[Multiplexer disconnected] [Press Enter to reconnect]`. Enter during the restart writes `[The + multiplexer is restarting — the new shell starts once it is back]`; after it, Enter starts a new shell. A new + `mux serve` runs from `data\bin\0.12.0-survival.2\`. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: N/A (`--leave-running` is refused on macOS: Velopack restarts the app with `open -n`, which drops the sandbox's + environment) + +### 38. The no-overlap path: an update that cannot keep the multiplexer +*The Task 24 carry (report §7).* +- **Preconditions:** step 37's sandbox after its `-CleanupOnly`. The run below installs afresh, and `-SkipBuild` reuses + that sandbox's builds, so the daemon it leaves running has not been through step 37's restart. As in step 36, the + harness edits your real user `PATH` and `Uninstall` key until its `-CleanupOnly`. +- **Actions:** + 1. `scripts/mux-update-survival.ps1 -Sandbox -SkipBuild -LeaveRunning` (Linux: `--skip-build + --leave-running`). It runs steps 1-8, packs `0.12.0-survival.3` (the .2 build with the marker + `ntilde-mux-protocol: 3-3`) into the feed, and leaves the .2 GUI and the daemon running. + 2. In the sandbox's Ntilde window, open the palette and run "Check for updates". A notification says .3 is downloaded. + 3. Run "Restart to update", or the notification's button. Read the question, and confirm it. + 4. Check the result. + 5. `scripts/mux-update-survival.ps1 -Sandbox -CleanupOnly` (Linux: `--cleanup-only`). +- **Expected:** + 3. The *Apply Update* dialog reads "Multiplexed sessions are still running." and "N multiplexed sessions will be closed + by the update (the new version cannot keep them).", with **Close sessions and update** and **Cancel**. Cancel + leaves the update unapplied. + 4. The daemon's pid exits, and the heartbeat files stop growing. `current\sq.version` becomes `.3`. The restarted GUI + starts a fresh daemon, with a new pid and its image under `\bin\`. `\logs\debug.log` has "[MainWindow] + the update closes the multiplexer: protocol 1-2, the new build's 3-3; ...". +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: N/A (`--leave-running` is refused on macOS, as in step 37) + +### 39. Updates with the setting off +*Phase 5 Task 22 (ruling: Off keeps pre-Phase-5 behaviour).* +- **Preconditions:** the survival sandbox with **Keep shells running when the window closes** set to *Off*, and a + multiplexer running (`ntilde mux ls`). +- **Actions:** stage an update and apply it from the notification. Then stage another and relaunch. +- **Expected:** the in-app apply asks first ("N multiplexed sessions will be closed by the update.") and stops the + multiplexer. While any multiplexer runs, a staged update is not applied automatically at startup. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 40. A remote `ntilde-mux` from a previous version +*Phase 5 Task 23 (the remote notice).* +- **Preconditions:** an `ntilde-mux` of an older version on t30-ssh, and a persistent tab on it with a shell. To make + one, run `scripts/docker-publish-mux-daemon.sh` from a copy whose publish line adds `-p:Version=0.11.0 + -p:InformationalVersion=0.11.0`, then install it with *Choose file…* in the install dialog. +- **Actions:** reconnect the tab, or open a new one. Click **Restart ntilde-mux on nova@127.0.0.1**, confirm, then press + Enter in the tab. +- **Expected:** a *Multiplexer* notification reads "ntilde-mux on nova@127.0.0.1 is from a previous version (0.11.0); + restart it when convenient — this closes its N shells.". The restart stops that `ntilde-mux` only, and the tab shows + `[ntilde-mux on nova@127.0.0.1 stopped] [Press Enter to reconnect]`. Enter starts the installed version once the old + one has stopped. If the connection is down, or it is already the installed version, nothing is sent and a notification + says so. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +## J. `ntilde mux ls --all` + +### 41. `ls --all` lists this computer, then every host that keeps sessions (key authentication, scripted) +*Phase 5 Task 27. Setup uses `spawn-for-test` in the local daemon and in the host's `ntilde-mux`.* +- **Preconditions:** the sandbox has SSH profiles with **Keep remote sessions running (ntilde-mux)**, using the throwaway + key. Their host keys are trusted in the sandbox (native: `ssh\native_known_hosts.json`). A local daemon with one + session. +- **Actions:** + 1. `ntilde mux ls --all` while the host's `ntilde-mux` is not running yet. + 2. Start two sessions on the host (setup); `ls --all`; `ls --all --json`. + 3. Stop the local multiplexer; `ls --all`; plain `ls`. + 4. Untick the profile's checkbox (here, `PersistRemoteSessions: false` in the sandbox store); `ls --all`. +- **Expected:** + 1. A `HOST` column in front of `ls`'s. `this computer` comes first, then `nova@` with `No sessions.`. Connecting + starts `ntilde-mux` on the host, as opening a persistent tab does. Exit 0. + 2. Both remote sessions are listed under the host. `--json` prints + `{"endpoints":[{"endpoint":"local","host":"this computer","sessions":[…]},{"endpoint":"ssh:","host":"user@host","sessions":[…]}]}`. + 3. `this computer unreachable: no multiplexer is running`, the remote host still listed, exit 1. Plain `ls` never + connects anywhere. + 4. The table ends "No remote hosts keep sessions.". + - Nothing prompts. A native profile that signs in with its key never asks the credential store; an OpenSSH one does + (step 42). `logs\mux-ls-all.log` holds the last run's lines. +- **Result / log:** + - Windows: PASS (run by implementer, 2026-10-09, `\40-win-ls-all-cells.log`, 12 of 12). Native backend, key + only: + + ``` + HOST ID STATE ATTACHED SIZE TITLE + this computer 1bdd94a6-2318-4879-a0c5-0734169d5f92 running 0 80x24 ...\t30shell.exe + nova@127.0.0.1 374cb101-b04c-40c2-8c54-86888a1adf6a running 0 80x24 spawn-for-test + nova@127.0.0.1 3f0ccbcc-bfbe-4c4c-a7ce-e81f4b2bb5f5 running 0 80x24 spawn-for-test + ``` + + The log shows only "[NativeSshExec] ... exec on nova@127.0.0.1:2230 via 0 jump hop(s)", "[RemoteMux] + nova@127.0.0.1: connected (daemon pid 375, protocol 2, automatic)" and "exited with 0". (The first run's JSON check + failed on the script's own expectation: it looked for the profile id with dashes, and the endpoint is + `ssh:7a3c0f30000040008000000000000030`. Fixed in the script and re-run; + `\40-win-ls-all-cells.run1-script-bug.log`.) + - Linux: PASS (run by implementer, 2026-10-09, `\50-linux-ls-all-agent-cells.log`). A native and an OpenSSH + profile, both key only, against `t30-ssh:22` over the Docker network: "ls --all: this computer's session, then both + key profiles' remote sessions under nova@t30-ssh, exit 0 - 4 remote rows"; the OpenSSH attempt ran "(batch mode: ssh + will not prompt)". + - macOS: OPEN — maintainer + +### 42. `ls --all` and the OpenSSH backend +*Phase 5 Task 27 (fix round 1: jump hosts off Windows).* +- **Preconditions:** OpenSSH profiles: one direct with key authentication, one through a jump host. +- **Actions:** `ntilde mux ls --all`. +- **Expected:** the direct profile is listed, connected in batch mode. On Linux and macOS, the jump-host profile is not + connected to: `nova@ unreachable: it goes through a jump host, which ls --all does not sign in through` + (`--json`: `"error":"it goes through a jump host, …"`). On Windows the jump-host profile is connected to like any + other. +- **Result / log:** + - Windows: OPEN — maintainer. Not run by the implementer: on Windows' OpenSSH 9.5 an automatic attempt asks the + Credential Manager whether a password is saved for the profile, by id and then by name-based keys, and the vault is + shared with the real install. Use a throwaway Windows account, or accept that read. + - Linux: PASS (run by implementer, 2026-10-09, `\50-linux-ls-all-agent-cells.log`). "nova@t30-ssh unreachable: + it goes through a jump host, which ls --all does not sign in through", and the log line "not connected to: OpenSSH + through a jump host or proxy could prompt on this terminal". + - macOS: OPEN — maintainer + +### 43. `ls --all` and a host key it cannot trust (scripted) +*Phase 5 Task 27; Phase 4 after-merge host-key rule.* +- **Preconditions:** step 41's native profile, with the host's old key trusted. +- **Actions:** rotate the host's keys with step 29's command (`ntilde-mux` keeps running). Run `ntilde mux ls --all`. +- **Expected:** the host's line reads `unreachable: signing in needs an answer, which ls --all never asks for`. Nothing + prompts, no new key is trusted, and no password is offered. +- **Result / log:** + - Windows: PASS (run by implementer, 2026-10-09, `\41-win-ls-all-changed-hostkey.log`; the rotation itself: + `\54-hostkey-rotation-check.log`, "ntilde-mux serve pid before: 375 ... after: 375"). "nova@127.0.0.1 + unreachable: signing in needs an answer, which ls --all never asks for (exit 1, 0.5 s)". Exit 1 because no local + multiplexer was running. The log reads "NeedsUser ...: the host key of nova@127.0.0.1 is not one the user trusts + (unknown, or changed), and an automatic reconnect accepts no new key". + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +## K. Agents + +### 44. `ntilde.list_sessions` sees a windowless session +*Phase 5 Tasks 12-14.* +- **Preconditions:** *Agent access (observe)* on (Windows: quit your real Ntilde first; see the setup's warning). A + window with one pane. A second shell in the same multiplexer that no pane shows: detach one with **Pane: Detach**, or + start one with `ntilde mux spawn-for-test` (setup). +- **Actions:** from an MCP client on `Ntilde.McpServer`: `ntilde.list_sessions`, then `ntilde.read_screen` with the + windowless id. +- **Expected:** the pane is listed as usual. The other shell is listed with kind `local (windowless)`, its profile + column `this computer`, and its id the one `ntilde mux ls` prints. The footnote reads "windowless: running in the + multiplexer with no window here. …". `read_screen` returns its screen. +- **Result / log:** + - Windows: OPEN — maintainer. Not run: the agent pipe `ntilde-agent-` is not keyed by `NTILDE_APPDATA_ROOT`, + so a sandbox GUI with agent access would share it with the maintainer's running Ntilde. + - Linux: PASS (run by implementer, 2026-10-09, `\50-linux-ls-all-agent-cells.log`). The GUI ran under Xvfb in + Docker with agent access observe on; the session was started with `spawn-for-test`; the MCP server was driven over + stdio: + + ``` + | 57f85db1-dd1f-4daf-bb64-31107b167c1b | Default Shell | Default Shell | local | 149x40 | yes | awaitingInput (heuristic) | c173f202-… | + | eb783ce8-df2a-49e1-9b56-3ece1066efd6 | spawn-for-test | this computer | local (windowless) | 80x24 | no | - | - | + ``` + + `read_screen` on `eb783ce8-…`: "Screen 80x24, … 0| t30-session-W". + - macOS: OPEN — maintainer + +### 45. Status, capture, and the act tools on a windowless session +*Phase 5 Task 13.* +- **Preconditions:** step 44. A remote persistent tab's host connected, with a detached remote shell. +- **Actions:** `ntilde.get_session_status`, `ntilde.capture_screen` with `mode=render` and with `mode=live`. Turn on + *Agent access (act)*: `ntilde.send_input`, then `ntilde.close_session` on the windowless id. Repeat on the remote + windowless shell, with and without the profile's **Allow AI agent access to this connection**. +- **Expected:** the status is always the heuristic tier. `render` gives a picture; `live` is refused. `send_input` + types into the shell; `close_session` ends it (`ntilde mux ls`). On the remote shell both are refused until the + profile allows agent access. The **Agent Activity** journal records each read as `windowless · · session + `, folding repeats into one line with `×N`, and records every act. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 46. Asking about windowless sessions never connects anywhere +*Phase 5 Task 12 (R4).* +- **Preconditions:** a persistent profile whose host the window is not connected to, with shells on that host. +- **Actions:** `ntilde.list_sessions`. +- **Expected:** that host's shells are not listed, and nothing connects or prompts. Only multiplexers the window is + already connected to are asked, and one that does not answer within 7 seconds counts as not found. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +## L. `ntilde.com` + +### 47. `ntilde mux attach` straight from PowerShell, with Ctrl+C and the detach chord +*Phase 4 plan Task 22 step 7.* +- **Preconditions:** a build with `ntilde.com` beside `Ntilde.exe` (an install, or the release zip), and a sandbox + session running `ping -t localhost`. +- **Actions:** from PowerShell (no `cmd /c`): `ntilde mux attach `. Press Ctrl+C, then **Ctrl+\ then d**. Read + `$LASTEXITCODE`. +- **Expected:** the screen is painted. Ctrl+C reaches the shell (the ping stops), and the launcher keeps waiting. The + chord prints `[detached from ]`, the prompt returns, and `$LASTEXITCODE` is 0. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: N/A (`ntilde.com` is Windows only) + - macOS: N/A (the same) + +### 48. `ntilde.com` forwards exit codes and passes arguments through (scripted) +*Phase 4 §11.1.* +- **Preconditions:** `ntilde.com` beside `Ntilde.exe` (here, the launcher published from `src/Ntilde.Launcher`, beside + the dev build). +- **Actions:** `ntilde mux attach ` (with and without a multiplexer), `ntilde mux ls` with + none running, `ntilde mux ls --bogus`, `ntilde mux`, and `ntilde mux spawn-for-test "/q /k echo t30-session-B"` + (setup). +- **Expected:** `$LASTEXITCODE` holds the CLI mode's exit code: 2 for `attach nonexistent` and for usage errors, 1 for + `ls` with no multiplexer. A quoted argument with spaces arrives as one argument. +- **Result / log:** + - Windows: PASS (run by implementer, 2026-10-09, `\10-win-cli-cells.log`). Every CLI cell there ran through + `ntilde.com`: "attach with no daemon: exit 2"; "attach to a prefix nothing matches: exit 2"; "ntilde.com forwards exit + 2 for a usage error". `ls --json` shows the spawned session's `"arguments":"/q /k echo t30-session-B"` intact. + - Linux: N/A (Windows only) + - macOS: N/A (Windows only) + +### 49. A GUI launch through `ntilde.com` returns at once +*Phase 4 §11.3; manual 12.10.* +- **Preconditions:** as step 47. +- **Actions:** in PowerShell, `ntilde`; then `ntilde | Out-Null`. +- **Expected:** a plain `ntilde` opens the window and returns at once with exit code 0. With its output captured, the + caller waits until the window closes; this is documented, and not a regression. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: N/A (Windows only) + - macOS: N/A (Windows only) + +### 50. The installer puts `ntilde` on PATH +*Phase 4 §11.4.* +- **Preconditions:** a Velopack install. Use the survival harness's `NtildeSurvival` install (step 36 with + `-LeaveRunning`), not your real one. Until its `-CleanupOnly`, your real user `PATH` carries that install's + `current` folder: this step checks exactly that. +- **Actions:** open a new terminal and run `ntilde mux ls`. Uninstall (the harness's `-CleanupOnly`), then open another + terminal. +- **Expected:** the install folder's `current` is on the user PATH, so `ntilde` runs `ntilde.com` in a new terminal. An + update keeps the entry, and uninstalling removes it. The release `.zip` contains `ntilde.com` but adds nothing to + PATH. +- **Result / log:** + - Windows: OPEN — maintainer (Task 24's harness checks the PATH entry's add, remove and byte-identical restore) + - Linux: N/A (Windows only) + - macOS: N/A (Windows only) + +## M. Turning it off + +### 51. With the setting Off, nothing of the multiplexer is left visible +*Phase 5 global constraint; manual 12.11.* +- **Preconditions:** a sandbox with shells running in the multiplexer. +- **Actions:** Settings → Appearance → *Scrollback* → **Keep shells running when the window closes** → *Off*; save. Open a + new tab, check the palette, close the window, and relaunch. +- **Expected:** + - New tabs run normal shells, and no multiplexer is started for them. + - **Session: Attach to Session…**, **Pane: Detach** and **Session: Quit and Close All Shells** leave the palette. + - Closing the window asks nothing. SSH tabs open plain, whatever their profile says. + - Shells that were already running keep running, and `ntilde mux kill-server` ends them. The remembered first-close + answer is forgotten, so turning the setting back on asks again. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 52. The default, and an explicit Off +*Phase 5 Tasks 15 and 19.* +- **Preconditions:** two sandboxes: one whose `settings.json` has no `SessionPersistence` key, one with + `"SessionPersistence": "Off"`. +- **Actions:** start Ntilde in each; open a tab; run `ntilde mux ls`. +- **Expected:** the first runs its shell in a multiplexer (*Keep running* is the default for local shells). The second + starts none and stays *Off*, updates included. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer + +### 53. One profile stops keeping its remote shells +*Phase 4 §7.6, manual 12.6.* +- **Preconditions:** a persistent remote tab with a shell. +- **Actions:** untick **Keep remote sessions running (ntilde-mux)** in its profile; open a new tab of it; relaunch; + close the old tab; tick it again and relaunch. +- **Expected:** new tabs, and saved tabs at the next launch, open plain SSH, while their shells keep running on the host + (`ntilde mux ls --all` no longer lists the host). Closing such a tab ends its shell when Ntilde can sign in without + asking (keys, agent). Ticking it again reattaches them at the following launch. +- **Result / log:** + - Windows: OPEN — maintainer + - Linux: OPEN — maintainer + - macOS: OPEN — maintainer diff --git a/docs/superpowers/plans/2026-10-08-ntilde-mux-phase5.md b/docs/superpowers/plans/2026-10-08-ntilde-mux-phase5.md new file mode 100644 index 000000000..6e7fcf5dc --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-ntilde-mux-phase5.md @@ -0,0 +1,999 @@ +# ntilde multiplexer Phase 5 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the multiplexer on `dev-mux` into a shipped product: close the Phase 4 review items, let agents see windowless sessions, make the default-on world usable, keep sessions alive across app updates, list remote sessions, give persisted native tabs SFTP, and prepare the `dev-mux` → `main` release. + +**Architecture:** Every change is additive on the existing Phase 0–4 design (ARCHITECTURE §8.1–§8.2): the daemon protocol gains optional fields and one optional method (`readScreen`), still Min 1 / Max 2; the GUI keeps one `MuxConnectionHosts` per window; the agent host gains a windowless-session source behind a seam; the update path keeps a compatible daemon instead of shutting it down. + +**Tech Stack:** .NET 10 (C#, NativeAOT for releases), Avalonia 12, xunit v3 (`[AvaloniaFact]` for UI), rusty_ssh (Rust, russh) over P/Invoke, Velopack 1.2.0, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-10-08-ntilde-mux-phase5.md` (the maintainer's brief, verbatim, plus §R rulings). Background: `docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md` (§14 follow-ups, §15 as-built). + +## Global Constraints + +- Build and test only through `scripts/build.ps1` (Windows) / `scripts/build.sh`; one project per invocation; never raw `dotnet build`/`dotnet test`. +- App.Tests runs as two lanes, never unfiltered and never concurrently with another build: `--filter "Category!=Replay&Category!=RenderMetrics&Category!=PtySmoke&Category!=Stress&Category!=GoldenSharedPng&Lane!=PlatformBoot" --blame-hang-timeout 5m`, then `--filter "Lane=PlatformBoot&Category!=GoldenSharedPng" --blame-hang-timeout 5m`. Other projects: `--filter "Category!=Replay&Category!=RenderMetrics&Category!=PtySmoke&Category!=Stress&Category!=GoldenSharedPng"`. +- Mux protocol: `MuxProtocol.MinSupportedVersion = 1`, `MaxSupportedVersion = 2` stay. New wire members are optional (absent from a v1/v2 peer, `JsonIgnore(WhenWritingNull/WhenWritingDefault)` so they stay off the wire when unset); the new `readScreen` method is answered by an older daemon with `protocol_error`, which the client maps to "unsupported". Every new wire DTO is registered in `src/Ntilde.Mux.Contracts/MuxJsonContext.cs` (NativeAOT). +- Layering (Architecture.Tests must pass unchanged): `Ntilde.Mux` references no App/Avalonia/Platform; `Ntilde.Mux.Daemon` references only `Ntilde.Mux`; `Ntilde.Platform` references only Pty; `Ntilde.Mux.Contracts` and `Ntilde.AgentHost.Contracts` are leaves (no project references). +- Credentials: no credential-path behaviour change except Task 2's jump-host fix. The once-per-attempt rule (a saved password is offered at most once per attempt) and the never-empty-password rule (a password prompt is never answered with an empty string — abort instead) stand. +- With `SessionPersistence` explicitly `"Off"`, nothing in the app changes (no daemon spawned, no new dialogs, no new commands visible). +- Pane lines and toasts built from server-controlled text must strip control characters (Task 5); pane status lines are built from cause enums, never from server text. +- No TCP listener; the local endpoint stays a 0600 UDS in a 0700 directory (Unix) / a current-user pipe (Windows). No thread-pool work on the output path. The proxy stays a dumb pump. +- Never touch the user's real Windows Credential Manager entries or real ntilde install in tests or experiments. Never use `git stash`. +- Every behaviour change lands with a test that fails before the change (run it red first, record that in the task report). +- New `TerminalSettings` fields are avoided (R1 uses a flag file). If one becomes unavoidable, it must be added to `TerminalPane.ApplySettings`' whitelist only if panes read it, and to `src/Ntilde.McpServer/Tools/SettingsTools.cs` `BoolFields`/`StringFields` and `KnownFields` (two gating drift-guard tests). +- Copy style: user-facing strings are plain sentences, no "please", `…` for "opens a dialog", em dash `—` in banners as existing ones do. +- Commits: one or more per task, message `type(scope): summary`, body ending with `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. A remote host whose link stalls (proxy alive, no bytes read) while the user types, resizes, closes or detaches several panes: the UI must stay responsive, and after the link recovers the frames arrive in call order with nothing duplicated. (Task 1: order + promptness test with a stalled `FakeMuxServerEnd`.) +2. An agent listing sessions while a remote host is reconnecting or prompting for a password: the agent path must never start a connection or a prompt, and must answer within the MCP 10 s round trip. (Task 12: `CurrentClient`-only test with a host whose `GetClient` would throw.) +3. An app update applied while shells run, on a machine where the daemon was started by the *previous* build: the shells survive, the new window reattaches, and the user is told the multiplexer is from the previous build — once, not per pane. (Tasks 20–24.) +4. A session-file restore after a reboot for a default-on user: fresh shells, no loss toast; but a daemon crash since boot is still announced. (Task 19.) +5. A user who closes a tab that shares a remote session while its link is down: the other client's shell must not be killed. (Task 26: share-close disposition test.) + +--- + +## Part A — §4 review items + +### Task 1: Non-blocking, ordered sends in `MuxClient` + +Every UI-thread send can block today: `MuxClient`'s outbound queue is a `BlockingCollection` bounded at `OutboundCapacity = 1024` frames (`src/Ntilde.Mux/MuxClient.cs:26,33`), and both `Enqueue` (`:309-320`, used by every request, which enqueues before its first await) and `Post` (`:322-332`, fire-and-forget: input, resize, detach, kill) call `_outbound.Add`, which blocks while the queue is full. A stalled link therefore freezes the UI thread in: local `MainWindow.KillMuxSessionOnClose` (`MainWindow.axaml.cs:~7356` `mux.KillAsync()`), Detach/Leave (`DisposeControlTree` `~:7295-7301` `detaching.Detach(...)`), Reconnect (`TerminalPane.axaml.cs:~5396-5397` `faulted.Kill(); session.Dispose();`), input (`TerminalView.cs:1240,1252,1684,2971`; `TerminalPane.axaml.cs:900,2910,3663`; `MainWindow.axaml.cs:3768,3785,6664-6665,8085,8108`) and resize (`TerminalPane.axaml.cs:4451,4814,4843,5049`). B1 (`MuxConnectionHost.HandOffKill`, `_killSends`) fixed only remote close kills. Ruling R6: fix it once, in `MuxClient`. + +**Files:** +- Modify: `src/Ntilde.Mux/MuxClient.cs` (the send path: `Enqueue`, `Post`, `OnDisconnected`, new overflow pump) +- Modify: `src/Ntilde.Mux/MuxClientOptions.cs` (new `MaxOverflowBytes`, default 8 MiB) +- Test: `tests/Ntilde.Mux.Tests/Client/MuxClientNonBlockingSendTests.cs` (new) +- Test: `tests/Ntilde.App.Tests/Shell/Mux/MuxLocalCloseStalledLinkTests.cs` (new) +- Reuse: `tests/Ntilde.App.Tests/Shell/Mux/Remote/FullSendQueue.cs` (`FillAsync`, `ReturnsPromptlyAsync`, `DrainUntilRequestAsync`), `tests/Ntilde.Mux.Tests/Support/FakeMuxServerEnd.cs` (`Create(pipeCapacityBytes)`, `AcceptHelloAsync`, `ReadRequestAsync`). If `FullSendQueue` is App.Tests-only, move it to `tests/Ntilde.Mux.Tests/Support/` and link it into App.Tests the way `MuxTestHost` is linked (`tests/Ntilde.App.Tests/Ntilde.App.Tests.csproj`, `Link="MuxSupport\…"`). + +**Interfaces:** +- Produces: `MuxClient` sends never block the caller. Semantics: frames reach the wire in the order their calls were made (across `Post` and `Enqueue`); once the overflow exceeds `MuxClientOptions.MaxOverflowBytes` the client faults (`Dispose`-equivalent disconnect with reason `"send overflow"`), which the existing reconnect/drop paths handle. No public signature changes. + +- [ ] **Step 1: Write the failing tests** (`MuxClientNonBlockingSendTests`, xunit `[Fact]`, Category none): + - `Every_send_returns_promptly_while_the_outbound_queue_is_full`: `var daemon = FakeMuxServerEnd.Create(pipeCapacityBytes: 4096)`; connect a `MuxClient` over `daemon.ClientEnd`, `await daemon.AcceptHelloAsync(2)`; open a session (`client.OpenSession(id, …, MuxAttachMode.Shared)` against a fake attach reply, or use `client.SendInput` via a `MuxClientSession` built the way `MuxClientSessionTests` does); `await FullSendQueue.FillAsync(client)` (the daemon reads nothing); then each of these must complete within `FullSendQueue.ReturnsPromptlyAsync` (5 s): `session.SendInput("x")`, `session.Resize(100, 30)`, `session.Detach(userDetached: true)`, `session.Kill()`, and `client.KillAsync(otherId)` — for `KillAsync` assert only that the call *returns a task* promptly (`Task t = client.KillAsync(id)` measured around the call), not that it completes. + - `Frames_reach_the_wire_in_call_order_after_a_stall`: same setup, fill, then call in order `SendInput(s1,"a")`, `KillAsync(s2)` (request), `SendInput(s1,"b")`, `Resize(s1,…)`, `Detach(s1)`; start draining on the daemon side; read frames until the detach request; assert the sequence of (kind, session, payload/method) after the filler frames is exactly `Input a`, `Request kill s2`, `Input b`, `ResizeEvent`/resize request, `Request detach s1`. + - `A_send_overflow_past_the_cap_disconnects_the_client`: `MaxOverflowBytes = 64 * 1024`; fill; post 100 × 1 KiB inputs; assert `client.IsConnected` becomes false within 5 s and a pending request's task faults with `IOException`. + - `Sends_after_disconnect_are_dropped_without_throwing`: dispose the daemon end; `SendInput` returns; `KillAsync` faults with `IOException` (today's contract). + +- [ ] **Step 2: Run them red.** `scripts/build.ps1 test tests/Ntilde.Mux.Tests --filter "FullyQualifiedName~MuxClientNonBlockingSendTests"`. Expected: the promptness test fails on the first call (timeout), the order test cannot run past the fill (timeout), the overflow test fails (no cap). Record the output in the report. + +- [ ] **Step 3: Implement the overflow pump in `MuxClient`.** One gate, one FIFO, one pump work item at a time; the frame stays in the overflow queue until `Add` returned so a later `TryAdd` cannot overtake it: + +```csharp +private readonly object _sendGate = new(); +private readonly Queue _overflow = new(); +private long _overflowBytes; // guarded by _sendGate +private bool _pumping; // guarded by _sendGate + +// Called by Enqueue and Post instead of _outbound.Add. Never blocks. +private bool Send(MuxOutboundFrame frame) +{ + lock (_sendGate) + { + if (_outbound.IsAddingCompleted) return false; + if (_overflow.Count == 0 && _outbound.TryAdd(frame)) return true; + _overflow.Enqueue(frame); + _overflowBytes += frame.PayloadLength; // add a PayloadLength to MuxOutboundFrame if absent + if (_overflowBytes > _options.MaxOverflowBytes) { overflowed = true; } + else if (!_pumping) { _pumping = true; ThreadPool.UnsafeQueueUserWorkItem(static c => c.PumpOverflow(), this, preferLocal: false); } + } + if (overflowed) { FaultFromSendOverflow(); return false; } // outside the lock: disconnect path takes other locks + return true; +} + +private void PumpOverflow() +{ + while (true) + { + MuxOutboundFrame next; + lock (_sendGate) + { + if (_overflow.Count == 0) { _pumping = false; return; } + next = _overflow.Peek(); + } + try { _outbound.Add(next); } // blocks this pool thread only + catch (InvalidOperationException) { DropOverflow(); return; } // CompleteAdding: disconnected + lock (_sendGate) { _overflow.Dequeue(); _overflowBytes -= next.PayloadLength; } + } +} +``` + `Enqueue` keeps its contract (a request whose frame could not be queued because the client is disconnected fails its TCS with `IOException`); `Post` keeps swallowing. `OnDisconnected` (`:565-569`) clears `_overflow` under `_sendGate` after `CompleteAdding()` (pending request TCSs are already failed there). `FaultFromSendOverflow` logs `"[MuxClient] outbound overflow past {cap} bytes; disconnecting"` and runs the same teardown a read error runs. Only one pool thread per client can be parked in the pump. + +- [ ] **Step 4: Run the Mux tests green**, then the whole `tests/Ntilde.Mux.Tests` project (all 900+ must pass). + +- [ ] **Step 5: App-level pin** (`MuxLocalCloseStalledLinkTests`, `[AvaloniaFact]`): a *local* `MuxConnectionHost` built over a `FakeMuxServerEnd` (`new MuxConnectionHost(ct => MuxClient.ConnectAsync(daemon.ClientEnd, null, ct), "test", null)` pattern from `MainWindowMuxSharingTests`), a `TestMainWindowFactory` window with `SessionPersistence = "KeepOnClose"` and that host injected, one mux pane attached; fill the send queue; close the pane's tab on the UI thread and assert `CloseTab` returns within 2 s (stopwatch around the call on the UI thread); then drain and assert the daemon receives `kill` for that session. Run red by temporarily reverting Step 3 (or before Step 3), then green. + +- [ ] **Step 6: Commit.** `fix(mux): never block a caller on a full outbound queue` — body names the call sites now covered and that B1's `HandOffKill` stays (harmless). + +### Task 2: Jump-host passwords on the interactive OpenSSH path + +The native interactive path is fixed by main's d1972b7 (merged in §0): a native prompt names its hop (`SshInteractionRequest.IsJumpHop/Host/Port/User`), and both `NativeSshPromptResponder` and `SshInteractionService.IsProfileTargetPrompt` keep the vault from a hop's prompt. What remains is OpenSSH's askpass: `SshAskPassCommand.IsTargetPasswordPrompt` (`src/Ntilde.App/Shell/SshAskPassCommand.cs:316-339`) fills the vault password for `user@host's password:` or `(user@host) …password:` when `user@host` is the profile's target. Two holes: (a) before OpenSSH 8.4 keyboard-interactive prompts carry no `(user@host)` prefix, so a bastion can send keyboard-interactive text reading exactly `ops@prod.internal's password: ` and get the target's password (Windows 10's built-in OpenSSH 8.1 is affected); (b) a hop whose `user@host` equals the target's (different port) is indistinguishable, since ssh prompts omit the port. The automatic remote path already refuses old ssh (`RemoteMuxHostFactory.PrefixesKeyboardInteractivePrompts`); the interactive paths — remote-mux user attempts and plain OpenSSH tabs — do not. + +Rule: **when the connection goes through a jump host, and either the local ssh does not prefix keyboard-interactive prompts (< 8.4, or unknown) or a hop's `user@host` equals the target's, the askpass helper runs without the vault** (`NTILDE_SSH_ASKPASS_NO_VAULT=1`, the existing "without saved password" mode) — the user types the password. + +**Files:** +- Modify: `src/Ntilde.App/Shell/Mux/Remote/RemoteMuxHostFactory.cs` (`CreateTransport`, interactive branch: `withoutSavedPassword` when the rule holds) +- Modify: the plain OpenSSH tab's askpass environment builder (find where a plain OpenSSH `TerminalPane`/`SshConnectionService.BuildLaunchDetailsFor` sets `SSH_ASKPASS` + `NTILDE_SSH_ASKPASS_PROFILE*` — `grep -rn "NTILDE_SSH_ASKPASS" src`) so plain tabs apply the same rule +- Create: `src/Ntilde.App/Shell/SshAskPassVaultPolicy.cs` — one pure function both call +- Test: `tests/Ntilde.App.Tests/Shell/SshAskPassVaultPolicyTests.cs` (new), `tests/Ntilde.App.Tests/Shell/Mux/Remote/RemoteMuxHostFactoryTests.cs` (add), and the plain-tab builder's test file +- Docs: Phase 4 spec §14 — mark "Vault password to a jump host (pre-existing, plain native tabs too)" resolved (native by d1972b7, OpenSSH by this task) + +**Interfaces:** +- Produces: `internal static bool SshAskPassVaultPolicy.MayOfferVault(SshProfile profile, bool sshPrefixesKeyboardInteractivePrompts)` — false when `profile.JumpHosts` (or the generated config's ProxyJump/ProxyCommand) is non-empty and (`!sshPrefixesKeyboardInteractivePrompts` or any hop's `User@Host` equals the target's, case-insensitive; a hop's empty user means the profile's user). +- Consumes: `OpenSshClientVersionCache.Shared` / `OpenSshClientVersion` (A4) for the prefix capability (unknown version ⇒ false). + +- [ ] **Step 1: Failing tests.** `SshAskPassVaultPolicyTests`: `[Theory]` rows — no jump hosts + 8.1 → true; jump host + 8.4 + distinct hop → true; jump host + 8.1 → false; jump host + unknown version → false; jump host + 8.4 + hop `ops@prod.internal` equal to target `OPS@prod.internal` → false; hop with empty user and host equal to target's → false. `RemoteMuxHostFactoryTests`: an *interactive* OpenSSH request for a profile with one jump host and a version cache reporting 8.1 → the built transport request's `WithoutSavedPassword` is true (follow the pattern at `RemoteMuxHostFactoryTests.cs:~536-576`); same profile with 9.6 → false. Plain tab: the launch environment for the same profile with 8.1 contains `NTILDE_SSH_ASKPASS_NO_VAULT=1`. +- [ ] **Step 2: Run red** (policy class missing → compile error counts as red; then the factory/plain-tab tests fail on the assertion once the class exists with `return true`). +- [ ] **Step 3: Implement** the policy and call it from both builders (the factory call already runs off the UI thread; the version probe for the plain-tab builder must not run on the UI thread — if the builder runs on the UI thread, use `OpenSshClientVersionCache.Shared.TryGetCached(path)` and treat "not probed yet" as unknown ⇒ no vault, and kick a background probe). +- [ ] **Step 4: Green**, plus `SshAskPassTargetPromptTests`, `SshAskPassVaultOnlyTests`, `RemoteMuxHostFactoryTests` all green. +- [ ] **Step 5: Commit** `fix(ssh): no vault password for askpass when a jump host could ask as the target`. + +### Task 3: One native known-hosts store, atomic `TrustHost` + +`new NativeKnownHostsStore` exists per `SshInteractionService` (`src/Ntilde.App/Services/Ssh/SshInteractionService.cs:39`, one per window) and as a static read-only `Lazy` in `RemoteMuxInteractionHandler.cs:277`. Each instance has its own `_syncRoot` (`src/Ntilde.Platform/Ssh/Native/NativeKnownHostsStore.cs:15`). `TrustHost` (`:45-79`) reads, modifies and `PersistEntriesLocked` (`:99-114`) writes with `File.WriteAllText` (truncate-and-write). `LoadEntriesLocked` (`:81-97`) swallows parse errors and returns an empty list, so the next `TrustHost` after a torn read wipes every trusted key. + +**Files:** +- Modify: `src/Ntilde.Platform/Ssh/Native/NativeKnownHostsStore.cs` +- Modify: `src/Ntilde.App/Services/Ssh/SshInteractionService.cs:39`, `src/Ntilde.App/Shell/Mux/Remote/RemoteMuxInteractionHandler.cs:277` (use the shared instance) +- Test: `tests/Ntilde.Platform.Tests/Ssh/NativeKnownHostsStoreTests.cs` + +**Interfaces:** +- Produces: `public static NativeKnownHostsStore NativeKnownHostsStore.ForPath(string storeFilePath)` — one instance per full path (normalised with `Path.GetFullPath`, OrdinalIgnoreCase on Windows) from a static `ConcurrentDictionary`; `public static NativeKnownHostsStore Default` = `ForPath()`. The public ctor stays for tests but delegates locking to a static per-path lock object so even two `new` instances on one path serialise. + +- [ ] **Step 1: Failing tests.** + - `Concurrent_trusts_from_two_instances_keep_every_entry`: two instances (`new NativeKnownHostsStore(path)` twice) on one temp path; `Parallel.For(0, 64, i => (i % 2 == 0 ? a : b).TrustHost($"h{i}", 22, "ssh-ed25519", $"SHA256:{i}"))`; then a fresh instance's `CheckHost` returns `Trusted` for all 64. + - `A_corrupt_store_is_kept_aside_not_overwritten`: write `"{not json"` to the path; `TrustHost("h",22,…)`; assert a file `.corrupt-*` exists with the original bytes, and the store now holds `h`. + - `TrustHost_never_leaves_a_partial_file`: trust 200 entries while a reader loop calls `CheckHost` on another instance on another thread; the reader never sees a parse failure (instrument via an internal `LoadFailures` counter, or assert every read returns ≥ the count trusted before it started). +- [ ] **Step 2: Run red** (`scripts/build.ps1 test tests/Ntilde.Platform.Tests --filter "FullyQualifiedName~NativeKnownHostsStoreTests"`). +- [ ] **Step 3: Implement.** Static `ConcurrentDictionary PathLocks`; all reads/writes lock the path's object. Write: serialise to `"{path}.{Guid.NewGuid():N}.tmp"`, `File.Move(tmp, path, overwrite: true)`, retry up to 5 × 20 ms on `IOException`/`UnauthorizedAccessException` on Windows (pattern: `MuxDiscovery.WriteDescriptor`, `src/Ntilde.Mux.Contracts/MuxDiscovery.cs:82-102`). Load: on `JsonException`, `File.Move(path, $"{path}.corrupt-{DateTime.UtcNow:yyyyMMddHHmmss}")` once, log, and return empty. Point both App sites at `NativeKnownHostsStore.Default`. +- [ ] **Step 4: Green** (whole Platform.Tests project, then `SshInteractionServiceTests` and `RemoteMuxInteractionHandlerTests` in App.Tests). +- [ ] **Step 5: Commit** `fix(ssh): one known-hosts store per file, written atomically`. + +### Task 4: `RemoteMuxCommand` escapes a recorded path instead of replacing it + +`src/Ntilde.App/Shell/Mux/Remote/RemoteMuxCommand.cs:43-50`: a recorded `RemoteDaemonPath` outside ASCII `[A-Za-z0-9._/+-]` (`IsSafeAbsolutePath`, `:32-41`) is silently replaced by `sh -c 'exec "$HOME/.local/share/ntilde/bin/ntilde-mux" …'`. A safe path is passed unquoted. + +**Files:** +- Modify: `src/Ntilde.App/Shell/Mux/Remote/RemoteMuxCommand.cs` +- Modify: `src/Ntilde.App/Shell/Mux/Remote/RemoteMuxConnector.cs` (log when a non-empty recorded path is refused) +- Test: `tests/Ntilde.App.Tests/Shell/Mux/Remote/RemoteMuxCommandTests.cs` + +**Interfaces:** +- Produces: `RemoteMuxCommand.For(options, arguments)` returns, for any absolute recorded path that contains no `'`, `\`, `!`, control character (`char.IsControl`) or Unicode format character (category `Cf`): `sh -c 'exec "" '` with `$`, `` ` ``, `"` inside `` escaped by a backslash (inside sh double quotes). A path containing any refused character, a relative path, or an empty path falls back to the default install path (Task 6's expression) and `RemoteMuxCommand.RefusedRecordedPath(string)` returns true so the connector logs `"[RemoteMux] recorded ntilde-mux path refused (unsafe characters); using the default install path"`. `IsSafeAbsolutePath` is renamed `IsQuotableAbsolutePath` with the new rule. + +- [ ] **Step 1: Failing tests.** Change `[InlineData("/home/a b/x", ProxyFallback)]` to expect `sh -c 'exec "/home/a b/x" proxy --stdio'`; add rows: `/home/José/.local/share/ntilde/bin/ntilde-mux` → quoted as is; `/home/a$b/x` → `sh -c 'exec "/home/a\$b/x" proxy --stdio'`; `/home/a"b/x` → `\"`; `/home/a'b/x` → fallback; `/home/a\b/x` → fallback; `/x/‮evil` → fallback; `relative/x` → fallback. The quoted commands are also run through the existing "runs in sh" test pattern if one exists (`Each_step_works_in_the_directory_the_remote_command_runs_from` in `RemoteMuxInstallCommandsTests` shows how the tests execute sh under WSL/Git Bash; skip when no sh). +- [ ] **Step 2: Red.** **Step 3: Implement.** **Step 4: Green** (`RemoteMuxCommandTests`, `RemoteMuxConnectorTests`). **Step 5: Commit** `fix(mux): quote a recorded ntilde-mux path instead of replacing it`. + +### Task 5: Askpass marker folder 0700, control characters out of toasts, cleared response payload + +Three small hardening items in one task (one reviewer can judge them together; each has its own test). + +(a) `SshAskPassSessionMarkers` (`src/Ntilde.App/Shell/SshAskPassSessionMarkers.cs:46,71,107`) creates `/askpass` with a plain `Directory.CreateDirectory` (0755 under a usual umask). (b) `RemoteMuxFailureClassifier.Quote` (`src/Ntilde.App/Shell/Mux/Remote/RemoteMuxFailureClassifier.cs:292-296`) only trims and caps; ESC, BEL, CR and bidi controls (U+202E etc.) reach the toast through `TerminalPane.RemoteMuxUnavailableMessage` (`TerminalPane.axaml.cs:~4267`). `RemoteOutputText.Quote` already drops `char.IsControl` for the installer. (c) `NativeSshPromptResponder.RespondAsync` (`src/Ntilde.Platform/Ssh/Native/NativeSshPromptResponder.cs:72-73`) never clears the response payload (a password in JSON), and `NativeSshInterop.SubmitResponse` (`src/Ntilde.Platform/Ssh/Native/NativeSshInterop.cs:641-662`) makes a second copy (`data.ToArray()`, `:648`). + +**Files:** +- Create: `src/Ntilde.Mux.Contracts/PrivateDirectory.cs` — lift `MuxDiscovery.CreatePrivateDirectory` (`MuxDiscovery.cs:278-291`) to `public static class PrivateDirectory { public static DirectoryInfo Create(string path) }` (0700 off Windows, re-asserted with `File.SetUnixFileMode`), and make `MuxDiscovery` call it. (Mux.Contracts is a leaf every App/Mux assembly already references; this adds no project reference.) +- Modify: `src/Ntilde.App/Shell/SshAskPassSessionMarkers.cs` (use `PrivateDirectory.Create`) +- Modify: `src/Ntilde.App/Shell/Mux/Remote/RemoteOutputText.cs` (`Quote` also drops `UnicodeCategory.Format` characters: U+200E/F, U+202A–U+202E, U+2066–U+2069, U+FEFF), `RemoteMuxFailureClassifier.cs` (use `RemoteOutputText.Quote`), `TerminalPane.axaml.cs` `RemoteMuxUnavailableMessage` (sanitize `reason` with the same function) +- Modify: `src/Ntilde.Platform/Ssh/Native/NativeSshPromptResponder.cs`, `NativeSshInterop.cs` (`SubmitResponse` takes `ReadOnlySpan` and pins it — `fixed (byte* p = data)` with a `byte*` P/Invoke overload — instead of copying; if the P/Invoke signature is generated/shared, zero the copy in `finally`) +- Test: `tests/Ntilde.App.Tests/Shell/SshAskPassVaultOnlyTests.cs` (add, Unix-only), `tests/Ntilde.App.Tests/Shell/Mux/Remote/RemoteMuxFailureClassifierTests.cs` (add), `tests/Ntilde.Platform.Tests/Ssh/NativeSshSessionInteractionTests.cs` or a new `NativeSshPromptResponderTests.cs` + +- [ ] **Step 1: Failing tests.** + - `The_marker_folder_is_private` (`Assert.SkipWhen(OperatingSystem.IsWindows(), …)`): fresh temp root; `TryClaim`; `File.GetUnixFileMode(dir) == UserRead|UserWrite|UserExecute`. Fails on ubuntu CI today (the local Windows run skips it — note in the report that red is proven by CI or WSL). + - `Server_text_in_a_failure_reason_loses_control_and_bidi_characters`: `RemoteMuxFailureClassifier.Classify(255, "", "x\u001b[2J\u0007y‮z\r", null)` → `Reason` contains none of `\u001b`, `\u0007`, `‮`, `\r`, and still contains `x`, `y`, `z`. And `TerminalPane.RemoteMuxUnavailableMessage("box", "a\u001bb")` contains no ESC. + - `The_response_payload_is_cleared_after_submit`: a fake `INativeSshInterop` whose `SubmitResponse(handle, kind, ReadOnlySpan data)` copies `data` to a captured array *and* keeps a reference to the array the responder passed (expose an internal `NativeSshPromptResponder.PayloadObserver` `Action?` test seam invoked with the payload array right before submit); after `RespondAsync` with `SshInteractionResponse.FromSecret("hunter2")`, the observed array is all zeros, while the fake's copy contains `hunter2` (proving the submit saw the real bytes). +- [ ] **Step 2: Red** (the bidi test fails on U+202E even after a naive `IsControl` filter — that is why Format is included). +- [ ] **Step 3: Implement**; `RespondAsync`: `byte[] payload = …; try { PayloadObserver?.Invoke(payload) /* test only, before submit */; interop.SubmitResponse(handle, kind, payload); } finally { CryptographicOperations.ZeroMemory(payload); PayloadObserver… }` — order the seam so the observer sees the array object and the test inspects it after `RespondAsync` returns. +- [ ] **Step 4: Green** (App.Tests lane filter `FullyQualifiedName~SshAskPass|FullyQualifiedName~RemoteMuxFailureClassifier|FullyQualifiedName~RemoteOutputText`, Platform.Tests whole project, Mux.Tests whole project — `MuxDiscovery` changed). +- [ ] **Step 5: Commit** `fix(mux): private askpass folder, clean server text in toasts, cleared prompt answers`. + +### Task 6: The remote install dir honours `$XDG_DATA_HOME` + +`.local/share/ntilde/bin` is hard-coded in `RemoteMuxCommand.cs:20,49` (`DefaultRelativePath`), `RemoteMuxInstallCommands.cs:103` (`OfflineOneLiner`), `:110` (`DirectoryVariable = "d=\"$HOME/.local/share/ntilde/bin\"; "`), and UI copy `Views/Ssh/RemoteMuxInstallDialog.cs:196`, `Views/Ssh/NewSshConnectionView.axaml:172`. The daemon root is `${XDG_DATA_HOME:-$HOME/.local/share}/ntilde/ntilde-mux` on Linux (.NET's `LocalApplicationData`, which ignores a relative `XDG_DATA_HOME`) and `~/Library/Application Support/ntilde/ntilde-mux` on macOS. + +Rule: the install dir is `/ntilde/bin` with `` = `$XDG_DATA_HOME` when it is set and absolute, else `$HOME/.local/share` — on Linux **and** macOS (keep `.local/share` on macOS: `Application Support` has a space and macOS sets no XDG variable, so nothing changes there). + +**Files:** +- Create: `src/Ntilde.App/Shell/Mux/Remote/RemoteInstallDir.cs` with `internal const string ShellExpression = "${XDG_DATA_HOME:-}"`-free POSIX snippet: + ```csharp + // POSIX sh; matches .NET's rule (an unset, empty or relative XDG_DATA_HOME means $HOME/.local/share). + internal const string Assign = "case \"${XDG_DATA_HOME-}\" in /*) d=\"$XDG_DATA_HOME/ntilde/bin\";; *) d=\"$HOME/.local/share/ntilde/bin\";; esac; "; + internal const string Display = "$XDG_DATA_HOME/ntilde/bin (default ~/.local/share/ntilde/bin)"; + ``` +- Modify: `RemoteMuxInstallCommands.cs` (`DirectoryVariable` → `RemoteInstallDir.Assign`; `OfflineOneLiner` uses it: `sh -c ' mkdir -p "$d" && cd "$d" && …'` — keep it one line a user can paste), `RemoteMuxCommand.cs` fallback → `sh -c ' exec "$d/ntilde-mux" '`, the dialog and tooltip copy → `RemoteInstallDir.Display` +- Test: `tests/Ntilde.App.Tests/Shell/Mux/Remote/RemoteMuxInstallCommandsTests.cs`, `RemoteMuxCommandTests.cs` + +- [ ] **Step 1: Failing tests.** Exact-string tests updated to the new expression; a new behavioural test runs the `Assign` snippet under `sh` (skip when no `sh` on PATH — on Windows CI Git Bash provides it) with env `XDG_DATA_HOME=/tmp/x` → `echo "$d"` prints `/tmp/x/ntilde/bin`; with `XDG_DATA_HOME=rel` → `$HOME/.local/share/ntilde/bin`; unset → same; empty → same. +- [ ] **Step 2: Red. Step 3: Implement. Step 4: Green** (`RemoteMuxInstallCommandsTests`, `RemoteMuxCommandTests`, `RemoteMuxInstallerTests`, `NewSshConnectionViewLayoutTests`). The Docker E2E (`RemoteMuxDockerE2eTests`, Category=DockerE2E) covers a real install in CI; Task 9 makes sure it runs on this PR. +- [ ] **Step 5: Commit** `fix(mux): install ntilde-mux under $XDG_DATA_HOME like the daemon root`. + +### Task 7: CI runs the remote-persistence E2E when its tests change + +`aot_gate_detect` (`.github/workflows/ci.yml:~1530-1595`) decides whether `mux_daemon_aot` runs on a PR with `grep -qE '^(src/|Directory\.(Build|Packages)\.props$|global\.json$|\.github/workflows/[^/]+\.ya?ml$|scripts/mux-daemon-smoke\.sh$)'`. A PR that changes only `tests/Ntilde.App.Tests/Shell/Mux/Remote/RemoteMuxDockerE2eTests.cs`, `tests/Ntilde.Platform.Tests/Ssh/DockerSshFixture.cs` or `tests/Ntilde.ExternalSuites/NativeSsh/` gets no binary, and `native_ssh_docker_e2e` skips the remote-persistence step with a notice and stays green. + +**Files:** +- Create: `scripts/ci/aot-gate-paths.sh` — reads changed paths on stdin, prints `run=true|false`; the regex adds directory-scoped entries `^tests/Ntilde\.App\.Tests/Shell/Mux/|^tests/Ntilde\.Mux\.Tests/|^tests/Ntilde\.Platform\.Tests/Ssh/|^tests/Ntilde\.ExternalSuites/NativeSsh/` (scope by directory, not file spellings — repo rule "guards parse, don't match spellings") +- Modify: `.github/workflows/ci.yml` `aot_gate_detect` to call it +- Test: `scripts/tests/test_aot_gate_paths.sh` (or a Python test next to `scripts/tests/check_app_tests_baseline_tests.py`, matching what that folder uses) — feeds path lists and asserts the output; wire it into the CI job that runs `scripts/tests` (find it: `grep -n "scripts/tests" .github/workflows/ci.yml`) + +- [ ] **Step 1: Failing test**: `tests/Ntilde.App.Tests/Shell/Mux/Remote/RemoteMuxDockerE2eTests.cs` alone → expect `run=true`; `docs/x.md` alone → `run=false`; `src/Ntilde.Mux/MuxClient.cs` → true; `tests/Ntilde.VT.Tests/x.cs` → false. Red: the script does not exist; then with today's regex the first row is false. +- [ ] **Step 2: Implement; Step 3: Green** (run the test script locally under Git Bash: `bash scripts/tests/test_aot_gate_paths.sh`). +- [ ] **Step 4: Commit** `ci: build ntilde-mux for the E2E when its tests change`. + +### Task 8: A stable `SSH_AUTH_SOCK` for daemon-spawned remote shells + +`ntilde-mux serve` (spawned on demand by the first `proxy --stdio`, `src/Ntilde.Mux/Daemon/MuxDaemonSpawner.cs:102-122`) inherits that proxy's environment, including `SSH_AUTH_SOCK=/tmp/ssh-XXXX/agent.N` when that connection forwarded an agent. Every shell it spawns later inherits the same path, which is dead once that SSH connection closes. Fix (tmux's pattern): the standalone daemon sets `SSH_AUTH_SOCK=/agent.sock` for its shells, and every `proxy --stdio` repoints that symlink to its own `SSH_AUTH_SOCK` before pumping. + +**Files:** +- Create: `src/Ntilde.Mux/Daemon/AgentSocketLink.cs`: + ```csharp + /// The stable agent socket path a remote daemon's shells use, and its repointing (Unix only). + public static class AgentSocketLink + { + public const string FileName = "agent.sock"; + public static string PathFor(string endpointDirectory) => Path.Combine(endpointDirectory, FileName); + /// Points the link at target when target is an existing socket owned by this user; atomic (symlink to tmp + rename). Returns false (and changes nothing) otherwise. + public static bool TryRepoint(string linkPath, string? target); + } + ``` +- Modify: `src/Ntilde.Mux/Cli/MuxProxyCommand.cs` (`Run`, after `connectDaemon` succeeds: `AgentSocketLink.TryRepoint(link, Environment.GetEnvironmentVariable("SSH_AUTH_SOCK"))` — the endpoint directory comes from the descriptor/`MuxPaths`), standalone serve host (`MuxServeHost` when `MuxPaths.IsStandalone`, or `LocalShellSessionFactory`): add `SSH_AUTH_SOCK=` to every spawned shell's environment overrides — only when the daemon was started with an `SSH_AUTH_SOCK` or the link exists, so hosts without agent forwarding see no change +- Test: `tests/Ntilde.Mux.Tests/Daemon/AgentSocketLinkTests.cs` (Unix-only, `Assert.SkipWhen(OperatingSystem.IsWindows(), …)`), `tests/Ntilde.Mux.Tests/Cli/MuxProxyTests.cs` (add), `tests/Ntilde.Mux.Tests/Daemon/LocalShellSessionFactoryTests.cs` (add) + +- [ ] **Step 1: Failing tests.** `TryRepoint` with two real Unix sockets `/tmp/x/a.sock`, `/tmp/x/b.sock` (bind a `Socket(AddressFamily.Unix…)`): repoint to a → `readlink == a`; to b → `== b`; to a regular file → false, link unchanged; to null → false. Proxy test: run the in-process proxy with env `SSH_AUTH_SOCK=` (inject an environment reader seam if `Run` reads `Environment` directly) → link points at a. Factory test: a spawn's overrides contain `SSH_AUTH_SOCK=/agent.sock` when the daemon's own environment had `SSH_AUTH_SOCK`. +- [ ] **Step 2: Red** (on Windows these skip: prove red in WSL — `wsl.exe` has no dotnet here, so rely on CI ubuntu; note this in the report). **Step 3: Implement. Step 4: Green (Mux.Tests).** +- [ ] **Step 5: Commit** `fix(mux): give remote shells an agent socket link the proxy keeps current`. + +### Task 9: The native exec poll waits instead of sleeping + +`NativeSshExecTransport`'s `PollLoop` (`src/Ntilde.Platform/Ssh/Exec/NativeSshExecTransport.cs:371-389`) sleeps `PollDelay = 10 ms` when `PollEvent` returns null (the ~15 ms attach floor in spec §14). `nova_ssh_poll_event` (`src/Ntilde.App/native/rusty_ssh/src/lib.rs:1275-1300`) is non-blocking; `SharedState` (`:278-302`) has `events: Mutex>` and a `response_cv: Condvar` pattern (`wait_for_response` `:1018-1034`), but no event-side wakeup. + +**Files:** +- Modify: `src/Ntilde.App/native/rusty_ssh/src/lib.rs` — add `events_cv: Condvar` to `SharedState`; `notify_all` in `queue_event` (`:~892-901`, after the push) and in `mark_closed` (`:~1036-1043`); new export: + ```rust + /// Waits up to timeout_ms for an event to be queued (or the session to close). Returns + /// NOVA_SSH_OK when one is ready, NOVA_SSH_TIMEOUT when none came, NOVA_SSH_CLOSED when closed. + #[no_mangle] + pub extern "C" fn nova_ssh_wait_event(handle: *mut NovaSshHandle, timeout_ms: u32) -> i32 + ``` + (reuse the existing status-code constants; if there is no TIMEOUT code, add one at the end of the enum — append-only.) Clamp `timeout_ms` to 1000. +- Modify: `src/Ntilde.Platform/Ssh/Native/INativeSshInterop.cs` — `bool WaitForEvent(NovaSshSafeHandle handle, TimeSpan timeout) { Thread.Sleep(timeout > TimeSpan.FromMilliseconds(10) ? TimeSpan.FromMilliseconds(10) : timeout); return true; }` as a **default interface method** (keeps every test fake compiling and behaving as today); `NativeSshInterop` overrides it with the P/Invoke. +- Modify: `NativeSshExecTransport.PollLoop`: `if (next is null) { _interop.WaitForEvent(handle, TimeSpan.FromMilliseconds(100)); continue; }` (bounded so `_stop` is observed). +- Test: Rust `mod event_wait_tests` next to `mod event_queue_budget_tests` (`lib.rs:~6478`): wait returns OK promptly after `queue_event` from another thread; TIMEOUT when empty (≥ timeout elapsed); CLOSED after `mark_closed`. C#: `tests/Ntilde.Platform.Tests/Ssh/Exec/NativeSshExecTransportTests.cs` — a scripted interop counting `PollEvent` calls over 500 ms idle with a `WaitForEvent` that blocks until an event is scripted: assert ≤ 10 calls (today ~50) and that an event scripted mid-wait is delivered within 50 ms. +- [ ] **Step 1: Failing tests** (C# first: the count assertion fails with the sleep loop). **Step 2: Red** (`cargo test --manifest-path src/Ntilde.App/native/rusty_ssh/Cargo.toml event_wait` fails to compile → red). **Step 3: Implement. Step 4: Green**: `cargo test` (whole crate), Platform.Tests, Mux.Tests; then rebuild App (`scripts/build.ps1 build src/Ntilde.App` builds the crate). **Step 5: Commit** `perf(ssh): wait for native exec events instead of sleeping 10 ms`. + +### Task 10: Sign and notarize `ntilde-mux` on macOS; pin its SHA-256 in the App + +`publish_mux_daemon` (`.github/workflows/release.yml:~1910-2124`) builds `ntilde-mux-osx-arm64` with only the linker's ad-hoc signature and writes `ntilde-mux-.sha256` next to each asset. The App (`src/Ntilde.App/Shell/Mux/Remote/GitHubReleaseMuxAssetSource.cs:79-127`) trusts whatever `.sha256` sits next to the binary. The app itself is signed and notarized inside `vpk pack` using `./.github/actions/setup-mac-signing` and the `MAC_*` secrets (`release.yml:~563-597`, `:~1116-1136`). + +**Files:** +- Modify: `.github/workflows/release.yml`: + - `publish_mux_daemon`: copy `publish_aot`'s `MAC_SIGNING_ENABLED` env gate and the two-checkout + `setup-mac-signing` steps (osx-arm64 only, `if: env.MAC_SIGNING_ENABLED == 'true'`). After the "Assert" step and **before** "Stage": `codesign --force --timestamp --options runtime --keychain "$KEYCHAIN" --sign "$MAC_SIGN_APP_IDENTITY" "$bin"`; `codesign --verify --strict --verbose=2 "$bin"`; `ditto -c -k --keepParent "$bin" mux.zip && xcrun notarytool submit mux.zip --keychain-profile "$MAC_NOTARY_PROFILE" --keychain "$KEYCHAIN" --wait`; re-run `"$bin" --version --json`. Raise the job's `timeout-minutes` to 90. When signing is disabled (forks), log `::notice::ntilde-mux is not signed (no signing secrets)`. + - `publish_mux_daemon`: `actions/upload-artifact` `mux-sha256-` with the `.sha256` file. + - `publish_aot` (already `needs: publish_mux_daemon`) and `publish_linux` (add `publish_mux_daemon` to `needs`): `actions/download-artifact` `pattern: mux-sha256-*`, `merge-multiple: true`, into `$RUNNER_TEMP/mux-sha256`, and pass `-p:NtildeMuxSha256Dir=$RUNNER_TEMP/mux-sha256` to the App publish. +- Modify: `src/Ntilde.App/Ntilde.App.csproj`: `` (pattern: the existing `vt-conformance-report.json` resource, `:~97-99`). +- Create: `src/Ntilde.App/Shell/Mux/Remote/MuxAssetPins.cs` — `internal static IReadOnlyDictionary Load()` reads every `Ntilde.Resources.mux-sha256.ntilde-mux-.sha256` resource with `MuxDaemonAsset.TryParseChecksum` → `rid → hex`; empty in dev builds. +- Modify: `GitHubReleaseMuxAssetSource` — optional ctor parameter `IReadOnlyDictionary? pins` (production passes `MuxAssetPins.Load()`): with a pin for the rid, the expected hash is the pin; the downloaded `.sha256` must equal it (else `InvalidDataException("The release's checksum does not match the one built into this app.")`); a cache hit is re-verified against the pin. No pin: today's behaviour. +- Docs: `docs/USER_MANUAL.md` (the remote install section) — one sentence: the macOS `ntilde-mux` is signed and notarized; a copy dragged in through Finder from a browser download still gets Gatekeeper's prompt the first time (that is the quarantine attribute, not the binary). +- Test: `tests/Ntilde.App.Tests/Shell/Mux/Remote/MuxAssetSourceTests.cs` (add: pinned hash ≠ consistent served pair → throws; pinned hash = served → ok; cached copy with a different hash than the pin → re-downloads or throws per the code path; no pins → today's behaviour); `tests/Ntilde.App.Tests/Shell/Mux/Remote/MuxAssetPinsTests.cs` (parses ` ntilde-mux-linux-x64` lines; ignores malformed). +- [ ] **Step 1: Failing tests** (C#). **Step 2: Red. Step 3: Implement code + workflow. Step 4: Green** (`MuxAssetSourceTests`, `MuxAssetPinsTests`); validate the workflow YAML with `python -c "import yaml,sys; yaml.safe_load(open('.github/workflows/release.yml'))"` and, if `actionlint` is available, run it. The signing steps cannot run locally: say so in the report; the PR's reviewer checks them by reading. +- [ ] **Step 5: Commit** `ci(release): sign and notarize ntilde-mux on macOS and pin its hashes in the app`. + +--- + +## Part B — §3 agent host sees windowless sessions + +Rulings R4 (what "windowless" means; ids are mux session ids; `CurrentClient` only) and R5 (windowless reads are journaled; windowless act on a remote endpoint needs the profile's agent allowlist for `send_input` and `close_session`). + +### Task 11: `readScreen` on the daemon and public client calls + +**Files:** +- Modify: `src/Ntilde.Mux.Contracts/MuxProtocol.cs` (`MuxMethods.ReadScreen = "readScreen"`), `src/Ntilde.Mux.Contracts/MuxMessages.cs` (new DTOs), `src/Ntilde.Mux.Contracts/MuxJsonContext.cs` (register them) +- Modify: `src/Ntilde.Mux/MuxServerConnection.cs` (`HandleRequest` case), `src/Ntilde.Mux/HeadlessTerminalSession.cs` (`PostReadScreen`, `LastOutputUnixMs`) +- Modify: `src/Ntilde.Mux/MuxClient.cs` (public `ReadScreenAsync`, `GetSessionInfoAsync`, `SendInputTo`) +- Test: `tests/Ntilde.Mux.Tests/Server/MuxServerReadScreenTests.cs` (new), `tests/Ntilde.Mux.Tests/Client/MuxClientReadScreenTests.cs` (new), `tests/Ntilde.Mux.Tests/Contracts/MuxJsonTests.cs` (wire shape) + +**Interfaces (Produces):** +```csharp +// Ntilde.Mux.Contracts (leaf: no VT types; the snapshot travels as TerminalStateSerializer bytes, base64 in JSON) +public sealed class ReadScreenParams { public Guid SessionId { get; set; } public int MaxScrollbackRows { get; set; } } +public sealed class ReadScreenResult +{ + public byte[] Snapshot { get; set; } = []; // TerminalStateSerializer.ToBytes(CaptureSnapshot(rows)) + public bool Running { get; set; } + public int? ExitCode { get; set; } + public bool HasActiveChildProcesses { get; set; } + public int AttachedClients { get; set; } + public int? InteractiveClients { get; set; } + public string? Title { get; set; } + public string? Cwd { get; set; } + public long? LastOutputUnixMs { get; set; } // null: no output yet +} +public static class MuxReadScreenLimits { public const int MaxScrollbackRows = 2000; public const int MaxSnapshotBytes = 4 * 1024 * 1024; } + +// Ntilde.Mux +public sealed record MuxScreenRead(TerminalStateSnapshot Snapshot, ReadScreenResult Status); +public Task MuxClient.ReadScreenAsync(Guid sessionId, int maxScrollbackRows, CancellationToken ct); // null = daemon does not know readScreen +public Task MuxClient.GetSessionInfoAsync(Guid sessionId, CancellationToken ct); +public void MuxClient.SendInputTo(Guid sessionId, string text); // unattached input; non-blocking (Task 1) +``` +Daemon rules: +- `MaxScrollbackRows` is clamped to `[0, MuxReadScreenLimits.MaxScrollbackRows]`, never `protocol_error`, so "protocol_error means unsupported" stays unambiguous. +- The work is posted to the session's parse thread like `PostAttach` (`HeadlessTerminalSession.cs:396-406`, `ExecuteAttachCore` `:749-850`), which calls `CaptureSnapshot` there. +- Error codes: unknown id gives `unknown_session`; a faulted session gives `internal_error`; a dropped work item (session stopped) gives `session_exited`. +- A serialized snapshot larger than `MaxSnapshotBytes` gives `snapshot_too_large`. The reply therefore never risks the 16 MB `ClientSendBudgetBytes` abort (`MuxServerConnection.cs:109-135`). +- An exited but unreaped session answers with its last screen and `Running = false`. +- `LastOutputUnixMs` is a `Volatile.Write` of `DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()` once per parsed output batch on the parse thread, not per byte. + +Client rules: +- A `MuxProtocolException` with code `protocol_error` from `readScreen` returns null ("unsupported"), on any negotiated version. +- Decode with the same `MuxAttachLimits` checks `DecodeSnapshot` uses (`MuxClient.cs:478-563`); make that decode helper reusable. + +- [ ] **Step 1: Failing tests.** + - Server (`MuxServerReadScreenTests`, `MuxTestHost` + `RawMuxConnection`, pattern `MuxServerRequestTests.cs:107-130`): + - Spawn a scripted session that wrote `"hello"`. `readScreen{sessionId, maxScrollbackRows: 0}` decodes (`TerminalStateSerializer.FromBytes`) to a snapshot whose main-screen row 0 starts with `hello`, with `Running == true` and `LastOutputUnixMs` within 10 s of now. + - `maxScrollbackRows: -5` is treated as 0. + - `1_000_000` is clamped: after writing 3000 lines, the snapshot has at most 2000 scrollback rows. + - An unknown id gives error `unknown_session`. + - An oversized snapshot (lower the cap through an internal `MuxServerOptions.MaxReadScreenBytes` used by the test) gives `snapshot_too_large`, and the connection stays usable (`ping` afterwards). + - Client (`MuxClientReadScreenTests`): + - Against `MuxTestHost`, `ReadScreenAsync` returns a snapshot equal (`TerminalStateAssert`) to an attach snapshot of the same session at the same point. + - Against `FakeMuxServerEnd` replying `protocol_error` to `readScreen`, it returns null. + - `SendInputTo` reaches a session the connection never attached (the scripted session records the input). + - Wire (`MuxJsonTests`): `ReadScreenParams` serializes as `{"sessionId":"…","maxScrollbackRows":0}`, and the `MuxProtocol` range is still 1..2 (`The_protocol_range_is_1_to_2` unchanged). +- [ ] **Step 2: Red** (the method is unknown, so `protocol_error`; the client methods are missing, so compile errors). +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Green.** Run the whole Mux.Tests project. Also build `src/Ntilde.Mux.Daemon` (NativeAOT-relevant source-gen registration) and run `scripts/build.ps1 test tests/Ntilde.Architecture.Tests`. +- [ ] **Step 5: Commit** `feat(mux): readScreen, and public session info and unattached input on MuxClient`. + +### Task 12: The window's windowless-session source + +**Files:** +- Create: `src/Ntilde.App/AgentHost/IWindowlessSessionSource.cs` +- Create: `src/Ntilde.App/Shell/Mux/MuxWindowlessSessions.cs`: the implementation over `MuxConnectionHosts`. It is UI-free except for a `Func>> shownHere` delegate the window supplies. +- Modify: `src/Ntilde.App/MainWindow.axaml.cs`: + - Build one `MuxWindowlessSessions` when `_muxHosts` exists. + - Publish it to `AgentHostService` (`SetWindowlessSource`) where `SetActionExecutor` is published (ctor `~:3874-3880`, `ApplyAgentHostSettingsLive` `~:6922-6932`), and clear it on teardown. + - `shownHere` is `Dispatcher.UIThread.InvokeAsync(...)`. It collects `(MuxEndpointId.Parse(p.MuxEndpoint).ToString(), id)` for every pane's `Session is MuxClientSession m` (`m.Id`) and `MuxSessionIdToRestore`. This is the `OfferMuxSessionsAsync` logic (`~:5283-5291`), applied to every endpoint. +- Test: `tests/Ntilde.App.Tests/Shell/Mux/MuxWindowlessSessionsTests.cs` (new) + +**Interfaces (Produces):** +```csharp +namespace Ntilde.AgentHost; +internal sealed record WindowlessSessionInfo(Guid SessionId, string Endpoint, Guid? SshProfileId, string HostDisplayName, + string Title, int Cols, int Rows, bool Running, int? ExitCode); +internal enum WindowlessOutcome { Ok, NotFound, Unsupported, Unreachable, NotRunning } +internal sealed record WindowlessScreen(WindowlessOutcome Outcome, WindowlessSessionInfo? Session, TerminalStateSnapshot? Snapshot, ReadScreenResult? Status); +internal interface IWindowlessSessionSource +{ + Task> ListAsync(CancellationToken ct); + Task ReadScreenAsync(Guid sessionId, int maxScrollbackRows, CancellationToken ct); + Task SendInputAsync(Guid sessionId, string text, CancellationToken ct); + Task KillAsync(Guid sessionId, CancellationToken ct); + /// The endpoint's SSH profile id for an act check; null for local or unknown. + Task<(WindowlessOutcome Outcome, Guid? SshProfileId)> ResolveAsync(Guid sessionId, CancellationToken ct); +} +``` +Rules: +- Only hosts in `_muxHosts.All` whose `CurrentClient` is non-null are asked: never `GetClient`, never `GetOrCreate`. +- Each daemon call has its own 4 s timeout (the MCP round trip is 10 s), and hosts are asked in parallel. +- A host that fails or times out contributes nothing to `ListAsync` (logged once per call), and makes a lookup on it `Unreachable`. +- Sessions in `shownHere` are excluded, and so is a `Faulted` summary. +- An id found on two endpoints is ambiguous: `NotFound`, logged. +- When `ReadScreenAsync` gets null from the client, it falls back to `GetSessionInfoAsync` for status and reports `Unsupported` for the snapshot. +- `SendInputAsync` / `KillAsync` first check `Running` via `listSessions` (`NotFound` / `NotRunning`), then call `SendInputTo` / `KillAsync`. +- `HostDisplayName` is "this computer" for local, else `host.Policy.DisplayName`. + +- [ ] **Step 1: Failing tests.** Use `MuxTestHost` daemons wired as a local host and a fake remote host (pattern `MainWindowMuxRemoteTests.cs:~92`), with a `shownHere` stub. Cases: + - It lists only sessions not shown, and excludes faulted sessions. + - A host whose `CurrentClient` is null is skipped, **and** a host whose `GetClient` throws is never called. Use a `MuxConnectionHost` test double that records `GetClient` calls, or assert the connect factory is never invoked a second time. + - A host that hangs (a `FakeMuxServerEnd` that never answers `listSessions`) costs at most 4.5 s, and the other host's sessions are still listed. + - A read on a v1-style fake (`protocol_error`) gives `Unsupported`, with `Status` from `sessionInfo`. + - Input to an exited session gives `NotRunning`. + - After a kill, the daemon's session list no longer has the session. + - An ambiguous id gives `NotFound`. +- [ ] **Step 2: Red.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Green:** the new test class, plus `MainWindowMux*Tests`. +- [ ] **Step 5: Commit** `feat(agent): a window-owned source of windowless mux sessions`. + +### Task 13: Agent host tools on windowless sessions + +**Files:** +- Modify: `src/Ntilde.AgentHost.Contracts/SessionContracts.cs`: `SessionInfo` gets `public bool? Windowless { get; set; }` and `public string? Endpoint { get; set; }`. Both are null for panes, so pane JSON is unchanged under `WhenWritingNull`. +- Modify: `src/Ntilde.AgentHost.Contracts/AgentHostProtocol.cs`: append `ErrorCodes.Unsupported = "unsupported"` ("the daemon that holds this session is too old for this request"). +- Modify: `src/Ntilde.App/AgentHost/AgentHostService.cs`: + - `SetWindowlessSource(IWindowlessSessionSource?)`, cleared in `StopLocked` like the executor; + - the handlers below; + - a windowless-read decay flag for the window light. +- Modify: `src/Ntilde.App/MainWindow.axaml.cs`: + - `RefreshAgentObserveIndicator` / `ComputeObserveIndicatorState` take the service's `WindowlessWatched` as a third input; + - the "Agent Activity" dialog copy "Actions taken…" becomes "Actions and windowless reads…". +- Test: `tests/Ntilde.App.Tests/AgentHost/AgentHostWindowlessProtocolTests.cs` (new; `HandleRequestLineAsync` pattern, `AgentHostActProtocolTests.cs:20-50`). +- Test: `AgentHostWindowlessCaptureTests.cs` (new; `[Collection("GoldenPng")]`, `[Trait("Lane","PlatformBoot")]`, `SnapshotService.EnsureAvaloniaInitialized()` as in `AgentHostCaptureProtocolTests.cs:31-58`). + +Handler rules. The registry lookup comes first; only if the pane id is not in the registry *and* a windowless source is set does the windowless path run. +- `listSessions`: + - Panes as today. Then `await source.ListAsync(ct)`, appended as `SessionInfo { PaneId = SessionId, Title, ProfileName = HostDisplayName, Kind = SshProfileId is null ? "local" : "ssh", Rows, Cols, IsActive = false, Windowless = true, Endpoint, Status = Running ? null : "exited" }`. + - Journal `Record("listSessions", null, "windowless", "ok")`, only when the windowless list is non-empty. +- `readScreen`: + - Call `source.ReadScreenAsync(id, 0)`. Build `new TerminalBuffer(snapshot.Cols, snapshot.Rows)`, `ImportState(snapshot)`, then run the same `BufferSnapshot.Capture` + DTO code as panes. Extract the pane path's DTO building into a helper that takes a buffer. + - `Unsupported` maps to `ErrorCodes.Unsupported`; `NotFound` / `Unreachable` map to `SessionNotFound`. + - Journaled (R5). Sets the windowless-watched flag. +- `readScrollback`: + - Call `source.ReadScreenAsync(id, MuxReadScreenLimits.MaxScrollbackRows)`, then page over the imported buffer's scrollback exactly as the pane handler does. + - `totalLines` is what the snapshot holds. Document that windowless scrollback is the newest 2000 rows. +- `getSessionStatus`, built from `ReadScreenResult` (or the `sessionInfo` fallback): + - `status`: `"exited"` when not `Running`; `"running"` when `HasActiveChildProcesses` or `snapshot.IsAltScreenActive`; otherwise `"awaitingInput"`. Use the existing status string constants. + - `confidence = "heuristic"`. + - `lastOutputAtMs` and `statusSinceMs` are both `LastOutputUnixMs ?? 0`. + - `isStalled = false`; thresholds as the pane DTO reports them; `exitCode`. +- `captureScreen`: + - `mode == "live"` gives `CaptureUnavailable` ("a windowless session has no window to capture"). + - `render` takes metrics from the active pane's `RenderParameters` (any registration with `RenderParameters.IsUsable`); with none it gives `CaptureUnavailable` ("no open pane to take font metrics from"). + - Render the imported buffer through `TerminalSnapshotRenderer.Capture` with the same budget and file-writing code as panes. Journaled. +- `sendInput`: + - After the size-cap and `actEnabled` checks, call `ResolveAsync`. + - A remote endpoint (`SshProfileId` non-null) requires `AllowsAgentActOnProfile(profileId)`, else `ProfileNotAllowed`. + - Then `SendInputAsync`: `NotFound` maps to `SessionNotFound`, `NotRunning` to `SessionNotRunning`. Journaled. +- `closeSession`: `actEnabled`, `ResolveAsync`, the allowlist for remote endpoints (R5), then `KillAsync`. Journaled. +- `waitForEvents`: unchanged. Windowless sessions emit no events (documented in Task 14). +- Window light: + - A windowless read sets `_windowlessWatchedUntil = now + AgentAttentionMachine.ReadDecaySeconds`. + - `SweepStatuses` (1 s timer) clears it and raises `ObserveActivityChanged` on each transition. + - `WindowlessWatched` exposes it. + +- [ ] **Step 1: Failing tests,** using a `StubWindowlessSource` in the test project: + - **list:** windowless rows (`windowless: true`) come after panes, and pane JSON has no `windowless` key (assert on the raw response line). + - **readScreen:** + - on a windowless id it returns the snapshot's text; + - on a pane id it never calls the source; + - `Unsupported` maps to `unsupported`. + - **getSessionStatus:** maps running, alt-screen and exited. + - **sendInput:** + - with act off it returns `actDisabled` and the source is never called; + - to a remote windowless session whose profile is not allowlisted it returns `profileNotAllowed`; + - with the allowlist it succeeds and is journaled. + - **closeSession:** the same three cases. + - **journal:** a windowless readScreen appends a journal entry; a pane readScreen does not (the existing `Capture_is_not_journaled_because_it_is_an_observe_tier_read` stays green). + - **window light:** `WindowlessWatched` becomes true after a read and false after the decay. Drive the sweep through the existing timer test hook, or an internal `Sweep(DateTime now)`. + - **observe off:** with the service stopped, the source is cleared and nothing is called. + - **capture (PlatformBoot lane):** + - render mode on a windowless session, with a stub registration supplying `RenderParameters`, returns a PNG whose dimensions match cols × rows × cell size; + - live mode gives `captureUnavailable`. +- [ ] **Step 2: Red.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Green:** the AgentHost test folder in both lanes, plus `MainWindowAgent*` tests if present. +- [ ] **Step 5: Commit** `feat(agent): list, read, status, capture, input and close for windowless sessions`. + +### Task 14: MCP surface and docs for windowless sessions + +**Files:** +- Modify: `src/Ntilde.McpServer/Tools/SessionTools.cs`: + - `FormatSessionList` adds a `windowless` marker: the `kind` cell reads `local (windowless)` / `ssh (windowless)`, with a footnote "windowless: running in the multiplexer with no window here". + - The empty message becomes "no terminal sessions are open". + - The descriptions of `list_sessions`, `read_screen`, `get_session_status`, `send_input`, `close_session` and `capture_screen` each mention windowless sessions in one clause. + - `TryUnwrap` maps `unsupported` like the other codes. +- Modify: `docs/mcp/tools.md` (live-tools tables, ~L41-67) and `src/Ntilde.McpServer/README.md`. +- Modify: `docs/agent-host/DIRECTION.md`: add a "Windowless sessions" paragraph covering what they are, R4/R5, scrollback limited to the newest 2000 rows, no events, and that render capture borrows a pane's font metrics. +- Test: `tests/Ntilde.McpServer.Tests/SessionToolsFormattingTests.cs`: add a list with one pane and one windowless row (both format), and the empty message. +- Test: `AgentHostClientTests.cs`: a fake endpoint returning a windowless `SessionInfo` round-trips `windowless: true`. +- [ ] **Step 1: Failing tests.** +- [ ] **Step 2: Red.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Green:** the whole McpServer.Tests project, with `McpServerStdioE2ETests.ExpectedToolNames` unchanged. +- [ ] **Step 5: Commit** `feat(mcp): show windowless mux sessions to agents`. + +--- + +## Part C — §1 the default + +Rulings R1 (a dialog; the remembered answer lives in a flag file), R2 (quiet restore after a reboot) and R3 (the flip is its own commit; designer and test windows pin Off). + +### Task 15: Name the default, pin test windows to Off, migration tests + +**Files:** +- Modify: `src/Ntilde.App/Shell/TerminalSettings.cs:~103-110`: `public const string DefaultSessionPersistence = SessionPersistenceMode.Off;` and `public string SessionPersistence { get; set; } = DefaultSessionPersistence;`. The flip in Task 19 changes only the constant's value. +- Modify: `src/Ntilde.App/Shell/AppServices.cs:~30-33`: `BuildForDesigner()` returns `new TerminalSettings { SessionPersistence = SessionPersistenceMode.Off }`, with a comment that the designer and every test window built from it must never spawn a daemon (the test host would be spawned as `mux serve`). +- Test: `tests/Ntilde.App.Tests/Core/SessionPersistenceSettingTests.cs` +- [ ] **Step 1: Tests.** These pass today and must keep passing across the flip; that is their purpose. + - `A_settings_file_without_the_key_gets_the_default`: `Deserialize("{}")` gives `TerminalSettings.DefaultSessionPersistence`. + - `An_explicit_off_survives_load_and_save`: `{"SessionPersistence":"Off"}` → load → save → reload gives `"Off"`. + - `An_explicit_keep_survives`: the same for `KeepOnClose`. + - `The_designer_settings_never_persist`: `AppServices.BuildForDesigner().Settings.SessionPersistence == "Off"`. + - `Default_is_off` becomes `Default_is_the_named_default`: `new TerminalSettings().SessionPersistence == TerminalSettings.DefaultSessionPersistence`. + - Add `A_test_window_spawns_no_daemon`, asserting `TestMainWindowFactory.Create().MuxHost is null`. + - Red check: temporarily set the constant to `KeepOnClose` and confirm that `The_designer_settings_never_persist` still passes, and that `A_test_window_spawns_no_daemon` fails once the `BuildForDesigner` pin is also removed. +- [ ] **Step 2: Implement.** +- [ ] **Step 3: Green** (App.Tests main lane). +- [ ] **Step 4: Commit** `refactor(settings): name the session persistence default; designer windows never persist`. + +### Task 16: First-close dialog (R1) + +**Files:** +- Create: `src/Ntilde.App/Shell/Mux/MuxCloseChoiceStore.cs`: + ```csharp + internal enum MuxCloseChoice { Keep, Close } + /// The remembered answer to the first-close dialog: a flag file under the app-data root, not a setting (R1). + internal sealed class MuxCloseChoiceStore(string rootDirectory) + { + public const string FileName = "mux-close-choice"; + public static MuxCloseChoiceStore Default { get; } = new(AppPaths.RootDirectory); + public MuxCloseChoice? Read(); // "keep" / "close" (trimmed, ordinal-ignore-case); anything else or no file -> null + public void Remember(MuxCloseChoice c); // atomic write (tmp + move); IO errors logged, not thrown + public void Forget(); // delete; IO errors logged + } + ``` +- Modify: `src/Ntilde.App/MainWindow.axaml.cs`: + - **Extract** the `kept` expression at `~:9824` into `CountKeptLocalSessions()`. + - **`OnClosing`:** before `PerformAppTeardown()`, ask only when all of these hold: `!_closeConfirmed`, `e.CloseReason != WindowCloseReason.OSShutdown`, `IsMuxPersistenceActive`, and `CountKeptLocalSessions() > 0`. Then: + - a remembered `Keep` → proceed; + - a remembered `Close` → `EndLocalSessionsOnTeardown()`, then proceed; + - no remembered choice → `e.Cancel = true; _ = AskFirstCloseAsync(count);`. + - **`AskFirstCloseAsync`** awaits the seam `ConfirmFirstClose` (`Func>`, default `ShowFirstCloseDialogAsync`, assigned in the ctor next to `ConfirmSharedClose`). Then: + - `Cancel` → nothing; + - `Keep` → `Remember(Keep)` if asked, `_closeConfirmed = true; Close();`; + - `Close` → `Remember(Close)` if asked, `EndLocalSessionsOnTeardown(); _closeConfirmed = true; Close();`. + - **`EndLocalSessionsOnTeardown()`** queues `host.KillWhenConnected(id)` for every local mux pane's live session. Tracked kills are flushed by `MuxConnectionHosts.Dispose`'s `FlushAndClose`, so teardown then detaches nothing that should live. + - **Types:** `internal readonly record struct FirstCloseAnswer(FirstCloseAction Action, bool Remember)` and `internal enum FirstCloseAction { Cancel, Keep, Close }`. + - **`BuildFirstCloseDialog(int count)`**, headless-testable like `BuildSharedCloseDialog` (`~:6535-6560`): + - title "Close Ntilde"; + - heading "Your shells keep running in the background."; + - body "Reopen ntilde to get them back.", plus "({count} shells)" when count > 1; + - a CheckBox "Don't ask again"; + - buttons **Keep running** (`IsDefault`) and **Close them**; Escape or closing the dialog means Cancel; + - a small hint line "Turn this off in Settings → Keep shells running when the window closes." +- Modify: `src/Ntilde.App/SettingsWindow.axaml.cs` save path (`~:3410-3414`): when the saved `SessionPersistence` differs from the loaded one, call `MuxCloseChoiceStore.Default.Forget()`. +- Modify: `docs/CONFIG_STORAGE_CONTRACT.md` inventory (`~:86-100`): add a row for `mux-close-choice` (root; "keep"/"close"; written by the first-close dialog; deleted when the persistence setting changes; safe to delete). +- Test: `tests/Ntilde.App.Tests/Core/MainWindowFirstCloseTests.cs` (new, `[AvaloniaFact]`). The window has `SessionPersistence = "KeepOnClose"`, an injected in-process `MuxTestHost` daemon as in `MainWindowMuxLifecycleTests`, and the store pointed at a temp root via an internal settable `MuxCloseChoiceStore` property on the window. +- Test: `tests/Ntilde.App.Tests/Shell/Mux/MuxCloseChoiceStoreTests.cs` +- [ ] **Step 1: Failing tests.** + - `Closing_with_live_local_shells_asks_once`: one live local mux pane; the seam returns `Cancel`. The window is still open (`IsVisible`), the seam was called once with count 1, and no kill reached the daemon. + - `Keep_running_detaches_and_the_shell_survives`: the seam returns `(Keep, false)`. The window is closed, the daemon still lists the session as running, and there is no flag file. + - `Close_them_ends_the_shells`: `(Close, false)`. The daemon received `kill` for the session before the client disconnected. + - `Dont_ask_again_remembers_the_answer`: + - `(Keep, true)`: the flag file contains `keep`, and a second window over the same daemon with a new live session closes without calling the seam and keeps the session. + - `(Close, true)`: the second close kills without asking. + - `No_question_without_live_local_shells`: with only exited sessions, a remote-only window, or persistence Off, the seam is never called. + - `OS_shutdown_never_asks`: raise closing with `WindowCloseReason.OSShutdown`, through the internal test hook the window already uses for close reasons or by calling `OnClosing` via a test subclass. The seam is not called, and the sessions are detached (kept). + - `Changing_the_setting_forgets_the_choice`: a `SettingsWindow` save with a changed value deletes the file. + - Dialog build test: `BuildFirstCloseDialog(3)` contains the heading text, a CheckBox "Don't ask again", and buttons "Keep running" (`IsDefault`) and "Close them"; pressing Escape completes with `Cancel`. + - Store tests: read / remember / forget round trip; garbage reads as null; an unwritable root does not throw. +- [ ] **Step 2: Red.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Green.** App.Tests main lane, filter `FullyQualifiedName~MainWindowFirstClose|FullyQualifiedName~MuxCloseChoice|FullyQualifiedName~MainWindowMux|FullyQualifiedName~UpdateClosesMux`; then the full lane at the end of Part C. +- [ ] **Step 5: Commit** `feat(mux): ask once whether closing the window keeps the shells running`. + +### Task 17: "Quit and close all shells" + +**Files:** +- Modify: `src/Ntilde.App/MainWindow.axaml.cs`: + - **Palette command** `"Session: Quit and Close All Shells"`, category "General". Give it the id `ShortcutCatalog.QuitAndCloseAllShellsId` if the catalog lists palette-only commands; otherwise no shortcut. Register it inside the `if (IsMuxPersistenceActive)` block in `SetupCommandPalette` (`~:8345`). + - **`internal async Task QuitAndCloseAllShellsAsync()`:** + 1. Count the local daemon's running sessions: `_muxHosts.Local.GetClient(3 s)` off the UI thread, then `ListSessionsAsync`. + 2. Confirm through a seam `ConfirmQuitAndCloseAll` (`Func>`) whose default is `ShowConfirmationDialogAsync("Quit Ntilde", "Close every shell?", "{N} shell(s) running in the background will be closed, including detached ones.", "Quit and close", 140)`. + 3. `EndLocalSessionsOnTeardown()`. + 4. After the kills are flushed, send `shutdown` to the local daemon. Reuse `ShutdownMuxDaemonForUpdateAsync`'s probe-and-shutdown, extracted as `ShutdownLocalDaemonAsync(TimeSpan wait)`. + 5. `_closeConfirmed = true`, then `Close()`. + - **Remote sessions** are not touched. When remote panes exist, the dialog says "Remote shells keep running." +- Modify: `src/Ntilde.App/SettingsWindow.axaml` (`~:709-719`): + - Under the `SessionPersistenceList` row, add a `Button` styled as a link, "Quit and close all shells…", visible only while the main window reports `IsMuxPersistenceActive`. + - Clicking it closes Settings (saving nothing) and invokes the window's `QuitAndCloseAllShellsAsync`, through the owner-callback pattern the Settings window already uses for other main-window actions. Find one with `grep -n "Owner\|MainWindow" src/Ntilde.App/SettingsWindow.axaml.cs | head`. +- Test: `tests/Ntilde.App.Tests/Core/MainWindowQuitAndCloseAllTests.cs` (new), with two live local sessions and one detached session in the daemon: + - confirm=true: all three are killed, `shutdown` is received, and the window is closed; + - confirm=false: nothing happens; + - persistence off: the command is not registered (`CommandRegistry` lookup by title after `SetupCommandPalette()`). +- Test: Settings link (`SettingsWindow` headless): the link is present when the owner reports persistence active, and absent otherwise. +- [ ] **Steps:** failing tests → red → implement → green → commit `feat(mux): Quit and close all shells`. + +### Task 18: Settings row copy for a default-on reader + +**Files:** +- Modify: `src/Ntilde.App/SettingsWindow.axaml:~709-719`: + - The label stays "Keep shells running when the window closes". + - New description: "Your shells run in a background process (the multiplexer), so closing the window or a crash does not end them; Ntilde reattaches them when it starts. Turn this off to end shells when their window closes. SSH tabs keep running on the host only when their connection also turns on 'Keep remote sessions running'. Applies to new tabs." + - ComboBox item text: "Keep running" / "Off". +- Modify: `src/Ntilde.McpServer/Tools/SettingsTools.cs:~62,131`. Only reword the description to match the Settings copy here. The prose and example that name the default value ("Default \"KeepOnClose\"") are written in Task 19's commit, since the default is whatever `TerminalSettings.DefaultSessionPersistence` will be after Task 19. +- Test: `tests/Ntilde.App.Tests/Core/SettingsWindowSessionPersistenceTests.cs` (or the existing settings layout test file): the description TextBlock text mentions "multiplexer" and "Turn this off". The McpServer drift guard still passes. +- [ ] **Steps:** failing test → red → implement → green → commit `docs(settings): describe session persistence for a default-on reader`. + +### Task 19: Quiet restore after a reboot (R2), then the flip (R3) + +**Files:** +- Create: `src/Ntilde.App/Shell/Mux/MuxRestoreExpectations.cs`: + ```csharp + /// Whether this launch should expect the previous session's daemon sessions to be gone: the session + /// file was saved before the machine last booted, so a reboot ended them (R2). + internal static class MuxRestoreExpectations + { + public static DateTime BootTimeUtc(Func utcNow, Func tickCount64) => utcNow() - TimeSpan.FromMilliseconds(tickCount64()); + public static bool SessionsEndedByReboot(DateTime? sessionSavedUtc, DateTime bootTimeUtc) => sessionSavedUtc is { } saved && saved < bootTimeUtc; + } + ``` +- Modify: `src/Ntilde.App/Shell/SessionManager.cs`: expose the loaded file's last-write time (UTC) with the restored session (`SessionRestoreInfo.SavedUtc` or similar), read once at startup restore. +- Modify: `src/Ntilde.App/MainWindow.axaml.cs` startup restore (`~:4116-4134`): when `SessionsEndedByReboot(...)`, set `pane.MuxQuietPreviousLost = true` on every restored mux pane. +- Modify: `src/Ntilde.App/Controls/TerminalPane.axaml.cs`: + - Add `internal bool MuxQuietPreviousLost { get; set; }`. + - On the `PreviousLost` outcome (banner + `MuxPreviousLostNoticeTitle` notice, `~:4237-4252`), skip both when it is set, and log one line `"[TerminalPane] previous session ended by a reboot; started a new shell"`. +- Then, **in its own commit:** + - `TerminalSettings.DefaultSessionPersistence = SessionPersistenceMode.KeepOnClose`; + - update the `SettingsTools.cs` prose and example to `"KeepOnClose"`; + - in `SessionPersistenceSettingTests`, `A_settings_file_without_the_key_gets_the_default` now also asserts `"KeepOnClose"` explicitly; + - `MainWindowMuxSharingTests.Mux_commands_are_not_registered_when_persistence_is_off` sets `"Off"` explicitly instead of relying on the default. +- Test: `tests/Ntilde.App.Tests/Shell/Mux/MuxRestoreExpectationsTests.cs`: the boot math; saved before and after boot; a null saved time gives false. +- Test: `tests/Ntilde.App.Tests/Controls/MuxPaneTests.cs`: add PreviousLost with `MuxQuietPreviousLost` (no notice raised, no banner text in the buffer), and without it (both, as today). +- Test: `tests/Ntilde.App.Tests/Core/MainWindowMuxLifecycleTests.cs`, injecting the clock and boot time through an internal seam on the window. Add: + - a restore whose session file predates boot raises no "previous sessions were lost" toast, while a fresh shell starts in each pane; + - a file saved after boot, with the daemon gone, still toasts. +- [ ] **Step 1: Failing tests** for the quiet restore. +- [ ] **Step 2: Red.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Green.** +- [ ] **Step 5: Commit** `feat(mux): start fresh shells quietly after a reboot`. +- [ ] **Step 6: The flip.** Change the constant, the McpServer prose and example, and the two tests named above. Run the App.Tests main lane, the PlatformBoot lane and McpServer.Tests. Commit `feat(settings): keep local shells running by default`, with the body: "The maintainer decides in the PR; dropping this commit restores Off with every other Phase 5 change intact." + +--- + +## Part D — §2 sessions survive app updates + +**Measured (R9).** The run used Velopack 1.2.0 on Windows 11, a probe app packed with vpk 1.2.0, and a sandbox install. Evidence: `velopack-measurement.md`; it is summarised in spec §R9. +- The Windows apply logs `Checking for running processes in: ` and then hard-kills (TerminateProcess) **every process whose image is anywhere under the install root**. That covers `current\` and any other subfolder. +- A copy of the same binary running from outside the root survived four applies with no heartbeat gap. +- The apply renames `current\` away. A process outside the root whose **working directory** is inside `current\` makes the rename fail ten times, and then the apply is abandoned: "Unable to start the update, because one or more running processes prevented it". +- On macOS and Linux the updaters contain no process-killing code (strings in `UpdateMac` / `UpdateNix`). There, a running daemon keeps its old image (inode) across the bundle or AppImage replacement. + +Today the local daemon runs `%LOCALAPPDATA%\NtildeApp\current\Ntilde.exe mux serve`. Every shell's console host, the sideloaded `current\\OpenConsole.exe` (#310, `Ntilde.App.csproj:~452-512`), also runs from under the root. So without a copy, an update kills the daemon *and* every shell. Decision: on a Windows Velopack install, the daemon runs from a copy at `\bin\\`. + +### Task 20: The daemon's build version on the wire + +Neither the endpoint descriptor nor `hello` carries an app version (`MuxEndpointDescriptor`, `src/Ntilde.Mux.Contracts/MuxMessages.cs:~258-276`; `WelcomeResult` `:~51-55`). The new GUI must know when the daemon is "from the previous build". + +**Files:** +- Modify: `src/Ntilde.Mux.Contracts/MuxMessages.cs`: + - `MuxEndpointDescriptor.AppVersion` (`string?`, `[JsonIgnore(Condition = WhenWritingNull)]`); + - `WelcomeResult.ServerVersion` (`string?`, same attribute). +- Modify: `src/Ntilde.Mux/MuxServerOptions.cs` (`public string? AppVersion { get; init; }`), `src/Ntilde.Mux/MuxDaemonHost.cs` (`CreateDescriptor` writes it), and `src/Ntilde.Mux/MuxServerConnection.cs` (`HandleHello` replies with it). +- Modify: `src/Ntilde.Mux/MuxClient.cs`: `public string? ServerVersion { get; }`, set from the welcome. +- Modify: the App serve path (`MuxCommand`/`MuxCliHost` → `MuxServeHost`) passes `AppVersionInfo` (`src/Ntilde.App/Shell/AppVersionInfo.cs`); the standalone `ntilde-mux` passes `MuxVersionInfo.Version`. +- Test: `tests/Ntilde.Mux.Tests/Contracts/MuxJsonTests.cs`: + - a descriptor or welcome without a version serializes exactly as today (no key); + - with one, it serializes as `appVersion` / `serverVersion`; + - an old JSON without the key deserializes to null. +- Test: `tests/Ntilde.Mux.Tests/Client/…`: `MuxClient.ServerVersion` is the server's `AppVersion` against `MuxTestHost` with `AppVersion = "9.9.9"`, and null against a v1 server built without it. +- [ ] **Steps:** failing tests → red → implement → green (Mux.Tests, Architecture.Tests; build `src/Ntilde.Mux.Daemon`) → commit `feat(mux): the daemon reports its build version`. + +### Task 21: On a Windows install, run the daemon from a copy outside the install root + +**Files:** +- Create: `src/Ntilde.App/Shell/Mux/MuxDaemonImage.cs`: + ```csharp + /// Where the local daemon's executable runs from (R9). On a Windows Velopack install, Velopack's apply + /// kills every process whose image is under the install root, so the daemon runs from a copy at + /// \bin\\ - Ntilde.exe, the DLLs beside it (rusty_pty.dll, conpty.dll, ...) and the + /// \OpenConsole.exe hosts conpty.dll starts - staged once per version. Elsewhere, and for dev + /// builds, the running executable itself. + internal static class MuxDaemonImage + { + /// The install root when exePath is \current\ and \Update.exe exists; else null. + public static string? VelopackInstallRoot(string exePath, Func fileExists); + /// Stages the copy if needed and returns its executable; returns exePath when no copy is needed + /// or staging failed (logged: the daemon then dies with the next update, as before Phase 5). + public static string Resolve(string exePath, string appDataRoot, string version, IMuxImageFileSystem fs, Action log); + /// Deletes \bin\ folders other than the current version's; a folder in use (a running + /// older daemon) fails to delete and is kept. Best effort, off the UI thread. + public static void PruneOldCopies(string appDataRoot, string currentVersion, IMuxImageFileSystem fs, Action log); + } + ``` + Staging rules: + - Copy into `\bin\...tmp\`: + - the executable; + - every `*.dll` in its directory; + - every `\OpenConsole.exe` that exists (`arm64`, `x64`, `x86`). + - Write a `.complete` file listing the copied files and their sizes, then `Directory.Move` to `\bin\\`. + - A destination that exists with a valid `.complete` is reused (the sizes must match the current install's files; a mismatch means a re-pack under the same version, so stage again under `-`). + - A concurrent stager losing the rename race reuses the winner's folder. + - `IMuxImageFileSystem` is a thin seam over `File`/`Directory`, for tests. +- Modify: `src/Ntilde.Mux/Daemon/MuxDaemonSpawner.cs`: `ProcessMuxDaemonSpawner` takes an optional `Func? imageResolver` (exe path in, exe path out). `Ntilde.Mux` gains no App reference: the App passes `p => MuxDaemonImage.Resolve(p, AppPaths.RootDirectory, AppVersionInfo.Version, …)` through `MuxDaemonLauncher.CreateDefault` / `MuxConnectionHost.CreateDefault` / `MuxCommand`'s `MuxCliHost`, so the GUI, `ntilde mux attach` and `ntilde.com` all spawn the same image. +- The daemon's working directory stays the user profile (`GetDaemonWorkingDirectory`). Add a test that it is never under the install root even when the profile directory is unavailable: fall back to the app-data root, never the exe's directory. +- Pruning runs once per GUI launch, after the local host connected, on the thread pool. +- `docs/CONFIG_STORAGE_CONTRACT.md`: add an inventory row for `bin\\` (the Windows daemon image copies, safe to delete when no daemon runs). +- Test: `tests/Ntilde.App.Tests/Shell/Mux/MuxDaemonImageTests.cs`, with a fake file system: + - not a Velopack layout → `exePath` unchanged and no copy; + - a Velopack layout → files copied, `.complete` written last, the returned path is `\bin\\Ntilde.exe`; + - a second call reuses the copy without copying; + - a partial copy without `.complete` is replaced; + - a copy failure returns `exePath` and logs; + - pruning deletes other versions and keeps the current one and one that throws on delete. +- Test: `tests/Ntilde.Mux.Tests/Daemon/ProcessMuxDaemonSpawnerTests.cs`: the resolver's result is the `FileName` of the start info. +- [ ] **Steps:** failing tests → red → implement → green → commit `feat(mux): on a Windows install the daemon runs from its own copy`. + +### Task 22: Applying an update keeps a compatible daemon + +Today `ApplyStagedUpdateAsync` (`MainWindow.axaml.cs:~10378-10444`) works like this: +- It probes the daemon and asks "N multiplexed session(s) will be closed by the update". +- It sends `shutdown`, waits 5 s, tears down, and calls Velopack's `ApplyUpdatesAndRestart`. +- `Program.ShouldAutoApplyUpdateOnStartup` (`Program.cs:~47-52`, `:155-171`) vetoes Velopack's startup auto-apply whenever a daemon answers a 200 ms probe. + +The new build's protocol range is unknown to the old GUI. Ruling: the release puts it in the Velopack release notes as a marker line, and the old GUI reads it from the staged update. A missing marker means "compatible": Min has been 1 since Phase 0, and the new GUI still handles a mismatch at launch (Task 23). + +**Files:** +- Modify: `.github/workflows/release.yml`: the release notes file passed to every `vpk pack` (`--releaseNotes`; add one if none is passed) ends with an HTML comment line ``. The values come from the built `ntilde-mux --version --json` (`protocolMin` / `protocolMax`) in the job that has the binary. In jobs without it, read the two constants from `src/Ntilde.Mux.Contracts/MuxProtocol.cs` with a parse step (a tiny `scripts/ci/mux-protocol-range.sh` that greps `MinSupportedVersion = ;` / `MaxSupportedVersion = ;` and fails if either is missing; a test feeds it the real file). +- Modify: `src/Ntilde.App/Update/IUpdateService.cs` / `VelopackUpdateService.cs`: expose the staged release's notes (`UpdateInfo.TargetFullRelease.NotesMarkdown`) as `string? StagedReleaseNotes`. +- Create: `src/Ntilde.App/Update/MuxUpdateCompatibility.cs`: + ```csharp + internal static class MuxUpdateCompatibility + { + /// The range in the notes' marker line, or null when absent or malformed. + public static (int Min, int Max)? ParseProtocolRange(string? releaseNotes); + /// Whether the update keeps the daemon: the ranges overlap (a null new range counts as overlapping) + /// and the daemon's image is outside the install root (a daemon inside it would be killed). + public static bool KeepsDaemon((int Min, int Max) daemon, (int Min, int Max)? newBuild, string? daemonImagePath, string? installRoot); + } + ``` + The daemon's image path comes from the descriptor's `Pid`, via `Process.GetProcessById(pid).MainModule.FileName` (guarded; on failure, treat the daemon as inside the root → not kept, today's path). The install root comes from `MuxDaemonImage.VelopackInstallRoot`. +- Modify: `MainWindow.ApplyStagedUpdateAsync` / `PrepareMuxDaemonForUpdateAsync`: + - `KeepsDaemon` → no confirmation, no `shutdown`; teardown detaches as on any close (sessions kept), then apply. + - Otherwise, today's confirm + `shutdown`, with the message "{N} multiplexed session(s) will be closed by the update (the new version cannot keep them)." Save the session file **before** the shutdown, so it does not name ids that are about to die. Today the save happens after (survey S2-B6). +- Modify: `Program.ShouldAutoApplyUpdateOnStartup`: veto only when a live daemon's image is inside the install root. A daemon running from a copy no longer blocks startup auto-apply. +- Test: `tests/Ntilde.App.Tests/Update/MuxUpdateCompatibilityTests.cs`: + - marker parsing: present, absent, malformed, several markers (first wins), whitespace; + - `KeepsDaemon` truth table: overlap/no overlap/null range × inside/outside root/unknown path. +- Test: `tests/Ntilde.App.Tests/Update/UpdateClosesMuxTests.cs`, extended with the existing seams (`MuxProbeForUpdate`, `ConfirmSessionLossForUpdate`, `MuxReadDescriptorForUpdate`, `FakeApplyUpdateService`), plus new seams for the image path and the staged notes: + - compatible + outside → apply called, no confirm, no shutdown sent; + - incompatible → confirm shown, shutdown sent, session saved before the shutdown (assert order via a recording fake); + - inside the root → today's path. +- Test: `tests/Ntilde.App.Tests/ProgramAutoApplyTests.cs` (or wherever `ShouldAutoApplyUpdateOnStartup` is tested; `grep -rn ShouldAutoApplyUpdateOnStartup tests`). +- Test: `scripts/tests/…` for `mux-protocol-range.sh` against the real `MuxProtocol.cs`. +- [ ] **Steps:** failing tests → red → implement → green → commit `feat(update): keep a compatible multiplexer running across an update`. + +### Task 23: "Multiplexer is from the previous build" and "Restart multiplexer now" + +**Files:** +- Modify: `src/Ntilde.App/Shell/Mux/MuxConnectionHost.cs`: raise `ServerVersionKnown(string? version)` once per connection (or expose `ServerVersion` with a `Connected` event, whichever fits the host's event style). +- Modify: `src/Ntilde.App/MainWindow.axaml.cs`. When the local host connects and `ServerVersion` is non-null and differs from `AppVersionInfo.Version`, enqueue one notice per launch via `EnqueueNotice`: + - title "Multiplexer"; + - message "The multiplexer is from the previous build ({old}); restart it when convenient — this closes its {N} shell(s)."; + - action **Restart multiplexer now** (a `PersistenceNoticeAction`, `src/Ntilde.App/Controls/PersistenceNoticeAction.cs`). + + The action: + 1. confirms with the count (`ShowConfirmationDialogAsync`); + 2. sends `shutdown` to the local daemon and waits up to 5 s; + 3. if it is still alive, kills it by pid, as `kill-server --force` does (`MuxCli.KillByPid` logic; extract a shared helper in `Ntilde.Mux` if needed); + 4. then calls `_muxHosts.Local.WarmUp()`, so a new daemon from this build starts. + + Open panes on the old daemon go through their existing "multiplexer disconnected" path, and Enter reconnects. +- The non-overlap case (`MuxUnavailableException(versionMismatch: true)`, `TerminalPane.axaml.cs:~3735-3743`): the existing hint text stays, and the same notice action is attached, so the user gets a button instead of a CLI command. +- Remote hosts: when a remote host's `ServerVersion` differs from the ntilde-mux version this app installs (`RemoteMuxStatusText` already compares versions; reuse its source), show one notice per host per launch: "ntilde-mux on {host} is from a previous version ({old}); restart it when convenient — this closes its {N} shell(s)", with action **Restart ntilde-mux on {host}**. The action sends `shutdown` over that host's `CurrentClient`; the next connect's proxy starts the new daemon. + - Check the install/update flow (`RemoteMuxInstaller` and the "update ntilde-mux" notice action): it must replace the binary **without** shutting the running daemon down. If it shuts it down today, change it to keep the daemon and rely on this notice (that is the brief's "same rule for remote daemons"), and say so in the report. +- Test: `tests/Ntilde.App.Tests/Core/MainWindowMuxUpdateTests.cs` (new): + - a `MuxTestHost` daemon with `AppVersion = "0.0.1"` gives exactly one notice with the action, even with three panes; + - the same version gives no notice; + - the action with confirm=true sends `shutdown`, then the host spawns or connects again (fake launcher records the spawn); + - confirm=false sends nothing; + - a version-mismatch fallback notice carries the action. +- Test: remote: `MainWindowMuxRemoteTests` with a fake remote daemon reporting an older version gives one notice per host; its action sends `shutdown` to that host only. +- Test: `RemoteMuxInstallerTests`: an update install leaves the running daemon alone (no `kill-server`/`shutdown` command among the executed remote commands). +- [ ] **Steps:** failing tests → red → implement → green → commit `feat(mux): offer to restart a multiplexer from a previous build`. + +### Task 24: Update-survival evidence + +The brief asks for a real Velopack update applied with a live daemon on Windows and on one Unix OS, with the shells intact afterwards, plus the no-overlap path. + +**Files:** +- Modify: `src/Ntilde.App/Update/VelopackUpdateService.cs`: when the environment variable `NTILDE_UPDATE_SOURCE_DIR` names a directory, use `new SimpleFileSource(new DirectoryInfo(dir))` instead of the GitHub source. It is a test and verification hook, logged at startup when active. Document it in `docs/CONFIG_STORAGE_CONTRACT.md` (environment variables) and test it (`VelopackUpdateServiceTests`: the variable set → the source is a `SimpleFileSource` for that directory; unset → GitHub). +- Create: `scripts/mux-update-survival.ps1`. It is self-contained and runs on Windows: + 1. AOT-publish `src/Ntilde.App` as `0.12.0-survival.1` and `.2` (`scripts/build.ps1 publish … -p:Version=…`; local AOT recipe: memory `local-aot-and-linux-runs`, vswhere on PATH, quoted `-p:` args). + 2. `vpk pack` both with packId `NtildeSurvival` (never `NtildeApp`) into a feed directory, with the protocol marker in the notes. + 3. Install `.1` via `Setup.exe --silent --installto \install`. + 4. Start it with `NTILDE_APPDATA_ROOT=\data` and `NTILDE_UPDATE_SOURCE_DIR=`, plus a settings file with `SessionPersistence=KeepOnClose`. + 5. Spawn two sessions through the CLI: `\current\Ntilde.exe mux` with `NTILDE_APPDATA_ROOT` set; `mux ls` shows them. + 6. Start a marker process in each: `mux attach` is interactive, so send input via a tiny client (`ntilde mux` has no `send`). Instead, start a session whose command writes a heartbeat file every second; pass the command through the mux spawn request, extending `ntilde mux` with a hidden `spawn-for-test ` verb only if no existing verb can start a session with a command (check `MuxCli` first). + 7. Trigger the apply: launch the GUI, which finds the staged update, and invoke the palette command through the agent host (`ntilde.spawn_session` is not it; use the MCP client in `tests/Ntilde.McpServer.Tests` as a library, or apply via `Update.exe apply`). Measure whichever is simplest and record which was used. + 8. Verify: + - `current\sq.version` is `.2`; + - the daemon pid is unchanged and its image path is under `\bin\0.12.0-survival.1\`; + - both heartbeat files kept advancing across the apply; + - after restart, `mux ls` lists the same session ids. + 9. No-overlap path: pack `.3` with the marker `ntilde-mux-protocol: 3-3` and apply. Expected: confirm path → daemon shut down → after restart, the new GUI starts a fresh daemon. Record it. + 10. Uninstall the sandbox (`Update.exe --uninstall --silent`), remove the HKCU Uninstall key if left, and print a summary. +- Unix: run the same flow on Linux in Docker if Velopack's Linux AppImage can run there (FUSE: `--device /dev/fuse --cap-add SYS_ADMIN`, or `APPIMAGE_EXTRACT_AND_RUN=1`, which changes how `$APPIMAGE` resolves; note it). Otherwise hand the macOS run to the maintainer with the script's steps written for zsh (`scripts/mux-update-survival.sh`). Report exactly which OS runs were done by whom. +- [ ] **Steps:** + 1. Implement the hook and its test, then commit `feat(update): a local update source for verification runs`. + 2. Write the script and run it on Windows. Save its full output to the task report, to be pasted into the PR. + 3. Commit `test(update): an update-survival run against a sandboxed Velopack install`. + +--- + +## Part E — §5 remote endpoints in the picker and `mux ls` + +### Task 25: "Attach to session…" lists remote hosts + +Today the picker is local-only. `AttachToMuxSessionAsync` (`MainWindow.axaml.cs:~5245`) uses `_muxHosts.Local`. `OfferMuxSessionsAsync` (`~:5274`) builds `openHere` from local panes and creates a *local shell* pane. `MuxSessionPicker.BuildRows` (`src/Ntilde.App/Shell/Mux/MuxSessionPicker.cs:33`) has no endpoint, and the picker seam returns `Guid?`. The factory needs no change for a shared remote attach: `MuxTerminalSessionFactory.CreateOn`'s `AttachShared` branch is endpoint-agnostic (`~:249-269`). + +Caveat: a remote host exists only while a pane needs its endpoint. Detaching the last remote tab releases the host (`ReleaseUnneededRemoteMuxHosts` `~:7396`), so "connected hosts only" would not list the shell you just detached. Ruling: the picker lists connected hosts' sessions **and** one "Connect to …" row for each `PersistRemoteSessions` profile whose host is not connected. Choosing that row connects interactively (the user is waiting, so prompts are fine), lists the host's sessions, and reopens the picker. Afterwards `ScheduleRemoteMuxHostRelease` lets a host built only for the picker go away again. + +**Files:** +- Modify: `src/Ntilde.App/Shell/Mux/MuxSessionPicker.cs`: + - `MuxSessionPickerRow` gains `MuxEndpointId Endpoint` and `string HostDisplayName`. + - New `MuxSessionPickerConnectRow(Guid ProfileId, string HostDisplayName)`. + - `BuildRows(IEnumerable hosts, IReadOnlySet<(MuxEndpointId, Guid)> openHere)` with `record MuxPickerHostListing(MuxEndpointId Endpoint, string HostDisplayName, IReadOnlyList? Sessions, string? Error)`. Rows come grouped: local first, then remotes in `MuxConnectionHosts.All` order. + - `Display` prefixes the host for remote rows: `"[user@host] title — command — cwd …"`. + - An `Error` host yields one disabled informational row: `"[user@host] not reachable: "`. +- Modify: `src/Ntilde.App/MainWindow.axaml.cs`: + - Seam `PickMuxSession` becomes `Func, Task>`, where `MuxPickerItem` is the abstract base of the session row and the connect row. Update `BuildMuxSessionPickerWindow` and its tests to match. + - Listing in `AttachToMuxSessionAsync`: + - local as today; + - for each remote host in `_muxHosts.All` with `CurrentClient is { } c`, `c.ListSessionsAsync` under `host.Policy.RpcTimeout`, in parallel; + - connect rows for `_sshConnectionService.GetConnectionProfiles()` with `PersistRemoteSessions` and no connected host. + - `openHere` and `FocusPaneShowingMuxSession` key on `(MuxEndpointId.Parse(p.MuxEndpoint), id)`. Orphan adoption stays local-only. + - A chosen remote row creates an SSH-profile pane, not a shell pane: + ```csharp + TerminalProfile p = _sshConnectionService.GetConnectionProfile(profileId); // fresh runtime copy, as AddTab does + p.Command = OperatingSystem.IsWindows() ? "ssh.exe" : "ssh"; p.Arguments = ""; + var pane = new TerminalPane(p, _settings, SshDiagnosticsLevel.None) + { MuxSessionIdToRestore = id, MuxAttachSharedToRestore = true, MuxEndpoint = MuxEndpointId.ForSsh(profileId).ToString() }; + AddTabWithPane(pane, title, select: true); + ``` + Only offer endpoints whose profile still has `PersistRemoteSessions`; otherwise the pane would open plain SSH with a pending id (see Task 26). + - A chosen connect row: `_muxHosts.GetOrCreate(MuxEndpointId.ForSsh(id))` → `GetClient(Policy.ConnectTimeout)` off the UI thread → list → reopen the picker with that host's rows → finally `ScheduleRemoteMuxHostRelease(endpoint)`. + - `RemoteDetachedMessage` (`~:6630`) drops "Attach to session… lists local shells only": "Detached — Attach to session… reopens it". +- Test: `tests/Ntilde.App.Tests/Shell/Mux/MuxSessionPickerTests.cs`: grouping and order, the host prefix, the error row, `openHere` keyed by endpoint (the same id on two endpoints is two rows). +- Test: `tests/Ntilde.App.Tests/Core/MainWindowMuxRemoteTests.cs`, using the local plus fake remote host harness at `~:92`. Add: + - the picker receives local and remote rows, and a remote host whose `CurrentClient` is null contributes a connect row, not a listing; + - choosing a remote row opens an SSH-profile pane whose `MuxEndpoint` is `ssh:`, whose session is attached `Shared` to that id on the fake remote daemon, and which marks the tab shared; + - choosing a remote id already shown focuses that pane; + - choosing a connect row calls `GetClient` once, then offers that host's sessions. +- [ ] **Steps:** failing tests → red → implement → green (`MainWindowMux*`, `MuxSessionPicker*`) → commit `feat(mux): Attach to session lists remote hosts`. + +### Task 26: Shared remote tabs keep their share through drops and never kill on close + +From survey §5 A4: +- **Share flag lost on a drop.** The remote drop paths (`TerminalPane.axaml.cs:~4100-4161`: `EnterRemoteReconnecting`, `EnterRemoteWaitingForEnter`, `HandleRemoteMuxDisconnected`, `HandleRemoteAttachFailed`) set `_muxReattachId` but never `_muxReattachShared`, while the local path sets `_muxReattachShared = reattach && _muxSessionIsShare` (`~:4611`). The result: + - a reconnected share comes back owned; + - a share whose session exited or vanished spawns a fresh shell (PreviousLost) instead of taking `ShareEnded`. +- **Unconfirmed kill.** `KillMuxSessionOnClose` (`MainWindow.axaml.cs:~7332`) and `KillPendingRemoteMuxSessionOnClose` (`~:7411`) queue a kill for a remote session even when the link is down or the attach is pending. `DecidePaneCloseAsync` asks "Detach / Close" only for a connected session with interactive others, so closing a share while reconnecting kills another client's shell on the next connect. +- **Lost route.** A profile that lost `PersistRemoteSessions` between pick and spawn opens plain SSH with the share id pending, and its close kills the share. + +**Files:** +- Modify: `src/Ntilde.App/Controls/TerminalPane.axaml.cs`: set `_muxReattachShared = _muxSessionIsShare` wherever the remote paths set `_muxReattachId`. When `MuxAttachSharedToRestore` is set but the request routes plain (not remote), drop the pending id (`MuxSessionIdToRestore = null`) with a log line. +- Modify: `src/Ntilde.App/MainWindow.axaml.cs`, `DecidePaneCloseAsync` / `DisposeControlTree`: a pane that is a share (`MuxSessionIsShare`, or `MuxAttachSharedToRestore` with a pending id) whose sharing is not known right now (link down, reconnecting, or the attach still pending) closes with `PaneDisposition.Detach`. It never queues a kill. The same applies to local shares whose connection is gone (today's behaviour, now explicit). +- Test: `tests/Ntilde.App.Tests/Controls/MuxPaneRemoteTests.cs` (or the remote pane test file in use; `grep -ln "EnterRemoteReconnecting" tests`): + - a shared remote pane dropped then `Reconnected` reattaches with `MuxAttachMode.Shared` and keeps `MuxSessionIsShare`; + - a shared remote pane whose session is gone on reconnect takes the `ShareEnded` path (the pane closes, and no new session is spawned on the fake daemon). +- Test: `tests/Ntilde.App.Tests/Core/MainWindowMuxRemoteTests.cs`: + - closing a shared remote tab while its host is reconnecting sends no `kill` to the daemon after reconnect, and the other client's session still runs; + - closing an owned remote tab while reconnecting still kills (today's behaviour); + - a share pick whose profile lost persistence opens plain SSH and closing it sends no kill. +- [ ] **Steps:** failing tests → red → implement → green → commit `fix(mux): a shared remote tab stays shared and never kills on close`. + +### Task 27: `ntilde mux ls --all` + +`MuxCli` (in `Ntilde.Mux`, no Platform reference) cannot reach SSH, and `ntilde-mux` must stay lean (`LayeringTests.Nothing_ntilde_mux_runs_references_an_OpenSSL_backed_assembly`). The CLI cannot see what a GUI connected. Ruling: `--all` lives in the App's `ntilde mux` adapter and connects on its own, **non-interactively** (keys, agent, saved vault password, an existing ControlMaster; it never prompts), to every profile with `PersistRemoteSessions`, in parallel with a 15 s per-host timeout. Each hello carries its own `ClientInstanceId`, so it never evicts a GUI connection. + +**Files:** +- Modify: `src/Ntilde.App/Shell/Mux/MuxCommand.cs`: intercept `ls` when `--all` is present, before `MuxCli.Execute`: + - print the local listing via `MuxCli` (unchanged), then one block per remote profile; + - text: a `HOST` column (`this computer`, or `user@host`) in front of today's `ID STATE ATTACHED SIZE TITLE`; + - an unreachable host prints `user@host unreachable: `; + - `--json --all` prints `{"endpoints":[{"endpoint":"local","host":"this computer","sessions":[…]},{"endpoint":"ssh:","host":"user@host","error":"…"}]}`. A new `MuxLsAllResult` is registered in a source-gen context in the App (`AppJsonContext` or a new `MuxCommandJsonContext`). + - `ls --json` without `--all` is byte-for-byte unchanged. +- Create: `src/Ntilde.App/Shell/Mux/Remote/RemoteMuxLister.cs`: `internal static Task> ListAsync(SshConnectionService svc, Func connectorFor, TimeSpan perHost, CancellationToken ct)`. Production `connectorFor` builds the connector from `RemoteMuxHostFactory.CreateTransport` and `new RemoteMuxInteractionHandler(user: null, …)`. Call `ConnectAsync(interactive: false, ct)`, then `ListSessionsAsync`, then dispose. +- Modify: `src/Ntilde.Mux/Cli/MuxCli.cs` usage text: `ls [--json] [--all]`. `--all` is accepted only by the App adapter; `ntilde-mux ls --all` prints "--all needs the ntilde app (it connects to your SSH profiles)" and exits 2. Update the tests that pin `UsageLines`. +- Test: `tests/Ntilde.App.Tests/Shell/Mux/MuxCommandLsAllTests.cs`, with fake connectors over `MuxTestHost` daemons: + - text layout with local, one remote and one unreachable host; + - the JSON shape; + - a host that hangs is cut at the timeout and the others still print; + - no `PersistRemoteSessions` profiles: the local listing only, plus "No remote hosts keep sessions." +- Test: `tests/Ntilde.Mux.Tests/Cli/MuxCliTests.cs` (`ntilde-mux ls --all` exits 2 with the message). +- [ ] **Steps:** failing tests → red → implement → green → commit `feat(mux): ntilde mux ls --all lists remote hosts`. + +--- + +## Part F — §6 SFTP and remote files on persisted remote tabs + +Ruling R8: native persisted tabs get SFTP, the remote files sidebar, path autocomplete and palette transfers through their own non-interactive connections, as plain native tabs do. Port forwards and OpenSSH ControlMaster reuse are deferred, with reasons. OpenSSH persisted tabs get palette transfers (scp runs on its own connection). + +### Task 28: Register native persisted tabs, with a per-host password scope + +`ActiveSshSessionRegistry` (`src/Ntilde.App/Services/Ssh/ActiveSshSessionRegistry.cs`) stores `(SessionId, ProfileId, BackendKind)` plus per-server runtime passwords keyed `(sessionId, host, port, user)`. Its readers: +- the sidebar listing gate, `RemoteDirectoryBrowserService.TryCreateNativeListingConnection` (`~:89-172`), which requires `TryGetActiveNativeSession`; +- autocomplete; +- transfers, `SftpService.ExecuteNativeSftpTransfer` (`~:633`), which use the id for passwords only, via `NativeHopPasswordResolver.Resolve(baseOptions, registry, sessionId, savedTargetPassword)`. + +A mux pane is never registered (`TerminalPane.axaml.cs:~3607-3608`: `if (session is not MuxClientSession)`). Typed passwords for a remote host live in `RemoteMuxInteractionHandler` per host, not in the registry. + +**Files:** +- Modify: `ActiveSshSessionRegistry`: + - `ActiveSshSessionDescriptor` gains an optional `Guid? PasswordScopeId`; + - `TryGetRuntimePassword` lookups made through a descriptor use `PasswordScopeId ?? SessionId`; + - new `UnregisterScope(Guid scopeId)` clears that scope's passwords. +- Modify: `src/Ntilde.App/Shell/Mux/Remote/RemoteMuxInteractionHandler.cs` and its owner: + - each remote host gets a `Guid PasswordScopeId` (on `MuxConnectionHost` or the connector); + - `Attempt` records each password answer's `request.Host/Port/User` with the secret (extend the `Answer` record); + - `Succeeded()` writes the non-superseded *typed* (not vault) password answers into the registry under the host's scope via `SetRuntimePassword(scope, host, port, user, secret)`; + - the host's dispose or release calls `UnregisterScope`. + - Do **not** give the exec transport's `NativeSshPromptResponder` a session id: `SshInteractionService` would replay stored passwords and bypass the handler's refused-password and jump-hop rules. Keep R7: a jump-hop profile's handler remembers nothing, so it writes nothing. +- Modify: `src/Ntilde.App/Controls/TerminalPane.axaml.cs`: + - register a `MuxClientSession` on a remote endpoint whose profile's backend is Native, as `(mux.Id, profileId, Native, PasswordScopeId: host scope)`; unregister on dispose and on detach; + - `IsPersistentRemoteTab` notices at `~:1156` (sidebar) are removed for native profiles; + - for OpenSSH profiles the sidebar entry stays hidden as on plain OpenSSH tabs (`IsRemoteFilesSidebarSupported` is profile-based). +- Modify: `src/Ntilde.App/MainWindow.axaml.cs`, `OnPaneRequestRemoteFilesSidebarTransfer` (`~:5481`) and `InitiateSftpTransfer` (`~:8502`): + - drop the persistent-tab refusal for native profiles; + - for OpenSSH profiles allow palette transfers (scp `-B` on its own connection, as on plain OpenSSH tabs). +- Retargeted destination (spec §15, codex D2): SFTP rebuilds options from the stored profile. While the host's pinned destination differs from the stored profile's, sidebar and transfers on that tab are refused with the notice "Remote Files" / "Not available while this tab still runs on the host it was opened on — reopen the tab to use the new host". Add an `internal bool IsRetargeted` on `RemoteMuxConnector` (pinned ≠ current), surfaced through the host. +- Modify: `src/Ntilde.App/Views/Ssh/NewSshConnectionView.axaml` (`~:165-174`, the Reliability tab; the brief's §4 editor-row item): under the "Keep remote sessions running (ntilde-mux)" checkbox, add a wrapping hint (Opacity 0.7): "Persistent tabs do not run this connection's port forwards. With the OpenSSH backend they also have no Remote Files sidebar (transfers from the command palette work)." Add the same sentence to `RemoteMuxInstallDialog.cs` (`~:139`). +- Test: `tests/Ntilde.App.Tests/Ssh/ActiveSshSessionRegistryTests.cs`: scope lookup; `UnregisterScope` clears it; the session-only path is unchanged. +- Test: `tests/Ntilde.App.Tests/Shell/Mux/Remote/RemoteMuxInteractionHandlerTests.cs`: + - a typed target password in a successful user attempt lands in the scope keyed by its host, port and user; + - a refused attempt writes nothing; + - a vault-filled password is not copied into the scope; + - a jump-hop profile writes nothing. +- Test: `tests/Ntilde.App.Tests/Core/MainWindowMuxRemoteTests.cs`, or a pane test: + - a native persisted pane registers with the host's scope; + - the sidebar toggle opens, with no notice; + - `RemoteDirectoryBrowserService` gets a listing connection (fake interop) carrying the typed password; + - a retargeted host refuses with the new notice; + - an OpenSSH persisted tab's palette transfer starts scp (fake process runner). +- Test: `NewSshConnectionViewLayoutTests`: the hint TextBlock exists and mentions "port forwards". +- [ ] **Steps:** failing tests → red → implement → green (App.Tests main lane, filter `FullyQualifiedName~Ssh|FullyQualifiedName~Mux|FullyQualifiedName~Sftp`; then the full lane) → commit `feat(mux): SFTP and remote files on native persistent tabs`. + +--- + +## Part G — §7 release + +### Task 29: One user manual chapter, README, docs, changelog, version + +**Files:** +- Modify: `docs/USER_MANUAL.md`: replace the accreted §3.3 subsections (`~:110-238`) and the persistence notes elsewhere (`~:486-488` Remote Files, `~:522-541` `ntilde.com`) with one chapter, "Persistent sessions and the multiplexer", written for a default-on reader. Its sections: + 1. What runs: a background process keeps your shells. + 2. Closing the window, and the first-close question; Quit and close all shells. + 3. Reopening: what comes back, and after a reboot (fresh shells, no warning). + 4. Detach versus close; Attach to session…, including remote hosts and Connect to…. + 5. Several windows and `ntilde mux attach`. + 6. Remote SSH tabs that keep running (`ntilde-mux`, install, `$XDG_DATA_HOME`, drops and reconnects, what works on a persistent tab: SFTP and Remote Files on native, transfers on OpenSSH, no port forwards). + 7. Updates: shells survive. When the multiplexer is from the previous build, "Restart multiplexer now"; when it is incompatible, what happens. + 8. Agents and windowless sessions. + 9. The CLI: `ntilde mux ls [--all] [--json]`, `attach`, `kill`, `kill-server [--force]`. + 10. `ntilde.com` on Windows. This includes the brief's §4 item: "If you capture its output (`ntilde | Out-Null`, `$x = ntilde`), the caller waits until the window closes; start it plainly or with `Start-Process ntilde`." + 11. Turning it off. + + Keep every anchor other docs link to: `grep -rn "USER_MANUAL.md#" docs src README.md`, and update links whose anchors change. +- Modify: `README.md`. Add a "Persistent sessions" bullet in "Why Ntilde?" (`~:27-42`) and a `### Persistent sessions` subsection after "Native SSH" in `## Features` (`~:262`). The one-line bullet: "Shells keep running when you close the window, reattach when you reopen, and SSH tabs survive network drops." +- Modify: `docs/ARCHITECTURE.md` §8.1/§8.2 (the default, `readScreen`, the update rule, the Windows daemon copy if Task 21 lands, the picker) and `docs/MODULE_OWNERSHIP.md` (new files from every task). +- Modify: `docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md`: mark the §14 items resolved, with links to Phase 5. +- Modify: `docs/superpowers/specs/2026-10-08-ntilde-mux-phase5.md` §R: any ruling added during execution. +- Create: `CHANGELOG.md`. There is none today; releases use GitHub's generated notes (`release.yml:124`). It gets an `## Unreleased` → `## 0.12.0` section with a user-facing multiplexer entry, not phase by phase, and states that earlier releases' notes are on GitHub Releases. +- Create: `docs/announcements/2026-10-
-persistent-sessions.md`: the release notes draft (what it is, the default, how to turn it off, updates, remote tabs, agents, known limits). +- Modify: `Directory.Build.props`: `0.11.0` → `0.12.0` in `Version`, `AssemblyVersion` (`0.12.0.0`), `FileVersion` (`0.12.0.0`) and `InformationalVersion`, as in commit `cfd3cd6 release: 0.11.0`. Tagging is the maintainer's step. +- [ ] **Steps:** + 1. Write the docs. + 2. Run the doc link checker if the repo has one (`grep -n "markdown-link\|lychee\|linkcheck" .github/workflows/*.yml`); otherwise check every `USER_MANUAL.md#` anchor by hand. + 3. Commit `docs(mux): one persistent sessions chapter; README, changelog, release notes`. + 4. Commit `release: 0.12.0` with the version bump only. + +### Task 30: Manual checklist on three OSes + +**Files:** +- Create: `docs/superpowers/plans/2026-10-08-ntilde-mux-phase5-manual-checklist.md`. One numbered checklist that merges: + - PR #489's 8 steps (`gh pr view 489 --json body`); + - Phase 3's extensions (`docs/superpowers/specs/2026-09-29-ntilde-mux-phase3.md`, manual section); + - Phase 4's Windows `ntilde.com` steps (`docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md`, manual section); + - the remote drop/reconnect scenario (Docker sshd `novaterm-native-ssh-e2e:v5`, drop and restore with `docker network disconnect/connect`); + - Phase 5's new steps: the first-close dialog, Quit and close all, reboot-quiet restore, update survival (Tasks 20-24's procedure), the picker with remote hosts, `ls --all`, SFTP on a persistent native tab, and an agent `list_sessions` with a windowless session. + + Each step has an expected result and a blank "Result / log" line per OS. +- [ ] **Steps:** + 1. Write the checklist. + 2. Run the Windows column. Use the dev build with `NTILDE_APPDATA_ROOT=%TEMP%\ntilde-smoke`. GUI steps are performed by the maintainer, because SendKeys automation is unreliable on this machine. CLI steps are scripted and their output pasted. + 3. Run the Linux column's CLI and daemon steps in Docker or WSL where possible. + 4. Hand the macOS column and the Linux GUI steps to the maintainer. + 5. Paste the logs into the final PR. Say plainly which cells were run by whom and which are still open. + 6. Commit `docs(mux): Phase 5 manual checklist`. + +### Task 31: Final review and the `dev-mux` → `main` PR + +- [ ] **Step 1: Run every suite** on the branch head: VT, Rendering, Architecture, Platform, McpServer, Mux, App main lane, App PlatformBoot lane, `cargo test` for rusty_ssh. Record the counts. +- [ ] **Step 2: Whole-branch review** on the most capable model, as SDD's final review. Make one fix dispatch, then one scoped re-review. +- [ ] **Step 3: Open the Phase 5 PR** into `dev-mux`. It is stacked on #510 until that merges. Its body has the sections by brief section, the §R rulings, the tests per §4 item, and the follow-ups. +- [ ] **Step 4: Merges into `dev-mux` and the `dev-mux` → `main` PR** happen on the maintainer's word. Prepare the `main` PR body: the user-facing summary, then links to #472, #474, #489, #499 and #504 (phases), #508 and #509 (hardening), #510 (sync) and the Phase 5 PR. diff --git a/docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md b/docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md index 980cba88f..aea8e96b5 100644 --- a/docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md +++ b/docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md @@ -817,32 +817,54 @@ longer names `cmd /c`. ## 14. Follow-ups (Phase 5 candidates) -- Flip the `SessionPersistence` default. -- Agent-host `read_screen` for windowless sessions. -- Update hand-off between daemon builds: a running daemon replaced without losing sessions. -- SFTP sidebar, remote files and port forwards on persisted remote panes (§8.4). -- Remote endpoints in the "Attach to session…" picker; adoption of remote orphans. +Phase 5 ([spec](2026-10-08-ntilde-mux-phase5.md)) took most of these; each is marked with where it went. + +- Flip the `SessionPersistence` default. **RESOLVED** in Phase 5 ([§1](2026-10-08-ntilde-mux-phase5.md#1-the-default-decision--implementation), + rulings R1-R3): the UX a default-on reader needs, and the flip in a commit of its own, the + maintainer's call in the PR. Local shells default to `KeepOnClose`. +- Agent-host `read_screen` for windowless sessions. **RESOLVED** in Phase 5 + ([§3](2026-10-08-ntilde-mux-phase5.md#3-agent-host-sees-windowless-sessions), R4, R5): the optional `readScreen` method, + and every session tool but `spawn_session`, `export_replay` and `wait_for_events` on windowless ids. +- Update hand-off between daemon builds: a running daemon replaced without losing sessions. **PARTLY + RESOLVED** in Phase 5 ([§2](2026-10-08-ntilde-mux-phase5.md#2-sessions-survive-app-updates-cheap-path), R9, R10): an update + keeps a protocol-compatible daemon running (on Windows from its own copy outside the install root), and + a daemon of another build offers "Restart multiplexer now". Handing sessions to a new daemon (SCM_RIGHTS + on Unix; Windows has no equivalent for a pseudoconsole) is still a follow-up. +- SFTP sidebar, remote files and port forwards on persisted remote panes (§8.4). **PARTLY RESOLVED** in + Phase 5 ([§6](2026-10-08-ntilde-mux-phase5.md#6-sftp-remote-files-and-forwards-on-persisted-remote-tabs-native-first), R8): + SFTP and remote files on native persisted tabs, palette transfers on OpenSSH ones. Port forwards and + OpenSSH ControlMaster reuse are deferred (R8 says why). +- Remote endpoints in the "Attach to session…" picker; adoption of remote orphans. **Picker RESOLVED** in + Phase 5 ([§5](2026-10-08-ntilde-mux-phase5.md#5-remote-endpoints-in-the-picker-and-mux-ls), with `ntilde mux ls --all`); + remote orphans are listed there but still not adopted on their own. - Embed the release's `ntilde-mux` SHA-256s in the app at build time, instead of trusting the - `.sha256` next to the asset. -- Refresh `SSH_AUTH_SOCK` in long-lived remote shells (tmux's `update-environment`). -- A winget alias for `ntilde.com`. + `.sha256` next to the asset. **RESOLVED** in Phase 5 + ([§4](2026-10-08-ntilde-mux-phase5.md#4-remaining-review-items-fixes-each-with-a-pinning-test), Task 10: `MuxAssetPins`). +- Refresh `SSH_AUTH_SOCK` in long-lived remote shells (tmux's `update-environment`). **RESOLVED** in + Phase 5 ([§4](2026-10-08-ntilde-mux-phase5.md#4-remaining-review-items-fixes-each-with-a-pinning-test), Task 8: a stable + `agent.sock` link every proxy repoints). +- A winget alias for `ntilde.com`. Open. Recorded during the build: -- **Native exec latency floor.** `NativeSshExecTransport`'s poll thread sleeps 10 ms when idle, which +- **Native exec latency floor. RESOLVED** in Phase 5 ([§4](2026-10-08-ntilde-mux-phase5.md#4-remaining-review-items-fixes-each-with-a-pinning-test), + Task 9: the poll thread waits in `nova_ssh_wait_event`). `NativeSshExecTransport`'s poll thread sleeps 10 ms when idle, which puts about 15 ms under every request round trip (attach of an empty session: 15 ms native against 2 ms over OpenSSH, §15). An event-driven wakeup from rusty_ssh would remove it; plain native tabs poll at 25 ms. -- **Password memory per (kind, host, user).** Add the host and user to the native `PasswordPrompt` +- **Password memory per (kind, host, user).** Still open (Phase 5 [R7](2026-10-08-ntilde-mux-phase5.md#r-rulings) keeps the + stricter jump-profile rule). Add the host and user to the native `PasswordPrompt` payload and key `RemoteMuxInteractionHandler`'s remembered secrets by them, so a profile with jump hops and passwords can reconnect on its own (today it reconnects on Enter, §15). -- **Vault password to a jump host (pre-existing, plain native tabs too).** `NativeSshPromptResponder` +- **Vault password to a jump host (pre-existing, plain native tabs too). RESOLVED** (native by d1972b7, whose prompts name + their hop; OpenSSH askpass by Phase 5 Task 2, `SshAskPassVaultPolicy`). `NativeSshPromptResponder` allows vault reuse on the *first* Password prompt, and with jump hops that may be a jump host's prompt, so the target's vault password can be sent to the jump host. - **Liveness knobs.** `RemoteMuxHostFactory.Create` does not expose `LivenessInterval` / `LivenessTimeout` (`init` on `MuxConnectionHost`), so the Docker E2E runs at the production 15 s + 10 s. -- **A captured GUI launch through `ntilde.com` waits for the GUI (greptile G2, PR #504).** When a +- **A captured GUI launch through `ntilde.com` waits for the GUI (greptile G2, PR #504). Documented** + in the user manual (12.10, Phase 5 §4), not fixed. When a caller captures `ntilde`'s output (`ntilde | Out-Null`, or a tool that reads stdout), it keeps waiting until the GUI exits: `Ntilde.exe` inherits the redirected standard handles and holds them after the launcher has returned. Launching `Ntilde.exe` directly behaves the same, so this is not a diff --git a/docs/superpowers/specs/2026-10-08-ntilde-mux-phase5.md b/docs/superpowers/specs/2026-10-08-ntilde-mux-phase5.md new file mode 100644 index 000000000..9a06ab006 --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-ntilde-mux-phase5.md @@ -0,0 +1,371 @@ +# ntilde multiplexer — Phase 5 (sync with main, default decision, agent host, update survival, release) + +Status: in progress. The brief below is the maintainer's, verbatim. §R records the rulings made while +executing it (the brief's "Decisions you may make", and conflicts the surveys found), each with its reason. +The implementation plan is `docs/superpowers/plans/2026-10-08-ntilde-mux-phase5.md`. + +## Why + +Phases 0–4 plus two hardening PRs are merged into `dev-mux` (8033845). The multiplexer is +feature-complete for its three goals: local shells survive window close and restart; several +windows and `ntilde mux attach` share sessions; SSH tabs survive network drops via a remote +`ntilde-mux` daemon. Everything is behind `SessionPersistence` (default Off) plus a per-profile +remote flag. Phase 5 turns it into a shipped product: bring the branch back onto `main`, decide +and implement the default, make sessions survive app updates, let agents see windowless sessions, +close the remaining review items, and merge to `main`. + +Read first: `CLAUDE.md`, `AGENTS.md`, `docs/ARCHITECTURE.md` §8.1, `docs/MODULE_OWNERSHIP.md`, +`docs/USER_MANUAL.md` §3.3, the Phase 4 spec `docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md` +(§14 follow-ups, §15 as-built, post-merge entries), PR #509's "Deliberately not changed" list, +`docs/agent-host/DIRECTION.md` and `docs/mcp/tools.md` (the agent host you will extend), +`src/Ntilde.App/Update/*` (Velopack apply path), `docs/CONFIG_STORAGE_CONTRACT.md`. +Build/test only via `scripts/build.sh` / `scripts/build.ps1`; one project at a time; App.Tests lanes +separately with `--blame-hang-timeout 5m`. **Work on `dev-mux` (or a branch off it); the final +deliverable is a PR from `dev-mux` to `main`.** Do not touch `claude/ntilde-multiplexer-mbxx9b`. + +## 0. Sync with `main` first (its own PR into `dev-mux`) + +`origin/main` is 81 commits ahead of the merge base (`9585a98`). Merge `origin/main` into +`dev-mux` before any Phase 5 work. Known hotspots to resolve by hand, never by taking one side +wholesale: +- **VT write-lock wait helpers** (`7a1c364`, `f4f7c25`): "never pump messages while waiting for + the buffer write lock", "keep VT free of native interop; App owns the non-pumping wait". Phase 0's + `TerminalBuffer.StateTransfer.cs` takes the write lock in `ImportState`; the headless mux sessions + and the text client take it off the UI thread. Make sure the new wait helpers are used (or + correctly bypassed) everywhere Phase 0–4 code takes that lock, and that the App-owned + non-pumping wait is not reachable from `Ntilde.Mux` (layering). +- **Reflow streaming** (`988622a`, `1c81fab`): "stream the reflow instead of materialising the + scrollback 4x", "hand old scrollback pages back as the reflow consumes them". Phase 0's + `ExportState` walks scrollback pages; Phase 1's `HeadlessTerminalSession` resizes on the parse + thread. Re-run the full snapshot/tail parity suite (`tests/Ntilde.VT.Tests/StateTransfer/`) and the + Mux equality suites after the merge; any divergence is a merge bug. +- **Inline image payload pinning** (`7455935`), **idle GC** (`3c50951`…`1516e5c`), **tab-list menu + lifetime** (`f5892d1`): check interactions with mux panes (image decoder is null on mux panes; + idle GC must not run on the daemon's parse threads; tab-list menus with shared/detached tabs). +- Architecture tests on both sides must pass unchanged. +Report the conflict list and how each was resolved. Run every suite on the merged tree before +starting section 1. + +## 1. The default (decision + implementation) + +**Recommendation:** flip `SessionPersistence` to `"KeepOnClose"` for **local** shells in the release +that merges to `main`; keep remote persistence per-profile opt-in. Implement so that the flip is a +one-line change plus the UX below, and let the user make the final call in the PR. + +Required UX for a default-on world (today's behaviour was designed for opt-in users who know what +they enabled): +- **First-close notice**: the first time a window closes with live local sessions, a dialog + "Your shells keep running in the background. Reopen ntilde to get them back." with + [Keep running] [Close them] and "Don't ask again". Remembered as a setting-free flag in app data. +- **Quit and close all shells** command (palette + Settings link) that kills every local session + and the daemon (`kill-server`) on exit. +- Settings row copy and the manual rewritten for a default-on reader (what runs, how to see it, + how to turn it off), and a one-line README feature bullet. +- Startup with orphans/detached: the existing toasts stay; make sure a brand-new user who closed + the window yesterday sees their shells return with no toast spam. +- Migration: an existing settings.json with no `SessionPersistence` key gets the new default; + one with an explicit `"Off"` keeps Off. Test both. + +## 2. Sessions survive app updates (cheap path) + +Today `ApplyStagedUpdateAsync` sends `shutdown` and the daemon dies with every shell (PR #489). +Change the rule: **keep a protocol-compatible daemon running across an update.** On apply, if the +live daemon's `[MinProtocol, MaxProtocol]` overlaps the new build's range (read from the descriptor +or `hello`), do not shut it down; after restart the new GUI reattaches as after any launch. Show +"Multiplexer is from the previous build; restart it when convenient" with a "Restart multiplexer +now" action (which is today's warn + `kill-server` path). Shut down only when ranges do not overlap. +Verify on Windows that Velopack can replace `current\` while `Ntilde.exe mux serve` runs from it +(the running image is mapped; Velopack renames/moves the old dir); if it cannot, the daemon must be +started from a copy outside `current\` (e.g. `/bin//`) — decide after measuring. +Same rule for remote daemons (already a separate binary version). The full fd-passing hand-off +(SCM_RIGHTS on Unix) is **not** in scope; record it as the follow-up with the Windows caveat. + +## 3. Agent host sees windowless sessions + +The agent host (`src/Ntilde.App/AgentHost/AgentHostService.cs`, MCP tools in `docs/mcp/tools.md`) +lists and reads only panes. Add, additively (protocol stays Min 1 / Max 2 with new optional methods +a v1 daemon answers with `protocol_error`, which the client maps to "unsupported"): +- Mux method `readScreen {sessionId, maxScrollbackRows}` → the headless buffer as a + `TerminalStateSnapshot` (reuse `CaptureSnapshot`), plus `status {running, exitCode, hasChildren, + attachedClients, interactiveClients, title, cwd}` already in `sessionInfo`. +- `ntilde.list_sessions` includes daemon sessions with no pane (local and remote endpoints the GUI + knows), marked `windowless: true`; `ntilde.get_session_status` and `ntilde.read_screen` work on + them under the observe opt-in; `ntilde.send_input` and `ntilde.close_session` under the act + opt-in (same allowlist rules as panes; SSH profiles honour the per-profile act allowlist). +- `capture_screen render` mode on a windowless session renders from the snapshot (the renderer + already works from a buffer); `live` mode is refused with `captureUnavailable`. +- Indicators: the pane-level agent indicators have no pane here; log to the agent journal and show + the window-level light as today. +- Tests in `tests/Ntilde.McpServer.Tests` and App.Tests with an in-memory daemon. + +## 4. Remaining review items (fixes, each with a pinning test) + +From PR #509's "Deliberately not changed" and my Phase 4 review nits: +- Every UI-thread send that can block on a full outbound queue: local `KillMuxSessionOnClose`, + Detach/Leave, Reconnect's `faulted.Kill()`, input and resize. Generalise B1: sends from the UI + thread go through a per-host single-consumer queue, never `BlockingCollection.Add` on the UI thread. +- Jump-host password prompt can be offered the vault password on the **interactive** path + (pre-existing, also plain native tabs): never offer a vault password to a prompt that names a hop. +- Native known-hosts: one store instance; atomic `TrustHost` write. +- `RemoteMuxCommand`: escape a recorded path with spaces/non-ASCII instead of silently replacing it. +- Askpass marker folder created 0700 off Windows; control characters stripped from server text + quoted into toasts (`RemoteMuxFailureClassifier`); `Array.Clear` the native response payload. +- Install dir honours `${XDG_DATA_HOME:-$HOME/.local/share}` like the daemon root. +- Profile editor row for remote persistence says what it disables (SFTP, remote files, forwards). +- Manual: a plain `ntilde` via `ntilde.com` waits when output is captured. +- CI: the remote-persistence E2E step must not skip green on tests-only PRs; run it when any + `src/Ntilde.Mux*`, `src/Ntilde.Platform/Ssh/Exec`, or `Shell/Mux/Remote` path changes. +- `SSH_AUTH_SOCK` staleness in long-lived remote shells: set `SSH_AUTH_SOCK` in daemon-spawned + shells to a stable per-root symlink that the proxy repoints on every connect (tmux's pattern). +- Native exec idle poll: blocking wait instead of the 10 ms sleep (the ~15 ms attach floor). +- Sign and notarize `ntilde-mux` osx-arm64 in `release.yml` alongside the app, or document the + Gatekeeper behaviour for Finder-copied binaries. +- Embed the `ntilde-mux` SHA-256s at build time: `publish_mux_daemon` writes a manifest the App + build embeds, and `GitHubReleaseMuxAssetSource` verifies against it (the `.sha256` asset stays as + a fallback for dev builds). + +## 5. Remote endpoints in the picker and `mux ls` + +"Attach to session…" lists sessions from every connected remote host (grouped by profile), and +`ntilde mux ls --all` prints them. Shared attach to a remote session follows the same exclusivity +rules; reconnect on a shared remote tab is the same loop. + +## 6. SFTP, remote files and forwards on persisted remote tabs (native first) + +Share the profile's native SSH connection between the mux exec channel and the SFTP subsystem and +forwards, so the sidebar and transfers work on persisted tabs. The hook is `ActiveSshSessionRegistry` +(skipped for `MuxClientSession` today, `TerminalPane.axaml.cs` RegisterActiveSshSession). OpenSSH +stays "not available" with the existing notice unless ControlMaster reuse through the persistent +exec `ssh` process turns out to be a one-day job; decide and say which. + +## 7. Release + +- Run the full manual checklist (PR #489's 8 steps, Phase 3's extensions, Phase 4's Windows + `ntilde.com` steps, and the remote drop/reconnect scenario) on **all three OSes** and paste the logs. +- Release notes and CHANGELOG entry for the multiplexer as a whole (user-facing, not phase-by-phase); + `docs/USER_MANUAL.md` gets one coherent "Persistent sessions and the multiplexer" chapter + replacing the accreted §3.3 subsections; README feature bullet; `docs/ROADMAP.md` already updated. +- Version bump per the repo's release process (`docs/plans/2026-04-22-ci-rebalance-and-release-publishing.md`). +- **Final PR: `dev-mux` → `main`.** Squash or merge per repo convention; the PR body is the + user-facing summary plus links to the five phase PRs and the two hardening PRs. + +## Constraints + +- Protocol additive only (Min 1 / Max 2 with optional new methods); a v1 daemon keeps working. +- No credential-path behaviour changes beyond the jump-host fix in §4; the once-per-attempt and + never-empty-password rules stand. +- Layering unchanged: `Ntilde.Mux` no App/Avalonia/Platform; leaves stay leaves. +- With `SessionPersistence` explicitly Off, nothing changes. + +## Decisions you may make (say which way and why) + +- Whether the daemon must be started from outside Velopack's `current\` on Windows (§2). +- OpenSSH ControlMaster reuse for SFTP on persisted tabs (§6): do or defer. +- Whether the first-close notice is a dialog or a toast with actions. + +## Report back with + +1. The `main` sync PR: conflict list and resolutions; full-suite results on the merged tree; parity + suite results after the reflow merge. +2. Branch/PR links; files by section; which §4 items landed with which test. +3. The default-flip implementation and the exact UX, with screenshots or test names for the + first-close notice and migration cases. +4. Update-survival evidence: a Velopack update applied with a live daemon on Windows and on one + Unix OS, shells intact afterwards, plus the no-overlap path. +5. Agent-host tests for windowless sessions, observe and act. +6. The three-OS manual logs and the release notes draft. +7. Open items left for a follow-up and anything done differently than stated here. + +--- + +## R. Rulings + +Each ruling: what was decided — why — what it costs if wrong. Added as they are made; the plan's +tasks carry them. + +R0 (§0). The sync landed as PR #510 (merge 40fdb1a). Conflicts and resolutions are in its description. + +R1 (§1). The first-close notice is a **dialog**, not a toast — the window is closing, so a toast would +vanish with it, and the choice must be made before teardown detaches or kills. "Don't ask again" +remembers the *answer* (keep or close) in a flag file under the app-data root (`mux-close-choice`, +content `keep` or `close`), not in `settings.json`. Saving Settings with a changed +`SessionPersistence` deletes it, so turning persistence back on asks again. — A remembered "keep" +with no remembered "close" would turn "Close them + Don't ask again" into the opposite of what the +user said. — Cost if wrong: one file and one branch in the close path. + +R2 (§1). After a reboot, restored panes start fresh shells **silently** (no "previous sessions were +lost" toast, no per-pane banner) when the session file was saved before the current boot. A daemon +that died *since* boot still gets today's notices. **Amended in Task 19:** the boundary is the later of +the boot and, on Windows, the current logon session's start (Fast Startup's "Shut down" is a logoff +that keeps the tick count), read from the OS (`SessionStartBoundary`); a boundary that cannot be read +is not quiet, so the loss is announced. When only the Windows logon time cannot be read, the boundary +falls back to the tick-count boot, which is never later than the real boundary, so it can only make a +restore louder. — Default-on users reboot; a loss toast after every +boot would read as an error. — Cost if wrong: a crash that coincides with a reboot is not announced. + +R3 (§1). The flip itself is its own commit (`SessionPersistence` default `"KeepOnClose"`), on top of +the UX, so the PR can drop it if the maintainer decides against it. `AppServices.BuildForDesigner` +pins `"Off"` so test windows and the designer never spawn a daemon. + +R4 (§3). A windowless session is a daemon session (local, or a remote endpoint with a live connection +— `CurrentClient`, never `GetClient`, so the agent path never prompts or connects) that no pane of +this window shows or is about to show. Its agent-facing id is its mux session id. A session another +process attaches (a text client, another window) counts as windowless here. + +R5 (§3). Reads of windowless sessions are journaled (the brief's "log to the agent journal"), unlike +pane reads, which stay unjournaled (`Capture_is_not_journaled_because_it_is_an_observe_tier_read`). +Act on a windowless session on a remote endpoint requires that profile's agent allowlist for +`send_input` **and** `close_session` (pane close is not allowlist-gated; a windowless kill is invisible +and destructive, so it is). — Cost if wrong: an agent needs the allowlist to close a remote windowless +shell. + +R6 (§4). The non-blocking send path lives in `MuxClient` (one ordered overflow pump per client), not +only in `MuxConnectionHost`: the UI callers hold sessions, not hosts, local hosts have no B1 chain, +and ordering between input and a following kill must hold across both. B1's `HandOffKill` stays. + +R7 (§4). `RemoteMuxInteractionHandler` keeps its stricter jump-profile rule (no remembered or vault +password for any prompt on a profile with jump hops), although native prompts now name the hop. +Relaxing it is a credential-path behaviour change the constraints exclude; it is listed as a follow-up. + +R8 (§6). SFTP, remote listing and transfers on a native persisted tab open their own non-interactive +connection, exactly as a plain native tab's do (they never shared the shell's connection); the +persisted tab registers in `ActiveSshSessionRegistry` with a per-host password scope. Port forwards on +persisted tabs are **deferred**: they would ride the mux exec channel's single event queue (head-of-line +blocking against the liveness ping), drop and rebind on every reconnect, and collide with plain tabs +of the same profile. OpenSSH ControlMaster reuse is **deferred** (Win32-OpenSSH has no ControlMaster; +a stale master would capture reconnects); OpenSSH persisted tabs get palette transfers (`scp` runs on +its own connection), and the sidebar stays native-only as for plain OpenSSH tabs. + +R9 (§2, measured 2026-10-08 with Velopack 1.2.0 on Windows 11, a probe app packed with vpk 1.2.0 into a +sandbox install). Velopack's Windows apply logs `Checking for running processes in: `, then +hard-kills every process whose image is under the install root (`current\` and any other subfolder). +A copy of the same binary running from outside the root survived four applies with no heartbeat gap. +The apply renames `current\` away, and an outside process whose working directory is inside `current\` +makes it fail ("Unable to start the update, because one or more running processes prevented it"). +The macOS and Linux updaters contain no process-killing code. + +Ntilde also sideloads `conpty.dll` and `\OpenConsole.exe` from the install folder (#310), so every +shell's console host runs from under the root too. **Decision: on a Windows Velopack install the local +daemon runs from a copy at `\bin\\`**: `Ntilde.exe`, the DLLs beside it and the +`\OpenConsole.exe` hosts, staged once per version. Elsewhere, and in dev builds, it runs from the +running executable as before. +— Without the copy, an update kills the daemon and every shell's console host whatever the GUI does. +— Cost if wrong: about 100 MB per installed version under app data (older copies are pruned when no +daemon runs them). + +R10 (§2). The old GUI cannot know the new build's protocol range, so the release puts it in Velopack's +release notes as a marker line, ``, and the GUI reads it from the +staged update. +- A missing marker counts as compatible. Min has been 1 since Phase 0, and the new GUI still handles a + mismatch at launch with a "Restart multiplexer now" action. +- A daemon whose image is inside the install root (one started by a pre-Phase-5 build) is treated as not + kept, because the apply would kill it: today's confirm-and-shutdown path. +- Velopack's startup auto-apply is vetoed only for such a daemon. +- The full hand-off (passing the PTY fds to a new daemon with SCM_RIGHTS on Unix) stays out of scope and + is the follow-up. Windows has no SCM_RIGHTS; it would need `DuplicateHandle` into the new daemon plus + re-creating each pseudoconsole's client side, which ConPTY does not support today. + +Rulings made while executing the plan (`.superpowers/sdd/2026-10-08-ntilde-mux-phase5/progress.md` has +each with its reason), where they change user-visible behaviour or a contract: + +R11 (§4, Task 2). The OpenSSH askpass helper withholds the vault password on a user's attempt through a +jump host whenever the prompt could be the hop's: ssh before 8.4, a hop named like the target, or a proxy +in the profile's extra SSH arguments (not parsed, so fail closed). A ProxyJump only `~/.ssh/config` names +is not seen. + +R12 (§4, Task 3). One known-hosts store per path (`ForPath(AppPaths.NativeKnownHostsFilePath)`; Platform +has no `Default`). `TrustHost` writes atomically, fails only with `IOException` (a contended Windows +rename is retried 10 x 25 ms), and two processes trusting at once may lose an entry, never tear the file. + +R13 (§4, Tasks 4, 6). The install dir is `$XDG_DATA_HOME/ntilde/bin` when set and absolute, else +`~/.local/share/ntilde/bin`; a recorded path sh double quotes can hold is escaped, not replaced. The +offline one-liner resolves the dir in the user's own shell and prints it, so an `XDG_DATA_HOME` set only +interactively can disagree with the exec channel's (accepted). + +R14 (§4, Task 5; amended after the Codex review of PR #511). New askpass record folders are `0700`. One +that already exists with another mode (an older build made it under the umask) is tightened to `0700` by the +askpass writer before any record is written; when that fails (the folder is not ours, or is a link), no record +is written and askpass takes its no-evidence path. `PrivateDirectory` itself still never changes an existing +folder: the daemon's "never chmod an existing dir" rule for its socket folder stands. + +R15 (§4, Task 8). The agent link is repointed only at a socket that answers and whose listener runs as +this user (`SO_PEERCRED` / `LOCAL_PEERCRED` against `geteuid()`); when that cannot be checked, never. + +R16 (§4, Task 10). The release fails on a notarization whose status is not `Accepted`. The app verifies +a downloaded `ntilde-mux` against the hashes it embeds, and against the `.sha256` only without them. + +R17 (§3, Task 11). `readScreen` replies are charged to the snapshot account, never the stream budget. +"Unsupported" is signalled only by a null result (a decode failure is a `protocol_error` exception); +`snapshot_too_large` is retried with fewer scrollback rows. + +R18 (§3, Tasks 12, 13). One 7 s deadline per windowless operation. Act checks (`mayAct`: act on, the same +source, the profile's allowlist) run inside that one survey, so turning act off mid-survey stops it; +`NotAllowed` outranks `NotRunning`. Repeated windowless reads fold into one journal entry with a count +(`×N`); acts never fold. + +R19 (§1, Task 16). "Close them" (asked or remembered) never ends a shell another interactive client +shows, nor a share whose sharing is unknown: those detach. macOS Cmd+Q (`ApplicationShutdown`) applies a +remembered answer and never asks; `OSShutdown` never kills. Enter answers Keep. Settings saves, imports +and restores that change the mode forget the answer. + +R20 (§1, Task 17). "Quit and close all shells" asks even when the count is unknown and proceeds once +confirmed; it ends shared and detached local shells too, and never remote ones. + +R21 (§1, Task 19). The quiet mark is kept per node in the session file (`PaneNode.MuxQuietPreviousLost`), +so an unvisited tab stays quiet in the next launch of the same boot. A restored share whose shell is gone +is announced even after a reboot (`ShareEnded`). The `MuxHostFactory` seam lives in `AppServiceBundle`, +and `BuildForDesigner`'s `MuxHostFactory` refuses, so no designer or test window can spawn a daemon (R3 +made structural). + +R22 (§2, Tasks 20, 23). An unknown version (null or `0.0.0`) is never another build's. "The previous +build" only when SemVer says older, else "a newer build" / "a different build"; no shell-count clause +at 0. A restored pane's mismatch banner stays text-only (the action is on the notice); the action labels +are the brief's, without an ellipsis. Amended (final review I1, residual N1): a remote daemon is offered a +restart only when it is older than the profile's recorded `RemoteDaemonVersion` ("a previous version"), +otherwise "Update ntilde-mux on {host}…" when older than the app; a remote offer released "after a +restart" stays claimed for the launch once `shutdown` went out, and is released by a recorded install. + +R23 (§2, Task 21). A copy is reused only when its `.complete` sizes and the executable's SHA-256 match. +Velopack's uninstall hook stops the daemon and removes the copies, deleting only copy-shaped folders. +Measured in Task 24: Velopack 1.2.0's uninstall runs a kill pass, then the hook, then a second kill pass. + +R24 (§2, Task 22). With `SessionPersistence` explicitly Off, both update paths keep their pre-Phase-5 +behaviour: any live daemon is asked about and shut down, and vetoes the startup apply. A corrupt +`settings.json` whose `.bak` says Off reads as not-Off at startup (accepted). + +R25 (§2, Task 24). (R1) `NTILDE_UPDATE_SOURCE_DIR` is honoured only when the install's Velopack app id is +the compiled-in `NtildeSurvival` (an allow-list, pinned against the scripts' pack id). (R3) On macOS the +survival script never lets Velopack restart the app (`open -n` drops the sandbox environment). (R4) The +scripts refuse a sandbox they did not create (a marker file) or outside an allowed location, before any +cleanup is armed. The hidden `spawn-for-test` verb ships: it adds nothing the same-user pipe does not. + +R26 (§5, Task 25). The remote detach notice names the host: "Shell kept running on {host} — Attach to +session… reopens it", or the `ntilde-mux attach ` text when the profile no longer keeps sessions. +Picker error rows are worded from `MuxPickerHostError`, never server or exception text, and an empty +picker's notice is those rows. A connect row shows "Connecting to {host}…" while it waits; connect rows +sort by profile name. + +R27 (§5, Task 26). A share whose sharing is unknown (link down or reconnecting, attach pending) detaches +on close without the running-process question - a detach never ends a shell - and the *Shell detached* +notice says so. + +R28 (§5, Task 27). (a) `ls --all` may start an idle remote daemon, as the picker's connect does (it exits +after 10 idle minutes). (b) Off Windows it skips OpenSSH profiles through a jump host or proxy +(`MuxListingError.ThroughJumpHost`): BatchMode does not reach a ProxyJump hop, whose prompt would reach +the user's terminal. The picker still lists them. + +R29 (all, pre-flight P2). A toast that quotes a value a daemon or remote host reported (a version, a +host, an unreachable reason) passes it through `RemoteOutputText.Quote` (control and format characters +dropped, length capped). + +R30 (§6, Task 28). A native persisted tab registers in `ActiveSshSessionRegistry` with its host's +password scope. A password is written to that scope only when the user typed it in an attempt that got +in: never one filled from the vault, never an empty one, never on a profile with jump hops (R7); the +scope is cleared when the host goes. The exec transport's `NativeSshPromptResponder` still gets no +session id, so `SshInteractionService` never replays stored passwords past the handler's rules. The +registry keeps every live descriptor per session id, so two windows on one shared session each keep +theirs; a lookup sees the newest, so while a share is open in another window the owner window does not +see its own typed password (it recovers when the share closes). While the host still runs on a +destination the profile no longer names, the sidebar, listing and transfers are refused, checked at +every listing and again once each transfer dialog is answered, and an open sidebar closes with the +notice. Port forwards and OpenSSH ControlMaster reuse stay deferred (R8). diff --git a/scripts/ci/aot-gate-paths.sh b/scripts/ci/aot-gate-paths.sh new file mode 100644 index 000000000..82e43b7c4 --- /dev/null +++ b/scripts/ci/aot-gate-paths.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Reads changed paths (newline-separated) on stdin and prints exactly one line, run=true or +# run=false, saying whether the AOT gate (and so the linux-x64 ntilde-mux build the Docker +# E2E consumes) must run. Always exits 0. +# +# Scoped by DIRECTORY, not by file spelling: anything under src/ can change the bundle, the +# build inputs decide how it is compiled, and the test directories below feed the E2E that +# needs the daemon binary. A PR touching only those tests used to get no binary, and the +# remote-persistence step skipped with a notice while the job stayed green. +# +# Tested by scripts/tests/aot_gate_paths_tests.py. +set -u + +changed="$(cat)" + +# Here-string, not `echo | grep`: a long list would make echo take SIGPIPE under pipefail. +if grep -qE '^(src/|Directory\.(Build|Packages)\.props$|global\.json$|\.github/workflows/[^/]+\.ya?ml$|scripts/mux-daemon-smoke\.sh$|tests/Ntilde\.App\.Tests/Shell/Mux/|tests/Ntilde\.Mux\.Tests/|tests/Ntilde\.Platform\.Tests/Ssh/|tests/Ntilde\.ExternalSuites/NativeSsh/)' <<<"$changed"; then + echo "run=true" +else + echo "run=false" +fi +exit 0 diff --git a/scripts/ci/aot-smoke-verdict.ps1 b/scripts/ci/aot-smoke-verdict.ps1 new file mode 100644 index 000000000..d87cfa611 --- /dev/null +++ b/scripts/ci/aot-smoke-verdict.ps1 @@ -0,0 +1,48 @@ +# The verdict of the AOT gate's smoke launch (ci.yml "Smoke launch the published bundle" and +# release.yml "Smoke launch the Windows bundle (blocking)"): reads the GUI's debug.log and the +# daemon's mux.log and decides whether the bundle came up with a real shell behind it. +# +# The smoke runs on the real default (SessionPersistence KeepOnClose), so the shell is spawned +# by the multiplexer daemon (`Ntilde.exe mux serve`), not by the GUI. Rules: +# - the UI must have come up: `[TerminalView] Theme applied`; +# - the GUI started the daemon (`[Mux] starting the multiplexer daemon`): the daemon path must +# then be complete, i.e. mux.log has `[MuxDaemon] serving` AND `[RustPtySession] Spawned +# ... pid=N`. A GUI-only spawn line does NOT count here: when the daemon fails the GUI falls +# back to a local shell, which would hide a broken AOT daemon; +# - the GUI never started a daemon (persistence off): the GUI's own Spawned line is required. +# Exits 0 and prints the verdict, or throws (exit 1) naming what is missing. +# +# Tested by scripts/tests/aot_smoke_verdict_tests.py. +param( + [Parameter(Mandatory)][string]$DebugLog, + [Parameter(Mandatory)][string]$MuxLog +) +$ErrorActionPreference = 'Stop' + +$spawnPattern = '\[RustPtySession\] Spawned .*pid=\d+' + +function Test-Line([string]$Path, [string]$Pattern) { + return [bool]((Test-Path -LiteralPath $Path) -and (Select-String -LiteralPath $Path -Pattern $Pattern -Quiet)) +} + +if (-not (Test-Line $DebugLog '\[TerminalView\] Theme applied')) { + throw "$DebugLog has no line matching '\[TerminalView\] Theme applied', so the bundle started but its UI never finished coming up. A trimmed-away type reached only while building the terminal view looks exactly like this." +} + +$guiSpawned = Test-Line $DebugLog $spawnPattern +$daemonStarted = Test-Line $DebugLog '\[Mux\] starting the multiplexer daemon' +$daemonServing = Test-Line $MuxLog '\[MuxDaemon\] serving' +$daemonSpawned = Test-Line $MuxLog $spawnPattern + +if ($daemonStarted) { + if (-not ($daemonServing -and $daemonSpawned)) { + throw "The GUI started the multiplexer daemon but the daemon path is incomplete: $MuxLog serving=$daemonServing, spawned a shell=$daemonSpawned (GUI-side spawn=$guiSpawned, which does not count: the GUI falls back to a local shell when the daemon fails). The AOT daemon (ntilde mux serve) is broken." + } + Write-Output "The GUI started the multiplexer daemon and the daemon spawned the shell." +} +elseif ($guiSpawned) { + Write-Output "The shell was spawned in the GUI process (persistence off, no daemon started)." +} +else { + throw "No shell spawn found: $DebugLog has no '$spawnPattern' line and the GUI never started the multiplexer daemon. The bundle started but cannot open a shell." +} diff --git a/scripts/ci/mux-protocol-range.sh b/scripts/ci/mux-protocol-range.sh new file mode 100644 index 000000000..7624aff12 --- /dev/null +++ b/scripts/ci/mux-protocol-range.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Prints the multiplexer protocol range this source tree builds, as `-` (e.g. `1-2`): the literals of +# MuxProtocol.MinSupportedVersion and MaxSupportedVersion in src/Ntilde.Mux.Contracts/MuxProtocol.cs, or in the file +# given as $1. release.yml puts it into every `vpk pack`'s release notes as ``, +# which an installed app reads from a staged update to decide whether the update keeps its running multiplexer +# (Phase 5 R10; MuxUpdateCompatibility.ParseProtocolRange). +# +# A release must never ship a guessed range, so anything not read for certain fails, with nothing on stdout: a +# constant missing, assigned more than once (a comment that looks like an assignment counts), or not a 1-9 digit +# integer literal (`= SessionEventsVersion;`), or a range that is not 1 <= min <= max. +# +# Tested by scripts/tests/mux_protocol_range_tests.py. Runs under macOS's bash 3.2 and BSD grep/sed too. +set -eu + +file="${1:-$(cd "$(dirname "$0")/../.." && pwd)/src/Ntilde.Mux.Contracts/MuxProtocol.cs}" + +fail() { + echo "mux-protocol-range: $*" >&2 + exit 1 +} + +[ -f "$file" ] || fail "$file not found" + +# The one line assigning constant $1 (`==` is a comparison, not an assignment), and its integer literal. +read_constant() { + name="$1" + lines="$(grep -E "(^|[^A-Za-z0-9_])${name}[[:space:]]*=([^=]|$)" "$file" || true)" + count="$(printf '%s' "$lines" | grep -c . || true)" + [ "$count" = "1" ] || fail "expected exactly one assignment of $name in $file, found ${count:-0}" + value="$(printf '%s\n' "$lines" | sed -nE "s/.*${name}[[:space:]]*=[[:space:]]*([0-9]{1,9})[[:space:]]*;.*/\1/p")" + [ -n "$value" ] || fail "$name in $file is not an integer literal: $(printf '%s' "$lines" | tr -d '\r')" + echo "$((10#$value))" +} + +min="$(read_constant MinSupportedVersion)" || exit 1 +max="$(read_constant MaxSupportedVersion)" || exit 1 +[ "$min" -ge 1 ] || fail "MinSupportedVersion is $min; protocol versions start at 1" +[ "$min" -le "$max" ] || fail "MinSupportedVersion $min is above MaxSupportedVersion $max" +printf '%s-%s\n' "$min" "$max" diff --git a/scripts/ci/mux-release-guard.sh b/scripts/ci/mux-release-guard.sh new file mode 100644 index 000000000..9de12de59 --- /dev/null +++ b/scripts/ci/mux-release-guard.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Keeps a release run from replacing a published ntilde-mux (Codex review of PR #511, P1). Every installed App +# pins the SHA-256 of its own version's ntilde-mux- (Phase 5 Task 10, R16). A rerun for a tag that already +# has them signs the macOS binary again, with a new secure timestamp, so it gets new bytes and a new hash, and +# replacing the asset breaks remote installs on macOS hosts for every App of that version. So a run refuses: +# +# mux-release-guard.sh preflight +# In release.yml's release_metadata, which every other job needs, so before anything is built, signed or +# uploaded: fails when the tag's GitHub release already carries any ntilde-mux asset. A release that does +# not exist yet passes. Any other gh failure, or an answer that is not the expected JSON, fails closed. +# +# mux-release-guard.sh uploaded +# In publish_mux_daemon, right after its upload, whose `overwrite_files: false` the pinned +# softprops/action-gh-release treats as "skip the existing asset and succeed". Reads that step's `assets` +# output (the assets it really uploaded) from $UPLOADED_ASSETS and fails unless both ntilde-mux- and +# ntilde-mux-.sha256 are in it, so a leg rerun on its own (which skips release_metadata) never hands +# the App builds a checksum for bytes the release does not carry. +# +# The tag and rid are only ever data (a workflow_dispatch input can hold shell metacharacters). +# Needs jq, and gh for preflight. Tested by scripts/tests/mux_release_guard_tests.py. Runs under macOS's bash 3.2. +set -eu + +fail() { + echo "::error::mux-release-guard: $*" >&2 + exit 1 +} + +usage() { + fail "usage: mux-release-guard.sh preflight | uploaded " +} + +preflight() { + tag="$1" + err="$(mktemp)" + trap 'rm -f "$err"' EXIT + status=0 + out="$(gh release view "$tag" --json assets 2>"$err")" || status=$? + if [ "$status" != "0" ]; then + # gh's own error for a tag with no release (its ErrReleaseNotFound). Anything else is not an answer. + if grep -qF "release not found" "$err"; then + echo "mux-release-guard: no release for $tag yet, so no ntilde-mux asset to replace" + return 0 + fi + fail "could not read the release for $tag (gh exit $status): $(tr -d '\r' <"$err")" + fi + out="$(printf '%s' "$out" | tr -d '\r')" + [ -n "$out" ] || fail "gh printed nothing for the release $tag" + names="$(printf '%s' "$out" | jq -r 'if (.assets | type) == "array" then .assets[] | .name | strings | select(startswith("ntilde-mux")) else error("no assets list") end')" \ + || fail "could not read gh's answer for the release $tag: $(printf '%s' "$out" | head -c 200)" + names="$(printf '%s' "$names" | tr -d '\r' | tr '\n' ' ' | sed 's/ *$//')" + if [ -n "$names" ]; then + fail "ntilde-mux assets already exist for $tag ($names); installed apps pin their hashes. Publish a new version instead of rerunning. (To finish a run that stopped part-way, use \"Re-run failed jobs\" on that run.)" + fi + echo "mux-release-guard: the release $tag has no ntilde-mux asset yet" +} + +uploaded() { + rid="$1" + json="$(printf '%s' "${UPLOADED_ASSETS-}" | tr -d '\r')" + [ -n "$json" ] || fail "no upload output to check (UPLOADED_ASSETS is empty)" + names="$(printf '%s' "$json" | jq -r 'if type == "array" then .[] | .name | strings else error("not a list") end')" \ + || fail "could not read the upload output: $(printf '%s' "$json" | head -c 200)" + names="$(printf '%s\n' "$names" | tr -d '\r')" + missing="" + for want in "ntilde-mux-$rid" "ntilde-mux-$rid.sha256"; do + printf '%s\n' "$names" | grep -qxF -- "$want" || missing="$missing $want" + done + if [ -n "$missing" ]; then + fail "this run did not upload$missing: the release already had it from an earlier run, and it was left as it is. Installed apps pin their hashes, and this run's checksum would not describe the published binary. Publish a new version instead of rerunning." + fi + echo "mux-release-guard: uploaded ntilde-mux-$rid and ntilde-mux-$rid.sha256" +} + +[ "$#" -eq 2 ] || usage +[ -n "$2" ] || usage +command -v jq >/dev/null 2>&1 || fail "jq not found" +case "$1" in + preflight) preflight "$2" ;; + uploaded) uploaded "$2" ;; + *) usage ;; +esac diff --git a/scripts/mux-update-survival.ps1 b/scripts/mux-update-survival.ps1 new file mode 100644 index 000000000..ec662c10f --- /dev/null +++ b/scripts/mux-update-survival.ps1 @@ -0,0 +1,957 @@ +#Requires -Version 7.2 +<# +.SYNOPSIS + Update-survival evidence for the local multiplexer (Phase 5 Task 24, spec R9/R10): a real Velopack update, applied + while the daemon runs, in a sandboxed install, with the daemon's shells intact afterwards. + +.DESCRIPTION + Windows only. Everything happens under -Sandbox: + 1. AOT-publishes src/Ntilde.App as .1 and .2, as release.yml's win-x64 lane does, with ntilde.com + beside Ntilde.exe. -SkipBuild reuses publish\\ from an earlier run. + 2. Packs both with vpk 1.2.0 (installed into \tools, not globally) as packId NtildeSurvival, never + NtildeApp, into feed\. The release notes carry the multiplexer protocol marker, and no shortcuts are made. + 3. Installs .1 silently into install\. + 4. Starts the installed GUI with NTILDE_APPDATA_ROOT=\data and NTILDE_UPDATE_SOURCE_DIR=\feed, + and a settings file with SessionPersistence=KeepOnClose. The GUI starts the daemon from its copy under + data\bin\\. + 5. Starts two sessions with `ntilde mux spawn-for-test`. Each runs a heartbeat: a line every second into a file. + 6. Lists them with `ntilde mux ls`. + 7. Lets the GUI's own update check stage .2 from the local feed, closes the GUI (its sessions are kept), and + starts it again. Velopack's startup auto-apply applies .2 and restarts the app as .2: a daemon running from + its copy outside the install root does not veto the startup apply. + 8. Checks the result: + - current\sq.version is .2; + - the daemon's pid is unchanged, and its image is under data\bin\<.1>\; + - both heartbeats kept advancing across the apply; + - `ntilde mux ls` lists the same session ids; + - whether the new GUI raised the "multiplexer is from the previous build" notice (Task 23). + 9. Prints the no-overlap path's manual steps. That path needs the in-app apply, which this script cannot drive. + 10. Uninstalls with the daemon and the new GUI running. Reports the order of Velopack's kill pass and our uninstall + hook, and whether the hook stopped the daemon. + + Once the sandbox is confirmed as this script's own, a finally block always cleans up: it stops what the sandbox + started, uninstalls, and puts the user PATH, the Uninstall key and shortcuts back as they were. It stops processes + by pid only, and only those whose image is under the sandbox. A refusal before that (a sandbox that is not the + script's, or one an earlier run left installed) exits without cleaning anything. + + SAFETY. This script never touches %LOCALAPPDATA%\NtildeApp or %LOCALAPPDATA%\ntilde, nor the user's own ntilde + processes, daemon or pipes, nor the Windows Credential Manager. + - The sandbox must be the script's own: a new or empty folder, which it marks with .ntilde-survival-sandbox on first + use, or a folder carrying that marker. It must lie under %TEMP% unless -AllowAnyLocation is given, and it may + never be, or contain, the user profile, %LOCALAPPDATA%, %APPDATA%, %TEMP%, Windows, Program Files or the + repository, nor overlap the real install or data folder. Recursive deletes happen only inside a marked sandbox. + - Every process it starts gets NTILDE_APPDATA_ROOT, and on Windows the processes Velopack starts inherit it: its + install, update and uninstall hooks, and the restart after an apply (measured with Velopack 1.2.0). + - The builds are packed as NtildeSurvival, the only install for which the app honours NTILDE_UPDATE_SOURCE_DIR. + - The sandbox GUI's settings turn off the two things that are global per user and not keyed by NTILDE_APPDATA_ROOT: + the agent host's pipe (ntilde-agent-) and the quake-mode global hotkey. The multiplexer's pipe name is + derived from the root. + +.PARAMETER Sandbox + The folder everything goes into. It must not contain whitespace (the heartbeat command line), must be new, empty or + already marked as this script's sandbox, and must not hold an install from an earlier run (clean that up with + -CleanupOnly). + +.PARAMETER AllowAnyLocation + Accept a sandbox outside %TEMP%. The other location rules still apply. + +.PARAMETER VersionPrefix + The builds are .1 and .2 (and .3 with -LeaveRunning). + +.PARAMETER SkipBuild + Reuse \publish\\ from an earlier run instead of publishing again (each AOT publish takes about + 10 minutes). + +.PARAMETER BuildOnly + Publish and stop: no pack, no install. + +.PARAMETER LeaveRunning + For the no-overlap path's manual steps. After step 8, pack .3 (the .2 build, with the marker 3-3), + then stop without uninstalling, leaving the .2 GUI and the daemon running. Clean up afterwards with -CleanupOnly. + +.PARAMETER CleanupOnly + Only clean up a sandbox an earlier run left: stop its daemon, uninstall, and restore what the install changed. + +.EXAMPLE + scripts/mux-update-survival.ps1 -Sandbox C:\Temp\ntilde-survival +#> +[CmdletBinding()] +param( + [string] $Sandbox = (Join-Path ([IO.Path]::GetTempPath()) 'ntilde-update-survival'), + [string] $VersionPrefix = '0.12.0-survival', + [switch] $SkipBuild, + [switch] $BuildOnly, + [switch] $LeaveRunning, + [switch] $CleanupOnly, + [switch] $AllowAnyLocation +) + +Set-StrictMode -Version 1.0 +$ErrorActionPreference = 'Stop' + +if (-not $IsWindows) { throw 'mux-update-survival.ps1 runs on Windows; scripts/mux-update-survival.sh is the macOS and Linux run.' } + +# ---- names and folders --------------------------------------------------------------------------------------------- + +$PackId = 'NtildeSurvival' # never NtildeApp: that is the real install's identity, and its folder under %LOCALAPPDATA% +$PackTitle = 'Ntilde Survival' +$V1 = "$VersionPrefix.1" +$V2 = "$VersionPrefix.2" +$V3 = "$VersionPrefix.3" +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + +$S = [IO.Path]::GetFullPath($Sandbox).TrimEnd('\') +$SandboxPrefix = $S + '\' +$Publish = Join-Path $S 'publish' +$Feed = Join-Path $S 'feed' +$SetupDir = Join-Path $S "setup-$V1" +$Install = Join-Path $S 'install' +$CurrentDir = Join-Path $Install 'current' +$CurrentExe = Join-Path $CurrentDir 'Ntilde.exe' +$UpdateExe = Join-Path $Install 'Update.exe' +$Data = Join-Path $S 'data' +$Tools = Join-Path $S 'tools' +$Shells = Join-Path $S 'shells' +$Evidence = Join-Path $S 'evidence' +$RunLog = Join-Path $Evidence ('survival-run-{0:yyyyMMdd-HHmmss}.log' -f (Get-Date)) +$SnapshotFile = Join-Path $Evidence 'side-effects-before.json' + +$RealInstall = Join-Path $env:LOCALAPPDATA 'NtildeApp' +$RealData = Join-Path $env:LOCALAPPDATA 'ntilde' +$UninstallKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\$PackId" +$VelopackAppLog = Join-Path $env:LOCALAPPDATA "velopack\velopack_$PackId.log" +$VelopackSharedLog = Join-Path $env:LOCALAPPDATA 'velopack\velopack.log' +$VelopackTemp = Join-Path $env:TEMP "velopack_$PackId" + +$Marker = Join-Path $S '.ntilde-survival-sandbox' + +# Whether $Child is $Parent or lies under it. +function Test-Inside([string] $Child, [string] $Parent) { + return ($Child.TrimEnd('\') + '\').StartsWith($Parent.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase) +} + +# ---- the sandbox must be this script's own (ruling R4) ------------------------------------------------------------- +# Everything here runs before anything is written and before the cleanup is armed: a refusal changes nothing. + +# Never in or around the real install or data; and never a folder that is, or holds, the profile, the system, the +# programs or the repository, since every process whose image is under the sandbox may be stopped. +foreach ($real in @($RealInstall, $RealData)) { + if ((Test-Inside $S $real) -or (Test-Inside $real $S)) { throw "The sandbox $S overlaps $real, which this script must never touch." } +} +$tempRoots = @([IO.Path]::GetTempPath(), $env:TEMP) | Where-Object { $_ } | ForEach-Object { [IO.Path]::GetFullPath($_).TrimEnd('\') } +foreach ($wide in @($env:USERPROFILE, $env:LOCALAPPDATA, $env:APPDATA, $env:SystemRoot, $env:ProgramFiles, ${env:ProgramFiles(x86)}, $RepoRoot) + $tempRoots) { + if ($wide -and (Test-Inside $wide $S)) { throw "The sandbox $S is, or contains, $wide. Choose a folder of its own (for example under %TEMP%)." } +} +# An allow-list of places, not only a deny-list: under %TEMP%, unless the caller says otherwise. +if (-not $AllowAnyLocation -and -not @($tempRoots | Where-Object { (Test-Inside $S $_) -and $S -ne $_ }).Count) { + throw "The sandbox $S is not under %TEMP% ($($tempRoots -join ', ')). Choose a folder there, or pass -AllowAnyLocation." +} +if ($S -match '\s') { throw "The sandbox path must not contain whitespace (it is in the heartbeat sessions' command line): $S" } + +# Ours: a new or empty folder (marked now), or one carrying the marker. Anything else is someone's folder. +if (Test-Path -LiteralPath $S -PathType Leaf) { throw "The sandbox $S is a file." } +$sandboxIsNew = -not (Test-Path -LiteralPath $S) -or -not @(Get-ChildItem -LiteralPath $S -Force).Count +if (-not $sandboxIsNew -and -not (Test-Path -LiteralPath $Marker -PathType Leaf)) { + throw "$S is not empty and does not carry $([IO.Path]::GetFileName($Marker)): it is not this script's sandbox. Choose a new or empty folder." +} +if ($sandboxIsNew -and $CleanupOnly) { + [Console]::Out.WriteLine("$S is not a sandbox of this script's yet: there is nothing to clean up.") + exit 0 +} +if ($sandboxIsNew) { + New-Item -ItemType Directory -Force -Path $S | Out-Null + [IO.File]::WriteAllText($Marker, "A sandbox of scripts/mux-update-survival.ps1. Anything in this folder may be deleted by it.`r`n") +} + +# Recursive deletes of the script's fixed-name folders: only inside a marked sandbox, and never the sandbox itself. +function Remove-SandboxFolder([string] $Path) { + $full = [IO.Path]::GetFullPath($Path).TrimEnd('\') + if (-not (Test-Path -LiteralPath $Marker -PathType Leaf)) { throw "Refusing to delete $full`: $S is not a marked sandbox." } + if (-not (Test-Inside $full $S) -or $full -eq $S) { throw "Refusing to delete $full`: it is not inside the sandbox $S." } + if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Recurse -Force } +} + +New-Item -ItemType Directory -Force -Path $Evidence | Out-Null + +# ---- output ---------------------------------------------------------------------------------------------------------- + +function Say([string] $Text = '') { + $line = '[{0:HH:mm:ss.fff}] {1}' -f (Get-Date), $Text + [Console]::Out.WriteLine($line) + Add-Content -LiteralPath $RunLog -Value $line -Encoding utf8 +} + +function Section([string] $Title) { + Say '' + Say "=== $Title ===" +} + +$Checks = [Collections.Generic.List[object]]::new() +function Check([string] $Name, [bool] $Ok, [string] $Detail = '') { + $Checks.Add([pscustomobject]@{ Name = $Name; Ok = $Ok; Detail = $Detail }) + $verdict = if ($Ok) { 'PASS' } else { 'FAIL' } + if ($Detail) { Say "$verdict $Name - $Detail" } else { Say "$verdict $Name" } +} + +# ---- processes ------------------------------------------------------------------------------------------------------- + +function Test-UnderSandbox([string] $Path) { + if ([string]::IsNullOrWhiteSpace($Path)) { return $false } + try { return [IO.Path]::GetFullPath($Path).StartsWith($SandboxPrefix, [StringComparison]::OrdinalIgnoreCase) } catch { return $false } +} + +function Get-ProcInfo([int] $Id) { + return Get-CimInstance Win32_Process -Filter "ProcessId=$Id" -ErrorAction SilentlyContinue +} + +function Get-SandboxProcesses { + return @(Get-CimInstance Win32_Process | Where-Object { Test-UnderSandbox $_.ExecutablePath }) +} + +# By pid, and only a process whose image is under the sandbox. +function Stop-SandboxProcess([int] $Id, [string] $Why) { + $proc = Get-ProcInfo $Id + if (-not $proc) { return } + if (-not (Test-UnderSandbox $proc.ExecutablePath)) { + Say "REFUSED to stop pid $Id ($($proc.ExecutablePath)): its image is not under the sandbox" + return + } + Say "stopping pid $Id ($($proc.ExecutablePath)): $Why" + Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue +} + +function Test-Alive([int] $Id) { + return $null -ne (Get-Process -Id $Id -ErrorAction SilentlyContinue) +} + +# Runs a console-style command and captures its output; ProcessStartInfo.ArgumentList quotes each argument. +function Invoke-Exe([string] $File, [string[]] $Arguments, [int] $TimeoutSeconds = 60) { + $psi = [Diagnostics.ProcessStartInfo]::new($File) + foreach ($a in $Arguments) { $psi.ArgumentList.Add($a) } + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + $psi.WorkingDirectory = $S + $proc = [Diagnostics.Process]::Start($psi) + $out = $proc.StandardOutput.ReadToEndAsync() + $err = $proc.StandardError.ReadToEndAsync() + if (-not $proc.WaitForExit($TimeoutSeconds * 1000)) { + Stop-SandboxProcess $proc.Id "it did not finish within $TimeoutSeconds s" + throw "$File $($Arguments -join ' ') did not finish within $TimeoutSeconds s" + } + $proc.WaitForExit() + # A child that inherited the pipes (Update.exe's scheduled rmdir) can hold them a little longer. + [void] [Threading.Tasks.Task]::WaitAll(@($out, $err), 15000) + $stdout = if ($out.IsCompleted) { $out.Result } else { '' } + $stderr = if ($err.IsCompleted) { $err.Result } else { '' } + return [pscustomobject]@{ Code = $proc.ExitCode; Out = $stdout; Err = $stderr } +} + +# The GUI, started as Velopack starts it: no arguments, working directory current\. +function Start-Gui { + $psi = [Diagnostics.ProcessStartInfo]::new($CurrentExe) + $psi.UseShellExecute = $false + $psi.WorkingDirectory = $CurrentDir + return [Diagnostics.Process]::Start($psi) +} + +function Get-GuiProcesses { + return @(Get-CimInstance Win32_Process -Filter "Name='Ntilde.exe'" | Where-Object { + $_.ExecutablePath -and ($_.ExecutablePath -ieq $CurrentExe) -and ($_.CommandLine -notmatch '\s(mux|--veloapp-)') + }) +} + +function Get-ImageVersion([int] $Id) { + try { return (Get-Process -Id $Id -ErrorAction Stop).MainModule.FileVersionInfo.ProductVersion } catch { return $null } +} + +# ---- files ----------------------------------------------------------------------------------------------------------- + +# Read with every share flag, as the app reads its descriptor, so a writer's atomic replace never fails on us. +function Read-SharedText([string] $Path) { + if (-not (Test-Path -LiteralPath $Path)) { return $null } + $fs = [IO.FileStream]::new($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete) + try { return [IO.StreamReader]::new($fs).ReadToEnd() } finally { $fs.Dispose() } +} + +function Read-Descriptor { + $text = Read-SharedText (Join-Path $Data 'mux\mux-endpoint.json') + if (-not $text) { return $null } + try { return $text | ConvertFrom-Json } catch { return $null } +} + +function Get-SqVersion { + $sq = Join-Path $CurrentDir 'sq.version' + if (-not (Test-Path -LiteralPath $sq)) { return $null } + try { return ([xml](Read-SharedText $sq)).package.metadata.version } catch { return $null } +} + +function Get-FileLength([string] $Path) { + $item = Get-Item -LiteralPath $Path -ErrorAction SilentlyContinue + if ($item) { return $item.Length } else { return 0 } +} + +# What a file gained since $Offset bytes. +function Read-Since([string] $Path, [long] $Offset) { + if (-not (Test-Path -LiteralPath $Path)) { return '' } + $fs = [IO.FileStream]::new($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete) + try { + if ($Offset -ge $fs.Length) { return '' } + [void] $fs.Seek($Offset, [IO.SeekOrigin]::Begin) + return [IO.StreamReader]::new($fs, [Text.UTF8Encoding]::new($false)).ReadToEnd() + } + finally { $fs.Dispose() } +} + +function Wait-Until([scriptblock] $Condition, [int] $TimeoutSeconds, [int] $PollMilliseconds = 500) { + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $value = & $Condition + if ($value) { return $value } + Start-Sleep -Milliseconds $PollMilliseconds + } + return $null +} + +function Find-LogLine([string] $Path, [string] $Pattern) { + $text = Read-SharedText $Path + if (-not $text) { return $null } + return ($text -split "`r?`n" | Where-Object { $_ -match $Pattern } | Select-Object -First 1) +} + +# ---- heartbeats ------------------------------------------------------------------------------------------------------ + +# A heartbeat line is "