Skip to content

Multiplexer Phase 5: default-on, update survival, windowless agents, remote picker, release - #511

Merged
benyblack merged 98 commits into
dev-muxfrom
feat/mux-phase5
Oct 9, 2026
Merged

benyblack merged 98 commits into
dev-muxfrom
feat/mux-phase5

Conversation

@benyblack

@benyblack benyblack commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Multiplexer Phase 5. Local shells now keep running by default, survive app updates, and agents can reach them without a window. "Attach to session…" and ntilde mux ls --all reach remote hosts, and persistent native SSH tabs get Remote Files and SFTP. Includes the 13 review items from the Phase 5 brief, a single user-manual chapter, a CHANGELOG, and the bump to 0.12.0.

It builds on the sync PR #510, already merged into dev-mux.

  • Spec: docs/superpowers/specs/2026-10-08-ntilde-mux-phase5.md. This is the brief plus §R: rulings R1–R30, made while the work was done.
  • Plan (31 tasks): docs/superpowers/plans/2026-10-08-ntilde-mux-phase5.md.
  • User manual: chapter 12, Persistent sessions and the multiplexer.
  • Manual checklist: docs/superpowers/plans/2026-10-08-ntilde-mux-phase5-manual-checklist.md.

Every task went through an implementer, a task review and fix rounds with scoped re-reviews. A whole-branch review followed, with one fix round of its own (see Final review below).

By brief section

§4: the review items (Part A)

Each item landed with a test that failed first.

Brief item Commit(s) Pinning test
Sends from the UI thread never block on a full outbound queue. This covers the local close kill, detach and leave, the faulted session's kill on reconnect, input, and resize. 6f82d3c MuxClientNonBlockingSendTests, MuxLocalCloseStalledLinkTests
No vault password is offered to a prompt a jump host could send (OpenSSH askpass, interactive) 3b39e5a, d6919ff SshAskPassVaultPolicyTests, RemoteMuxHostFactoryTests, OpenSshExecCommandLineTests
Native known hosts: one store per file, an atomic TrustHost, and a store that cannot be read is never overwritten 403de65, 5b9c8b0 NativeKnownHostsStoreTests
RemoteMuxCommand quotes a recorded path that has spaces or non-ASCII characters, instead of replacing it 6e516a3, da6a4a6 RemoteMuxCommandTests
The askpass marker folder is 0700; control and bidi characters are stripped from server text in toasts; the native response payload is zeroed 5bc3050 SshAskPassVaultOnlyTests, RemoteMuxFailureClassifierTests, NativeSshPromptResponderTests
The install directory honours $XDG_DATA_HOME fcd1120 RemoteMuxInstallCommandsTests, RemoteMuxCommandTests
CI runs the remote-persistence E2E when only its tests change 5681207 scripts/tests/aot_gate_paths_tests.py
Remote shells get a stable SSH_AUTH_SOCK link. The proxy repoints it on connect, and the target must be a live socket owned by this user. 8a6fd8a, cec9520 AgentSocketLinkTests, MuxProxyTests, LocalShellSessionFactoryTests
Native exec waits for events instead of sleeping 10 ms cc734f1, d86bd41 NativeSshExecTransportTests, Rust event_wait_tests
ntilde-mux on macOS is signed and notarized, with the notarization status checked; the App pins the ntilde-mux SHA-256s fad1d08, 70d137d MuxAssetSourceTests, MuxAssetPinsTests

§3: the agent host sees windowless sessions (Part B)

  • The daemon gains readScreen as an optional protocol method. A v1 daemon answers "unsupported", and the agent tools say so.
  • The window publishes the sessions no pane shows. list_sessions marks them windowless. read_screen, read_scrollback, get_session_status, capture_screen (render), send_input and close_session take their ids. Every read appears in the activity journal.

§1: the default (Part C)

  • SessionPersistence is KeepOnClose by default for local shells. Off behaves exactly as before Phase 5.
  • Closing the window: the first close asks Keep running / Close them, with Don't ask again.
  • Quit and close all: Session: Quit and Close All Shells, plus a link under the setting.
  • After a reboot, tabs start fresh shells quietly. The boundary is the later of the boot time and, on Windows, the logon time. If the boundary can't be read, the change is announced.
  • Tests and the designer windows pin persistence Off, so a test can never start a daemon.

§2: sessions survive app updates (Part D)

  • The daemon reports its build on the wire and in its descriptor. Both fields are optional.
  • On a Windows Velopack install the daemon runs from a copy at <app-data>\bin\<version>\. Velopack's apply kills every process whose image is under the install root, so this copy is what keeps it alive (measured, R9). Old copies are pruned. Uninstall stops the daemon and removes its copies.
  • A compatible daemon is kept when an update is applied. Releases carry <!-- ntilde-mux-protocol: <min>-<max> --> in their notes, and the App keeps the daemon when the ranges overlap. An incompatible update asks first and closes the sessions. Startup auto-apply is vetoed only when the daemon runs from inside the install root, or with persistence Off.
  • A daemon from another build raises a notice: "Multiplexer is from the previous build" (or a newer one, or a different one), with Restart multiplexer now. Panes are let go before the shutdown and reconnect to the new daemon.
  • Evidence: a real Velopack update, applied with the daemon live, in a sandboxed NtildeSurvival install (scripts/mux-update-survival.ps1 / .sh). The daemon kept its pid and both heartbeat shells.

§5: remote endpoints (Part E)

  • "Attach to session…" lists local and remote sessions. Each profile that keeps sessions but isn't connected gets a row Connect to user@host…, which shows Connecting to user@host… while it works. Unreachable hosts show a reason taken from a fixed cause list.
  • Shared remote tabs stay shared through drops. A share whose sharing can't be confirmed right now detaches on close and never queues a kill.
  • ntilde mux ls --all lists this computer and every profile that keeps sessions:
    • it runs in parallel, with 15 s per host;
    • it never prompts, and never pushes off a GUI connection;
    • it has a HOST column and --json;
    • on Linux and macOS it skips OpenSSH profiles that go through a jump host or proxy (R28b).

