Repository navigation
Multiplexer Phase 5: default-on, update survival, windowless agents, remote picker, release - #511
Conversation
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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
…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>
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>
|
… 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>
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
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>
|



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 --allreach 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.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.docs/superpowers/plans/2026-10-08-ntilde-mux-phase5.md.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.
MuxClientNonBlockingSendTests,MuxLocalCloseStalledLinkTestsSshAskPassVaultPolicyTests,RemoteMuxHostFactoryTests,OpenSshExecCommandLineTestsTrustHost, and a store that cannot be read is never overwrittenNativeKnownHostsStoreTestsRemoteMuxCommandquotes a recorded path that has spaces or non-ASCII characters, instead of replacing itRemoteMuxCommandTestsSshAskPassVaultOnlyTests,RemoteMuxFailureClassifierTests,NativeSshPromptResponderTests$XDG_DATA_HOMERemoteMuxInstallCommandsTests,RemoteMuxCommandTestsscripts/tests/aot_gate_paths_tests.pySSH_AUTH_SOCKlink. The proxy repoints it on connect, and the target must be a live socket owned by this user.AgentSocketLinkTests,MuxProxyTests,LocalShellSessionFactoryTestsNativeSshExecTransportTests, Rustevent_wait_testsntilde-muxon macOS is signed and notarized, with the notarization status checked; the App pins thentilde-muxSHA-256sMuxAssetSourceTests,MuxAssetPinsTests§3: the agent host sees windowless sessions (Part B)
readScreenas an optional protocol method. A v1 daemon answers "unsupported", and the agent tools say so.list_sessionsmarks them windowless.read_screen,read_scrollback,get_session_status,capture_screen(render),send_inputandclose_sessiontake their ids. Every read appears in the activity journal.§1: the default (Part C)
SessionPersistenceis KeepOnClose by default for local shells. Off behaves exactly as before Phase 5.§2: sessions survive app updates (Part D)
<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.<!-- 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.NtildeSurvivalinstall (scripts/mux-update-survival.ps1/.sh). The daemon kept its pid and both heartbeat shells.§5: remote endpoints (Part E)
ntilde mux ls --alllists this computer and every profile that keeps sessions:--json;§6: SFTP and remote files on persistent tabs (Part F)
§7: release (Part G)
CHANGELOG.md, and a release-notes draft indocs/announcements/2026-10-09-persistent-sessions.md.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):
cargo testUpdate survival (Windows), re-run at the head on fresh AOT builds: 34 of 34 checks passed. Velopack 1.2.0 applied
.1→.2in 2.0 s while the daemon ran from its copy. The daemon's pid stayed the same, both heartbeat shells kept advancing, andntilde mux lslisted 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):
ls --all, plus Linux CLI,ls --all, the jump-host skip, the agent's windowless session, and the GUI kill under Xvfb.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:Fixed in one fix round, plus a residual round after its scoped re-review:
ntilde-muxwith 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.ntilde-muxis 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.Dispatcher.UIThread(the #81 hang mechanism).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.
debug.lognow 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.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.
ntilde-muxagain. The new timestamp gives new bytes, and the upload overwrites the asset. Installed apps pin the original hash, so they reject it.ntilde-muxassets are refused.scripts/ci/mux-release-guard.sh preflightruns inrelease_metadata, before anything is built, signed or uploaded, and fails closed on any unexpectedghanswer. The upload usesoverwrite_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 fakegh, plusReleaseWorkflowTests.0700before 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.Results on d733bdc:
The askpass POSIX tests passed 132/132 in the Linux Docker image.
Notes for the maintainer
rebase --ontochecked 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".dev-mux→main:scripts/mux-update-survival.sh), which has not been run anywhere;ls --allin 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.list_sessionsfor windowless sessions, by up to about 4 s per hung host (7 s in all).CHANGELOG.mdis not wired into the release body.release.ymlstill 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
RemoteFailureKindhas no "cancelled" cause.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.logand 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 lsandntilde-mux lsprint session titles uncleaned.Ntilde.Muxneeds its own Clean.The offline Copy install command still trusts the release's own
.sha256. Embed the pinned hash.RustPtySessionprepends/k chcp 65001to any shell whose path ends incmd.exe, somycmd.exegets it too. Compare the file name instead.ntilde mux attachdisconnects 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.CaptureLiveAsynchas an older off-threadDispatcher.UIThread.InvokeAsyncread. It predates this phase but is the same mechanism.kill-servercounts 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
CHANGELOG.mdto the release body.SettingsToolstext quotes the checkbox without "(ntilde-mux)". This was left alone so that dropping the flip stays clean.ls --all --jsonendpoint id uses theN-format GUID (no dashes); the manual doesn't say which form.🤖 Generated with Claude Code