Skip to content

Phase 5: remote agent observation — forwarded muxad, per-host badges, Mac-side alarms#4

Merged
jiunbae merged 4 commits into
mainfrom
amux-phase5-remote-observe
Jul 9, 2026
Merged

Phase 5: remote agent observation — forwarded muxad, per-host badges, Mac-side alarms#4
jiunbae merged 4 commits into
mainfrom
amux-phase5-remote-observe

Conversation

@jiunbae

@jiunbae jiunbae commented Jul 9, 2026

Copy link
Copy Markdown
Member

Phase 5: remote agent observation (ssh → tmux → agent, observed on the Mac)

The mirroring half of amux already handled remote hosts; the observation half was local-only — every muxad join was scoped to .localAmux, so the primary target workflow (ssh → tmux → claude) showed the terminal with zero agent state, and completion alarms fired on the remote host instead of the Mac. This PR closes that gap end-to-end.

What's new

  • RemoteTmuxHost+MuxaForward — local forward socket path under ~/.cmux/ssh/ (same slug+connectionHash scheme and AF_UNIX byte budget as the ControlMaster path), a remote probe that mirrors muxad's own bind rule ($XDG_RUNTIME_DIR/muxa.sock, else /tmp/muxa-<uid>.sock; prefers a live socket, else predicts the default so the forward works the moment the daemon starts), and the ssh -N -L invocation (multiplexed over the existing ControlMaster, ExitOnForwardFailure=yes).
  • AmuxRemoteMuxaForwarder (actor) — holds one host's muxad socket forward open; the status service's reconnect loop calls ensureForward() before every connect, so a dead forward heals on the existing retry cadence.
  • AmuxAgentObservationHub — composes the local status service with one forwarder+service pair per SSH host that currently has a live mirror. Reconciled from RemoteTmuxController.mirrorTopologyDidChange (a didSet on sessionMirrors); aggregates attendTarget() (longest-blocked across all daemons) and agentPane(inWorkspace:), so attend, the prompt composer, and the choice sheet now work for remote agents too.
  • Host-scoped joinsmirrorWorkspace(hostId:sessionName:) / (hostId:containingPane:); the .localAmux joins now delegate to them, and two hosts with same-named sessions can never cross-match.
  • AmuxAgentAlarmPolicy — state transitions become workspace-scoped cmux notifications: entering waiting_input/waiting_choice/error, plus working → idle (finished), with a per-agent+state 20s cooldown. Remote agents now alert on the Mac; local agents gain the same completion alarm. 3 new title keys localized in all 19 locales.
  • debug.amux.mirror_ssh (DEBUG-only RPC) — mirror ONE named session on an SSH host, so E2E against a shared machine doesn't attach to every session like remote.tmux.mirror does.

E2E evidence (ssh localhost as the remote host, live muxad v3)

  1. debug.amux.mirror_ssh {"host":"localhost","session":"amux-e2e-remote"} → topology hook fired, observer created, ~/.cmux/ssh/muxa-localhost-….sock bound and answering muxad protocol (v3 hello).
  2. user_prompt_submit hook in the remote pane → sidebar badge 1 working on the mirror workspace (pane-id join; the installed daemon doesn't emit tmux_session yet).
  3. notification (permission_prompt) → badge 1 waiting and notification.store.add alarm with the permission message as body.
  4. user_prompt_submitstop → finished alarm (working → idle).
  5. amux.attend → selected the remote mirror workspace.
  6. Workspace close → observer torn down: ssh -N gone, forward socket unlinked.

Unit tests: AmuxMuxaForwardTests (9) + AmuxAgentAlarmPolicyTests (9), wired into the pbxproj (test-wiring lint passes), all green via cmux-unit.

Localization audit

New user-facing strings are the 3 alarm titles — added to Resources/Localizable.xcstrings for all 19 locales. Alarm subtitle/body are runtime data (workspace title, agent text). The new debug-only RPC's invalid_params messages are bare English, matching the sibling debug.amux.parse_choices precedent.

Post-E2E hardening (follow-up commits on this branch)

  • Finished-alarm body prefers the turn's prompt over a stale needs-input notification (caught live in E2E).
  • App-termination teardown: applicationWillTerminate stops the hub; AmuxRemoteMuxaForwarder.stop() is nonisolated over an OSAllocatedUnfairLock-held process handle (lock carve-out with justification) so teardown can run synchronously where an actor hop might never be scheduled.
  • Forward liveness = socket connectability, not process liveness. Observed against OpenSSH: a multiplexed ssh -N -L registers the forwarding with the ControlMaster and exits — the master owns the listener (verified with lsof: the listener fds sit on the [mux] master). ensureForward() therefore treats a connectable local socket as already-forwarded (re-requesting would stack listeners on the master), and teardown is unlink-the-path (the master's orphaned listener fd dies with the master's ControlPersist window).

Known limitations / follow-ups

  • Pane-id fallback join can cross-match panes across tmux servers until muxa PR feat: carry tmux socket + session name on agent rows muxa#60 (tmux_session/socket on the wire) ships and daemons upgrade — same pre-existing limitation as the local join, now documented per-host.
  • Remote onboarding: the remote host must already run muxad with agent hooks wired (muxa init there). Wiring hooks over SSH from amux onboarding is a follow-up.
  • Double alarms: if the remote muxad also has desktop notify enabled it alerts on the remote host as well (harmless — different machine). Local muxa notify + cmux alarm dedup remains an onboarding preset task (plan §4.3).
  • SSH mirror close still kills the session (pre-existing upstream semantics; localAmux got detach-by-default in Phase 3). Aligning SSH mirrors with detach-by-default is a separate change.
  • last_response DTO + prompt/response text in the GUI is the next slice (option b from the review).

🤖 Generated with Claude Code

jiunbae and others added 4 commits July 9, 2026 10:37
…larms (Phase 5)

The mirroring half already handled remote hosts, but agent observation was
local-only: every muxad join was scoped to the .localAmux engine, so an
ssh->tmux->claude workflow showed the terminal without any agent state.

- RemoteTmuxHost+MuxaForward: local forward socket path (AF_UNIX budget,
  slug+connectionHash scheme), remote muxad socket probe (mirrors muxad's
  XDG/tmp bind rule) + stdout parser, ssh -N -L invocation builder.
- AmuxRemoteMuxaForwarder: actor holding one host's muxad socket forward
  open over the existing SSH ControlMaster; respawned lazily by the status
  service's reconnect loop via prepareConnection.
- AmuxAgentObservationHub: composes the local status service with one
  forwarder+service pair per SSH host that has a live mirror; reconciles on
  RemoteTmuxController.mirrorTopologyDidChange (sessionMirrors didSet) and
  aggregates attendTarget/agentPane across daemons.
- RemoteTmuxController: host-scoped joins mirrorWorkspace(hostId:...) that
  the localAmux joins now delegate to, plus activeSSHMirrorHosts().
- AmuxAgentAlarmPolicy: waiting_input/waiting_choice/error entry and
  working->idle (finished) transitions become workspace-scoped cmux
  notifications with per-agent-state cooldown — remote agents now alert on
  this Mac instead of only on the remote host; local agents gain the same
  completion alarm.
- debug.amux.mirror_ssh: DEBUG-only single-session SSH mirror RPC so E2E
  against a shared host doesn't mirror every session like remote.tmux.mirror.
- Localization: 3 new alarm title keys x 19 locales.
- Tests: AmuxMuxaForwardTests (9) + AmuxAgentAlarmPolicyTests (8), wired
  into cmux.xcodeproj, all passing via cmux-unit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…input notification

Verified live in the ssh-localhost E2E: the working->idle alarm was showing
the earlier permission-request message instead of what the turn was about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An ssh -N mux client left behind by a quit pins the host's ControlMaster
past its ControlPersist window (an attached session stops the countdown),
so applicationWillTerminate now stops the observation hub, and the
forwarder's stop() is nonisolated over an OSAllocatedUnfairLock-held
process handle (lock carve-out: an actor hop scheduled during termination
may never run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rocess

Observed against OpenSSH: a multiplexed `ssh -N -L` registers the forward
with the ControlMaster and exits — the master owns the listener. So
ensureForward() now treats a connectable local socket as 'already
forwarded' (re-requesting would stack listeners on the master), the
readiness wait no longer reads process exit as failure, and stop()/the
app-termination hook are documented as unlink-the-path teardown (the
master's orphaned listener fd dies with the master's ControlPersist
window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jiunbae
jiunbae merged commit 22920a1 into main Jul 9, 2026
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