§6: SFTP and remote files on persistent tabs (Part F)

  • Native persistent tabs register with a per-host password scope, so Remote Files, path completion and transfers work as they do on plain native tabs. Only a password the user typed, in an attempt that got in, is kept:
    • never one filled from the vault;
    • never an empty one;
    • never on a jump-host profile (R7, R30).
  • OpenSSH persistent tabs get palette transfers.
  • Retargeted hosts: once the profile's host has been changed since the tab opened, the sidebar and transfers are refused.
  • Deferred: port forwards, and OpenSSH ControlMaster reuse (R8).

§7: release (Part G)

  • The user manual gets chapter 12, plus a README bullet and subsection.
  • ARCHITECTURE and MODULE_OWNERSHIP are updated.
  • New CHANGELOG.md, and a release-notes draft in docs/announcements/2026-10-09-persistent-sessions.md.
  • The version is now 0.12.0 (release: 0.12.0). Tagging is the maintainer's step.

Tests

On 0887ad2, the head before the final fix round (both App.Tests lanes were run again on the final head; see Final review):

Suite Passed Failed Skipped
VT 860 0 0
Rendering 114 0 0
Architecture 113 0 0
Platform 632 0 32
McpServer 202 0 0
Mux 957 0 41
App main lane 6069 0 29
App PlatformBoot lane 59 0 4
rusty_ssh cargo test 135 0 0

Update survival (Windows), re-run at the head on fresh AOT builds: 34 of 34 checks passed. Velopack 1.2.0 applied .1 → .2 in 2.0 s while the daemon ran from its copy. The daemon's pid stayed the same, both heartbeat shells kept advancing, and ntilde mux ls listed the same ids. The new GUI offered the restart. The uninstall hook stopped the daemon, and the user PATH came back byte-identical. An earlier Linux run in Docker (AppImage, FUSE, Xvfb) passed 15 of 15.

Manual checklist (159 cells):

  • 13 PASS, run by the implementer: Windows CLI and ls --all, plus Linux CLI, ls --all, the jump-host skip, the agent's windowless session, and the GUI kill under Xvfb.
  • 0 FAIL.
  • 136 OPEN for the maintainer: the GUI steps on Windows and Linux, and all of macOS.
  • 10 N/A.

Final review

A whole-branch review ran on the most capable model, over f529f6c..0887ad2. It found no Critical issues, one Important and six Minor, and ruled on each of the roughly 75 items parked during the tasks. Every hard constraint held:

  • the credential path adds no replay, vault or empty-password behaviour;
  • the protocol change is additive and checked against the base daemon;
  • layering is unchanged;
  • with persistence Off, both update paths behave as before Phase 5;
  • every kill path re-checks its target.

Fixed in one fix round, plus a residual round after its scoped re-review:

Finding Fix Commit
I1. The remote "previous version" notice and its Restart compared the running ntilde-mux with the app's version. After every app update this nagged on every host, and Restart killed the host's shells only to start the same old binary again. Restart is offered only when the version recorded as installed on the host is newer than the running one. When the host's ntilde-mux is simply older than the app, the notice offers Update ntilde-mux on {host}… instead. Once a remote shutdown has been sent, the host is not offered a restart again in that launch. After an install, the restart is offered straight away. 8022c2f, ec21b7f
M2. The MCP footnote said a windowless id "works with every session tool". It now names the six tools that take one. 650fc27
M1. Two new off-thread reads of Dispatcher.UIThread (the #81 hang mechanism). Both go through the window's own dispatcher. adaaf36
M3. "Close them" and the first-close count missed the shells of restored tabs not shown yet. These are counted and ended, except shares. The list is taken again just before ending. The close hold is released on every path. 259d00c, 990d458, 869218f
M5. A nonsense boot time could crash startup. It now reads as "no boundary", which is the loud side. 3b19cd4
A manual paragraph described a toggle that does not exist. Corrected. d379582

Both App.Tests lanes passed on the final head: main 6112 passed, 0 failed (29 skipped), PlatformBoot 59 passed, 0 failed (4 skipped).

PR review round

The review round fixed Greptile's three findings and a maintainer heads-up about the default and startup updates. A scoped re-review checked each fix, and a residual round followed.

Finding Fix Commits
Greptile P1: "Close them" missed startup tabs not built yet, because their saved shells live in the placeholder's tree. The hold, the count, the re-list and the kill pass all include placeholder ids. Shares, quiet-lost ids and remote ids are excluded. A null child in a hand-edited file is skipped. 01ce6a5, cbfc799, 1e9d258
Greptile P2: stopping the whole daemon (quit and close all, or an update that cannot keep it) left unvisited tabs' ids in the session file, so those tabs reported "previous session lost". Those ids are dropped from the save. The quit drops them only once they are known gone: the stop went through, or each shell was killed by name. Otherwise they stay named and reopen in their tabs. c6f8efd, cbfc799
Greptile P2: an agent listing windowless sessions did not light the window's activity indicator. A listing that returns windowless rows lights it. 3ca832f
Heads-up: turning the default on would stop updates installing at startup unless the update-survival work ships too. In this PR both ship. The startup gate holds an update back only for a daemon whose image is inside the install folder, or when persistence is Off. The re-run at the head proved the startup apply goes through with the daemon live. A test now pins it: default settings and a daemon image outside the root do not block, and an image inside the root does. The residual case is the daemon falling back to the install folder because its own copy could not be staged. There, debug.log now says why the startup apply was held. The "Restart to update" question names the real reason ("running from the install folder") instead of the protocol one. The two boolean wrappers that only tests called are gone. 3c1c2eb, b98f7cb, 041082d, 5c2147b

Both App.Tests lanes passed on the final head: main 6149 passed, 0 failed (29 skipped), PlatformBoot 59 passed, 0 failed (4 skipped). Architecture 113 passed, 0 failed. McpServer passed 202/0 on 041082d.

Codex review round

Codex reviewed 5c2147b and raised three findings. All three were valid, all in code from this phase, and all are fixed.

Finding Fix Commit
P1: rerunning a published release breaks macOS installs. A rerun for an already-published tag signs the macOS ntilde-mux again. The new timestamp gives new bytes, and the upload overwrites the asset. Installed apps pin the original hash, so they reject it. Reruns of a tag that already has ntilde-mux assets are refused. scripts/ci/mux-release-guard.sh preflight runs in release_metadata, before anything is built, signed or uploaded, and fails closed on any unexpected gh answer. The upload uses overwrite_files: false. At the pinned action version that setting skips an existing asset rather than failing, so the job then fails unless both of its assets were really uploaded. A run that stopped partway is finished with "Re-run failed jobs". Tests: 26 cases with a fake gh, plus ReleaseWorkflowTests. d9ae916
P1: a refused password stays in the transfer scope. The host's SFTP and listing connections kept offering a password the server had refused, for example after a password rotation, which could pile up failed logins. A remembered password the host forgets as refused also leaves the host's scope, under the server it was offered to. It is removed only while the scope still holds that same value, and under the same generation check as the write. 131c827
P2: an existing askpass folder is not owner-only after an upgrade. The askpass writer tightens an existing folder to 0700 before writing any marker. If that fails (not ours, or a link), it writes nothing and askpass takes its no-evidence path. PrivateDirectory's rule for the daemon's socket folder is unchanged. Spec R14 is amended to match. 752e2fc, d733bdc

Results on d733bdc:

Suite Passed Failed Skipped
App.Tests main lane 6163 0 32
App.Tests PlatformBoot lane 59 0 4
Platform 632 0 32
Mux 957 0 41
Architecture 115 0 0

The askpass POSIX tests passed 132/132 in the Linux Docker image.

Notes for the maintainer

  • The default needs the update-survival work (Part D). Never ship 5756680 (the flip) without Tasks 20-22. With shells kept and no daemon copy, a daemon would nearly always run from the install folder, and every startup update would be held back. In history the flip comes before Part D, so don't cherry-pick it on its own.
  • Dropping the default. If you decide against the on-by-default flip, drop 5756680 (the flip) and 80fdeb0 (the docs lines that state the default). Both drop cleanly; a trial rebase --onto checked that. Then reword three commit-1 sentences that read as default behaviour: the README "Why Ntilde?" bullet, CHANGELOG.md:10, and the announcement's line 27. The remaining docs will be neutral rather than saying "off by default".
  • Before dev-mux → main:
    • run the macOS update-survival run (scripts/mux-update-survival.sh), which has not been run anywhere;
    • run the no-overlap in-app apply path (checklist step 38);
    • go through the OPEN checklist cells.
  • macOS Cmd+Q never asks (R19). Mac users who quit the usual way are never told their shells keep running.
  • ls --all in a script loop. Each run is a new attempt with no memory of a refusal. A loop with a stale saved password offers it once per run, which fail2ban-style lockouts may count.
  • A slow remote host delays list_sessions for windowless sessions, by up to about 4 s per hung host (7 s in all).
  • Startup auto-apply ends other Ntilde windows. Velopack's apply kills every process whose image is under the install root, so any other Ntilde process running from the install goes too. This is unchanged from before Phase 5; the daemon is safe because it runs from its copy.
  • CHANGELOG.md is not wired into the release body. release.yml still uses GitHub's generated notes plus the protocol marker. This is a follow-up.

Follow-ups (not blocking)

These were triaged by the final review. None blocks the merge into dev-mux.

UX

  • Restart and quit
    • A restart shows no progress feedback (about 12 s).
    • A new tab or split during a local restart can land on the old daemon. A host-level restart flag would cover it.
    • Enter on a focused Close them button still answers Keep running.
    • Cmd+Q with a remembered "Close them" leaves the shells of tabs not yet shown running. Nothing is lost: they come back as restored tabs.
    • Quit-and-close-all, and an update that cannot keep the daemon, leave the ids of tabs not yet shown in the session file. Those tabs then show "previous session lost" at the next launch.
  • Remote
    • Cancelling an SSH prompt from a Connect to… row shows "Could not connect". RemoteFailureKind has no "cancelled" cause.
    • The transfer dialog's path completion is not checked against a changed (retargeted) host. It only lists; no transfer starts.
    • When a pinned profile's host has changed, the connector's record of the installed version can lag (it errs toward no restart).
  • Restore after logout: on macOS, and on Linux hosts with KillUserProcesses=yes, the quiet-restore boundary misses a logout without a reboot. It errs on the loud side.

Hardening

  • When the daemon cannot stage its own copy, it runs from the install folder, and with shells kept every startup update is held back. debug.log and the update question say why, but no notice says so when it happens. A once-per-launch notice needs about 60 lines of plumbing from the spawner to the window.

  • A Task 23 local restart marks no ended ids, so a shell that outlives a failed stop is still reopened. After a successful restart, unvisited tabs therefore say "previous session lost".

  • The accepted corner of the update path: a daemon whose image cannot be checked (UnknownImage), that really runs outside the root, and that survives a failed shutdown. It is documented in ARCHITECTURE.

  • Copy the daemon image in Velopack's install/update callback instead of inside the first pane's connect wait, and cache the image's hash. On a slow disk with Defender scanning, the first tab could otherwise fall back to a non-persistent shell.

  • Plain ntilde mux ls and ntilde-mux ls print session titles uncleaned. Ntilde.Mux needs its own Clean.

  • The offline Copy install command still trusts the release's own .sha256. Embed the pinned hash.

  • RustPtySession prepends /k chcp 65001 to any shell whose path ends in cmd.exe, so mycmd.exe gets it too. Compare the file name instead.

  • ntilde mux attach disconnects on a paste over about 9 MiB (send overflow). It needs a blocking-send variant for producers that aren't on the UI thread.

  • Host normalisation is asymmetric for a bracketed or ported target against an identical hop ([::1], :port). This is contrived, but it is on the credential path.

  • CaptureLiveAsync has an older off-thread Dispatcher.UIThread.InvokeAsync read. It predates this phase but is the same mechanism.

  • kill-server counts a zombie daemon as running, in containers without an init process.

  • SFTP and listing refusals clear nothing. An SFTP or listing connection's own refusal does not clear the scope, and plain native tabs keep a typed password until the tab closes. Only the mux host's refusal clears it (Codex round).

Docs and tests

  • Connect CHANGELOG.md to the release body.
  • The MCP SettingsTools text quotes the checkbox without "(ntilde-mux)". This was left alone so that dropping the flip stays clean.
  • The ls --all --json endpoint id uses the N-format GUID (no dashes); the manual doesn't say which form.
  • Remaining test-strength and doc-wording nits are listed per task in the review's triage table.
  • The VT throughput benchmark floor flakes under full-suite load.

🤖 Generated with Claude Code

benyblack and others added 17 commits October 8, 2026 12:19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MuxClient sent through a BlockingCollection bounded at 1024 frames, so on a
stalled link every send blocked its caller once the queue was full. The UI
thread sends through it: a local daemon that stopped reading froze the
window for as long as it stalled, with no liveness ping to end it. Phase 4's
B1 moved only remote close kills off the UI thread.

Now every send returns at once (ruling R6). A frame that finds the queue
full, or frames already waiting, joins an overflow FIFO; one pool work item
per client moves it into the queue head first, and the head leaves the
overflow only once the queue has taken it, so frames keep their call order
across posts and requests. Past MuxClientOptions.MaxOverflowBytes (8 MiB) of
waiting payload the client disconnects with reason "send overflow", pending
requests fail with IOException, and the host's reconnect or drop path takes
over. A request on a disconnected client still fails with IOException; a
post is still dropped.

Call sites no longer able to block on the link:
- the local close kill, MainWindow.KillMuxSessionOnClose (mux.KillAsync)
- Detach/Leave in MainWindow.DisposeControlTree
- Reconnect's Kill and Dispose in TerminalPane
- input: TerminalView, TerminalPane and MainWindow SendInput calls
- resize and presentation updates in TerminalPane
- the reader thread's DetachAttach of an abandoned attach

B1's MuxConnectionHost.HandOffKill stays: now redundant, and harmless.

FullSendQueue moves to Ntilde.Mux.Tests/Support and is linked into
App.Tests as MuxSupport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the target

A user's OpenSSH attempt through a jump host now runs the askpass helper without
the vault (NTILDE_SSH_ASKPASS_NO_VAULT) when the local ssh does not prefix
keyboard-interactive prompts (before 8.4, or unknown) or a hop's user@host equals
the target's, so a bastion cannot collect the target's saved password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A proxy named in ExtraSshArgs fails closed at any ssh version, and a hop Host typed
as user@host or host:port is normalised as the config compiler emits it before it
is compared with the target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per-path process-wide lock shared by every instance, ForPath() shared
instances, tmp+rename writes, and an unparseable store is kept aside as
.corrupt-<timestamp> (newest 5 kept) instead of being overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Load now distinguishes no file, unreadable and corrupt. Reads retry 5x20ms;
a persistent read failure, a failed aside-move, or an unparseable store makes
TrustHost throw IOException without writing, and CheckHost return Unknown.
A literal JSON null is treated as corrupt. SshInteractionService catches the
IOException so a failed trust never crashes the host-key dialog flow. The torn
write test now reads the file directly, bypassing the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The remote install dir was hard-coded to $HOME/.local/share/ntilde/bin, but the
daemon root follows $XDG_DATA_HOME when it is set and absolute. The install
scripts, the default-path fallback of the remote command, the offline one-liner
(now one single-quoted sh -c script) and the UI copy share one RemoteInstallDir
expression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A recorded RemoteDaemonPath outside [A-Za-z0-9._/+-] was silently replaced by the
default install path. Any absolute path without ', \, !, a control or a Unicode
format character is now run as sh -c 'exec "<path>" ...' with $, ` and " escaped
for sh's double quotes; a refused path falls back to the default install dir and
the connector logs that once per attempt. IsSafeAbsolutePath is now
IsQuotableAbsolutePath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refused-character check looked at UTF-16 chars, so format characters above
U+FFFF (U+E0001, the TAG range) and unpaired surrogates passed. It now walks
runes and refuses control, format, line and paragraph separator characters and
any unpaired surrogate. The offline one-liner also prints where it installed,
RemoteInstallDir.Display is the final wording, the install button tooltip is
pinned to it by a test, and the test source holds no literal bidi characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d prompt answers

- PrivateDirectory (Mux.Contracts) lifts MuxDiscovery's 0700 creation; the askpass marker folder uses it.
- RemoteOutputText drops control, format and separator runes and unpaired surrogates; the failure classifier and the unavailable-toast message use it.
- The native prompt responder zeroes its response payload after submit, and SubmitResponse no longer copies it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aot_gate_detect matched only src/ and build inputs, so a PR touching just the
remote-persistence E2E tests got no linux-x64 ntilde-mux binary; the step then
skipped with a notice and the job stayed green. The path rule moves to
scripts/ci/aot-gate-paths.sh and gains directory-scoped test entries, with a
self-test wired into the same job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rent

The standalone ntilde-mux daemon inherits SSH_AUTH_SOCK from the proxy that
spawned it, which goes dead when that ssh connection closes. Its shells now get
SSH_AUTH_SOCK=<endpoint dir>/agent.sock, and every proxy --stdio repoints that
symlink (atomic rename) to its own live agent socket. Unix only; the GUI's own
daemon is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r's socket

The liveness probe connects non-blocking, so a hung agent with a full backlog is
"not live" instead of hanging every proxy. After connecting it compares the
listener's peer credentials with geteuid() and fails closed when it cannot.
The daemon no longer logs before its stdio is reassigned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The native exec poll thread slept 10 ms whenever PollEvent found nothing, a
~15 ms floor on remote-mux attach latency. rusty_ssh gains nova_ssh_wait_event,
which parks on a condvar until an event is queued, the session closes, or the
timeout (clamped to 1 s) passes; queue_event and mark_closed notify it. The
exec PollLoop calls it through INativeSshInterop.WaitForEvent (a default
interface method that sleeps as before, so test fakes are unchanged) with a
bounded 100 ms wait so _stop and handle release are still seen promptly.
NOVA_SSH_RESULT_TIMEOUT (-10) is appended to the status codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in the app

publish_mux_daemon signs and notarizes ntilde-mux-osx-arm64 (secret-gated, with a
::notice:: on forks) before the checksum is written, and uploads each .sha256 as a
workflow artifact. publish_aot and publish_linux embed them in the App via
NtildeMuxSha256Dir; GitHubReleaseMuxAssetSource refuses a download or cached copy
that disagrees with its pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check notarytool's JSON status rather than its exit code, set an explicit codesign
identifier, document the re-run hazard, test that the csproj resource name matches
MuxAssetPins.ResourcePrefix and that a pin mismatch fails before the binary download,
and say in the manual which hash a release build checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 24da4b33-7946-4306-a5a1-beabaff89ef7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

benyblack and others added 12 commits October 8, 2026 14:59
…n MuxClient

The agent host must see sessions that no window shows (Phase 5 Part B).
The daemon gains an optional readScreen method. It returns the headless
screen as TerminalStateSerializer bytes, with the session's status taken
on the same parse thread, plus LastOutputUnixMs, stamped once per output
chunk. It clamps the row count to 0..2000 and never answers
protocol_error, so a client can read that code as "unsupported" on any
negotiated version.

Refusals: unknown_session, internal_error (faulted), session_exited
(dropped item) and snapshot_too_large (past 4 MiB, which keeps the
reply inside the stream budget).

MuxClient gains ReadScreenAsync, decoded by the same DecodeSnapshot
and MuxAttachLimits as attach, plus GetSessionInfoAsync and
SendInputTo, which uses the single non-blocking send path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wait for the unlocked reader's first read before writing, and keep writing
until it has read at least 20 times during the writes (capped), so a fast
runner can no longer finish every write before the reader runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…IOException

TrustHost documents IOException with nothing written, but on Windows a
reader holding the file open made the rename throw UnauthorizedAccessException
out of the retries. Wrap it in IOException, widen the retry budget to
10 x 25 ms, and cover it with a Windows-only test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re not "unsupported"

A readScreen reply is a Response frame carrying a snapshot of up to
4 MiB, about 5.6 MB as base64. It was charged to the connection's
16 MiB stream account. Three maximum reads at once, or one on a GUI
connection with a stream backlog, dropped the connection, and every
pane on it, as client_too_slow.

MuxOutboundFrame gains a creator-set bulk mark (IsBulk, MarkBulk). The
connection charges and refunds bulk frames to the snapshot account,
as it does Snapshot frames. The readScreen reply is marked and stays a
Response on the wire.

On the client, a readScreen result or snapshot that does not decode
now throws MuxClient.MalformedReplyCode ("malformed_reply", never sent
on the wire). It no longer throws protocol_error, which a caller reads
as "unsupported". Unsupported is a null return only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 5 spec §3, ruling R4 (Task 12). IWindowlessSessionSource lists, reads,
sends input to, kills and resolves the daemon sessions no pane of the window
shows or is about to show; MuxWindowlessSessions implements it over the
window's MuxConnectionHosts.

- Only hosts whose CurrentClient is live are asked, in parallel, each call
  with its own 4 s timeout; never GetClient or GetOrCreate, so the agent path
  never connects or prompts. A host that fails or times out is left out of
  the listing (logged once per call) and makes a lookup Unreachable.
- Shown-here (endpoint, id) pairs and faulted sessions are excluded; an id
  listed by two endpoints is ambiguous: not listed, NotFound, logged.
- A read too large for one reply is asked again with half the scrollback
  rows, down to 0; a daemon without readScreen gives Unsupported with the
  status from sessionInfo. Input and kill check Running first.
- MainWindow builds one source when its mux hosts exist; its shownHere is a
  Dispatcher.UIThread.InvokeAsync over every pane (live and pending ids, every
  endpoint). It is published to AgentHostService.SetWindowlessSource (a
  store-only seam for Task 13) next to SetActionExecutor, and cleared on
  teardown and Stop.
- MuxConnectionHosts.AllByEndpoint pairs each host with its endpoint id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he survey

Task 12 review, fix round 1.

- Each public call of MuxWindowlessSessions has one OperationBudget (7 s),
  inside the MCP server's 10 s round trip. Every daemon call, and the
  window's shownHere, waits min(CallTimeout, what is left); a read's halving
  retries and its sessionInfo fallback, and a kill, stop when the budget
  runs out: Unreachable, logged once. The caller's own cancellation still
  propagates as OperationCanceledException.
- SendInputAsync / KillAsync take a mayAct(SshProfileId) check, run on the
  act's own look-up before the running check: false (or a throw) is the new
  WindowlessOutcome.NotAllowed and nothing is sent. ResolveAsync stays for
  callers that need only the profile.
- shownHere is asked once every listing is in, so a pane that attached or
  spawned meanwhile is counted (a new remote tab still holds no id while its
  spawn runs; noted in the code).
- One aggregated ambiguity log line per call; a call's timeout message says
  "no answer within N s" only when its own timer fired.
- Tests: the budget under slow refusals and stalled calls, mayAct refusal and
  the profile it gets, shownHere ordering, the fail-closed window paths, and
  the hang test's bound relaxed to 2 x CallTimeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ess sessions

The agent host now serves daemon sessions no pane of the window shows
(Phase 5 spec section 3, rulings R4 and R5) through the window's
IWindowlessSessionSource. Every per-session handler asks the registry
first, so a pane id never reaches the source and pane behaviour is
unchanged.

- listSessions appends windowless rows (windowless: true, endpoint,
  host as profileName); pane rows carry no new keys on the wire.
- readScreen / readScrollback / getSessionStatus import the daemon's
  snapshot into a private buffer and reuse the pane code; scrollback is
  the newest 2000 rows; status is heuristic from the daemon's facts.
- captureScreen render borrows a measured pane's font metrics and
  theme; live mode is captureUnavailable.
- sendInput and closeSession pass the act check into the source's own
  look-up: local needs the act toggle, a remote endpoint its SSH
  profile allowlist, for kill too (R5).
- Windowless reads are journaled and light the window light through a
  decaying WindowlessWatched flag; pane reads stay unjournaled.
- New error code "unsupported" for a daemon too old to read screens.
- MainWindow passes WindowlessWatched to the light, posts its refresh
  through its own dispatcher, and the journal dialog copy names
  windowless reads. A thread-local AgentHostService.Instance override
  lets window tests pin the source's publish and its clear at teardown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ur the toggle

Task 13 review, fix round 1.

- AgentActivityJournal: entries carry Count and Windowless; storage is a
  LinkedList (capacity 200). RecordRead folds a read into an identical
  one (method, id, target, outcome) within the reads since the last act,
  moving it to newest with Count + 1. Acts keep Record and never fold.
  Windowless reads and listings go through RecordRead, so a polling
  agent no longer pushes act records out of the ring.
- The windowless act check is made inside the source's look-up and
  re-reads the act toggle and that the source is still the published
  one: act turned off meanwhile is actDisabled, a withdrawn source is
  actUnavailable, and nothing is sent or killed.
- Journal targets for windowless sessions read "windowless · <host>",
  from a new IWindowlessSessionSource.HostDisplayName (no daemon asked).
  The Agent Activity dialog line (now MainWindow.DescribeAgentActivity)
  calls such an id a session, not a pane, and shows "xN" for folds.
- The outcome-to-error map has an explicit NotFound arm and throws on an
  outcome it does not know (answered as internal). Unreachable after the
  session was found gets act-specific messages: a close says the kill
  was not confirmed and the session may have ended.
- Tests for the NotRunning status branch, Unreachable input and close,
  capture over the pixel budget, and readScrollback on an old daemon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
list_sessions marks windowless rows "(windowless)" with a footnote shown only when one is listed, and the empty message says "no terminal sessions are open". The list/read/status/capture/input/close descriptions each gain one clause. docs/mcp/tools.md, the McpServer README and the agent-host DIRECTION doc describe windowless sessions (R4/R5, 2000-row scrollback, no events, borrowed font metrics).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndows never persist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spec R1. The first time a window closes with live local shells, ntilde
asks "Your shells keep running in the background." with Keep running
(the default) and Close them, plus "Don't ask again". The remembered
answer is the flag file mux-close-choice under the app-data root
(MuxCloseChoiceStore, "keep" or "close"); saving Settings with a changed
session persistence deletes it.

OnClosing holds the close (e.Cancel) and posts the question, so the
answer's re-close never re-enters OnClosing; a second close while the
dialog is up is held, not asked again. Close them ends each local pane's
shell through the local host (EndLocalSessionsOnTeardown), whose
teardown waits for the kill replies before it disconnects, and the
session file no longer names those shells, so the next launch starts
fresh ones quietly instead of reporting them lost.

Only a window close asks. A lifetime shutdown (OSShutdown, and
ApplicationShutdown such as macOS Cmd+Q) never asks and never kills,
whatever was remembered: it behaves like Keep. Persistence off, remote
shells and exited shells never ask. A question that cannot be shown
closes as Keep; a minimized window is restored before the dialog opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…membered answer

Task 16 review, fix round 1:
- "Close them" (asked or remembered) skips a shell another client is also
  typing into: it detaches, as Keep does, like a pane close that never ends
  one without asking.
- An application shutdown (macOS Cmd+Q, TryShutdown) applies a remembered
  answer without asking and keeps with none; an OS shutdown still never kills.
- With the question open, a close with nothing left to keep (the last tab's
  shell exited) goes through instead of being held.
- A question overtaken by a teardown before its post ran is never shown.
- Enter keeps with "Don't ask again" as it stands, wherever focus is (a
  focused CheckBox used to swallow it).
- AppPaths.MuxCloseChoiceFilePath backs the store; the flag file is excluded
  from backups.
- A Settings Import/Restore that changes the persistence mode forgets the
  remembered answer too.
- "Reopen Ntilde to get them back."
- Test windows answer Cancel by default and never open the real modal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
benyblack and others added 8 commits October 9, 2026 11:05
The list footnote told agents a windowless id "works with every session
tool". It works with read_screen, read_scrollback, get_session_status,
capture_screen (render only), send_input and close_session; export_replay
answers sessionNotFound and wait_for_events never reports one (final review
M2). The footnote now names exactly those, and the test pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OnMuxHostConnected is raised on the pool and MuxSessionsShownHereAsync is
called from the agent host's thread; both read the Dispatcher.UIThread
static there. That off-thread read is the #81/#426 mechanism: in headless
App.Tests it can land between the session's reset and setup and make the
pool thread the UI thread for the next test. Both now use the window's own
Dispatcher instance, as OnAgentObserveActivityChanged does (final review M1).

The branch's other new Dispatcher.UIThread use (the first-close question's
post) runs in OnClosing, on the UI thread, and is left as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SessionStartBoundary.Read() caught only IO, access and library exceptions,
but DateTime.UnixEpoch.AddSeconds throws ArgumentOutOfRangeException for a
btime or kern.boottime past DateTime's range, and the window's constructor
reads it with no catch of its own (final review M5).

Read() now catches any exception and returns null (the loud side: every
notice is kept), through a seam the tests drive; the /proc/stat and timeval
parsers also give none for seconds no DateTime can hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first-close count and "Close them" saw only panes with a live
MuxClientSession. A restored tab not visited yet holds its shell's id
pending, so its shell was neither counted nor ended, came back at the next
launch, and with only such tabs no question was asked at all (final review
M3).

- PendingLocalMuxSessionIds: the local ids panes hold pending, never a
  share's (MuxAttachSharedToRestore) or a quiet-lost one, nor one shown live
  or already ended. They count toward the hold, so the question is asked
  when only unvisited tabs have shells.
- Whether each still runs and whether another client shows it, only the
  daemon can say: the close is held while the local host's current
  connection lists them (UnshownLocalMuxSessionsAsync, 2 s, never connects
  or spawns; none when it cannot say), for the question's count and for a
  remembered "Close them" alike. An ApplicationShutdown cannot be held, so
  it ends the live shells only, as before.
- They end through the existing path: _localSessionsEndedOnClose and
  KillWhenConnected in EndLocalSessionsOnTeardown, so the save leaves them
  out. MuxOrphans.IsUnshown is shared for the "nobody shows it" rule.
- Manual 12.2 and ARCHITECTURE say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chapter 9 documented an "Agent screenshots" toggle that does not exist and
said every capture is journaled. Captures (capture_screen) need only Agent
access (observe) and light the pane's agent indicator like any read; the
journal records acting calls, allowed or denied, and reads of windowless
sessions. The replay toggle is named as Settings names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pins the other side of the M3 fix: once the daemon says the pending shells
no longer run, nothing is left to keep and the window closes unasked. The
close in that path is guarded like the answer's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recorded RemoteDaemonVersion is per profile and per computer, so it can
be stale: a second profile for the same host, another computer or a hand
install. A Restart offered whenever running != recorded then ended every
shell on the host to start the same binary again, every launch (residual N1).

- Ruling (a): DecideRemote offers Restart only when the running daemon is
  older than the record (IsBehindInstalled); a newer or unordered one falls
  through to the Update/None rule. The last look checks the same, and the
  remote notice always says "a previous version" (RemoteMessage loses its
  installed parameter and the newer/different wording; local is unchanged).
- Ruling (b): once a remote shutdown was sent, the host's offer stays
  claimed for the launch; it is released only where nothing was stopped.
  A record stale the other way costs at most one restart per launch.
- After a recorded install (the notice's dialog or the editor's),
  DecideMuxNoticeAgainAfterInstall releases that host's offer and decides
  its live connection again, so the Restart replaces the Update notice in
  the same session.
- Tests: the decision table, a running daemon ahead of a stale record, one
  restart per launch against a record stale the other way, Update then
  Restart after the install, and the last look's pane count (N5).
- Manual 12.6, ARCHITECTURE and spec R22 (amended) say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… lets go

- N2: the first-close question can stay open for minutes, and another client
  (ntilde mux attach, another window's Attach to session...) may open a
  pending shell meanwhile. On "Close them" the window asks the daemon again
  just before ending, with the same 2 s bound, re-checks the teardown, and
  ends only what is still shown by nobody. The remembered path already
  lists at close time.
- N3: both posted close flows reset _firstCloseQuestionOpen in a finally,
  so no failure can leave every later close held; the listing's catch takes
  any exception (none is the safe answer). The listing goes through a seam
  (MuxListSessionsForClose) the test makes throw an unplanned type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@benyblack
benyblack marked this pull request as ready for review October 9, 2026 09:57
@greptile-apps

greptile-apps Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Critical impact] Multiplexer Phase 5: persistent sessions, daemon lifecycle, remote mux, and release infrastructure.

Fix the upload guard’s source before merging so manual releases of older commits can finish.

Findings

  1. P1 Older releases stop halfway ▶

Summary

This PR turns local persistence on by default and adds update survival, windowless agent access, remote session picking, and transfers on persistent SSH tabs.

  • Local shells keep running by default when their window closes.
  • Compatible app updates can leave the multiplexer and its shells running.
  • Agents can reach sessions that no window pane shows.
  • The session picker and command-line list now reach remote hosts.
  • Persistent remote tabs can use Remote Files and SSH transfers.
  • Release materials and checks now describe the multiplexer in version 0.12.0.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Workflow ref] --> B[Preflight guard]
  B --> C[Checkout selected release commit]
  C --> D[Build and upload daemon]
  D --> E[Run guard from release checkout]
  E -->|Script exists| F[Pass checksum to app builds]
  E -->|Older commit lacks script| G[Release stops]
Loading

Reviews (3) · Last reviewed commit: "docs(spec): R14 - an existing askpass fo..." · Reviewed by Greptile

Comment thread src/Ntilde.App/MainWindow.axaml.cs Outdated
Comment thread src/Ntilde.App/MainWindow.axaml.cs
Comment thread src/Ntilde.App/AgentHost/AgentHostService.cs
benyblack and others added 9 commits October 9, 2026 12:46
… built yet

PR #511 review (Greptile P1). Startup restore leaves every tab but the
selected one a placeholder until a background pass builds it: no pane,
its shells' ids only in the saved tree (the tab's TabSession). A close
before that pass - a remembered "Close them" included - ended the
visible shells and left those running.

HeldLocalMuxSessionIds now also walks each placeholder's saved tree, so
PendingLocalMuxSessionIds feeds the hold, the count, the re-list (N2)
and the kill pass with them, under the same exclusions: shares,
quiet-lost ids, ids a pane shows live, ids already ended, and (through
the listing) ids another interactive client shows. Kills go through
the existing EndLocalSessionsOnTeardown path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the session

PR #511 review (Greptile P2). MarkLocalShellsEnded marked only live
panes' shells, so "Quit and close all shells" and an update that cannot
keep the daemon left the ids of restored tabs not shown yet - panes
holding them pending, and startup placeholders' saved trees - in the
session file. The next launch then said "previous session lost" for
shells the user had just agreed to end.

It now also marks every id the window holds without a live pane
(HeldLocalMuxSessionIds), shares included: the whole daemon stops, so a
share ends with it (the quit kills it by name, R20), and a live share
pane was always marked here. Task 23's restart is left alone on
purpose: it marks nothing, so a shell that outlives a failed stop is
reopened like any other.

The placeholder test helpers move to TestMainWindowFactory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #511 review (Greptile P2). HandleListSessionsAsync journaled a
listing that returned windowless rows as a read but never called
NoteWindowlessRead, so an agent could poll windowless titles, hosts and
status while the window's activity light stayed idle. It now lights the
window whenever it journals; a listing with no windowless row does
neither.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e shells

PR #511 heads-up. When the multiplexer runs from the install folder
(MuxDaemonImage.Resolve could not stage its own copy) or cannot be
checked, the update has to stop it, but the confirmation said "(the new
version cannot keep them)", the protocol's reason.

MuxUpdateCompatibility.WhyUpdateStopsDaemon now returns a cause
(MuxUpdateStopReason: Protocol, InstallFolder, UnknownImage, or None
when kept; the protocol decides first), KeepsDaemon is "None", and
SessionLossQuestion words each: the protocol case as before, "(the
multiplexer is running from the install folder, so the update has to
stop it)", and "(the multiplexer could not be checked, so the update
has to stop it)" for an unreadable image or descriptor. Persistence Off
still gives no reason. The startup gate's image check now tells the
install folder from an unknown image too (StartupApplyHoldFor), with no
change to what it blocks; the next commit logs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the flip's dependency

PR #511 heads-up, (b) and (c). The startup gate runs before AppLogger
is up, so a held update left no trace. LiveDaemonBlocksStartupApply now
keeps the gate's reason (StartupApplyHoldAt: InstallFolder,
UnknownImage, PersistenceOff), and Main logs it once, right after the
build line; AppLogger is initialised no earlier than before.

The flip to SessionPersistence on depends on the update-survival work:
a test now drives the gate's composition with a settings.json that has
no SessionPersistence key and a live daemon whose image is outside the
install root (does not hold), and the same with the image inside
(holds). Program.LiveDaemonBlocksStartupApplyAt gains an image-lookup
seam for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s log line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…own gone

PR #511 residual M1. The quit marked every held id (pending panes' and
startup placeholders') ended before its stop, whatever the stop did.
With the pre-quit listing timed out (no kill by name) and a stop that
returned NotStopped or NoneReached, those shells kept running unnamed
and came back as orphans instead of in their tabs.

Live panes are still marked first. Held ids are now marked after
ShutdownLocalDaemonAsync, all of them when it returned Gone or
StopUnconfirmed, otherwise only those its KillAsync ended. Still before
Close(): its OnClosing runs PerformAppTeardown, whose
SaveSessionWithoutEndedShells reads the marks. The update path keeps
marking before its shutdown, as it saves before it; ARCHITECTURE names
the accepted corner. The quit's listing goes through the
MuxListSessionsForClose seam, which the tests use to time it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… placeholder's tree

PR #511 residuals M2 and M4.

M2: HeldLocalMuxSessionIds' tree walk now skips a null child, as
DedupeMuxIds, MarkLocalMuxSessionsEndedByReboot and WithoutMuxIds do.
The one-line guard went into cbfc799 with the rest of MainWindow.axaml.cs;
this is its test: a split holding a null closes before it is built
without throwing, and the leaf beside the null is counted and ended.

M4: two rows beside the share row. A quiet-lost leaf, and a remote
(ssh:) leaf whose id the local daemon also runs, are neither counted
nor ended, and both stay named. The remote row's local shell is
user-detached, so startup does not adopt it into a tab of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #511 residual M3. MuxUpdateCompatibility.KeepsDaemon and
BlocksStartupApply, and Program.LiveDaemonBlocksStartupApplyAt, had no
production caller left once the window and Main took the reasons
(WhyUpdateStopsDaemon, StartupApplyHoldFor, StartupApplyHoldAt). They
are deleted. Their tests keep every row and go through the production
functions via small test-side helpers (None keeps / any reason holds).
ARCHITECTURE's update bullet now names WhyUpdateStopsDaemon and
StartupApplyHoldFor instead of the wrappers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@benyblack

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T13:13:38.840133Z 5c2147b Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5c2147b69f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/release.yml
Comment thread src/Ntilde.App/Shell/Mux/Remote/RemoteMuxInteractionHandler.cs
Comment thread src/Ntilde.Mux.Contracts/PrivateDirectory.cs
benyblack and others added 4 commits October 9, 2026 15:30
Every installed App pins the SHA-256 of its version's ntilde-mux-<rid>.
A rerun for a tag that already has them signs the macOS binary again
(new secure timestamp, new bytes, new hash) and the upload replaced the
asset, so remote installs on macOS hosts broke for that version.

- release_metadata, which every job needs, now runs
  scripts/ci/mux-release-guard.sh preflight: the run fails before
  anything is built, signed or uploaded when the tag's release already
  has any ntilde-mux asset. A release that does not exist yet passes;
  any other gh failure fails closed.
- The ntilde-mux upload sets overwrite_files: false. At the pinned
  action SHA (v2.6.2) that skips an existing asset and succeeds, so the
  next step (mux-release-guard.sh uploaded) fails the leg unless both of
  its assets are in the step's assets output, before the checksum is
  handed to the App builds. This covers a leg rerun on its own, which
  skips release_metadata.
- scripts/tests/mux_release_guard_tests.py (fake gh, real jq) runs in
  ci.yml; ReleaseWorkflowTests checks the wiring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Task 28 keeps a typed password that got a remote host in under the
host's password scope, which SFTP, the sidebar and path completion read
before the vault. When a server later refused it (a reconnect after a
password change), the handler forgot it from memory but the scope kept
offering it, piling up failed logins on every transfer connection.

Each password the host offered from memory and then forgets as refused
(a refused attempt, or a prompt that superseded it within an attempt
that then got in another way) now leaves the scope too, under the server
it was offered to, through the new compare-and-remove
ActiveSshSessionRegistry.RemoveRuntimePassword: only while the scope
still holds that value, and under the handler's gate and generation
check, so a late refusal clears nothing written since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a Unix upgrade the askpass folder usually exists already, made by an
earlier build under the umask (often 0755). PrivateDirectory.Create
leaves an existing folder alone by design (the daemon judges its own
socket directory), and the record writer did no check of its own, so
records landed in a listable, possibly group- or other-writable folder.

SshAskPassSessionMarkers now prepares the folder itself before any
record (CanRecord, TryClaim, RecordDeclined): off Windows a symbolic
link in its place is refused, never followed, and a mode other than
exactly 0700 is chmodded to 0700 and read back. chmod works only for the
owner, so a failure means the folder is not ours: nothing is written,
logged once (the app's log, MuxLsAll's log, the helper's stderr). Every
caller already treats that as "cannot record", so the flow ends with no
evidence: no saved password offered or filled, never answered/declined.
PrivateDirectory is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Codex review of PR #511 found the old "not tightened" rule left an
upgraded user's 0755 folder listable; 752e2fc tightens it in the askpass
writer and fails closed when it cannot. PrivateDirectory's own rule for the
daemon's socket folder is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Oct 9, 2026

Copy link
Copy Markdown

Comment thread .github/workflows/release.yml
@benyblack
benyblack merged commit ee75a32 into dev-mux Oct 9, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant