Skip to content

mux: per-surface agent state machine with hook authority and title detection - #7412

Open
lawrencecchen wants to merge 2 commits into
feat-rust-tui-backendfrom
feat-mux-agent-area
Open

lawrencecchen wants to merge 2 commits into
feat-rust-tui-backendfrom
feat-mux-agent-area

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Adds a per-surface agent state machine (AgentState/AgentSource/AgentStore in agent.rs): hook reports (session lifecycle events from coding-agent hooks) take authority over title-based detection.
  • Detection never clears an authoritative hook entry; a hook clear with no detection fallback emits a terminal Done transition; a session-only report (no state) records the session ref without entering the hook state map.
  • Background title-activity detector debounces PTY output and classifies agents by title after a quiet window.
  • New control-socket commands: report-agent, list-agents, notify. New broadcast events: agent-state-changed, notification.
  • Includes scripts/hooks/claude-code-hook.sh, the reference hook wiring Claude Code's Notification/Stop/SessionStart events to report-agent.

Base is feat-rust-tui-backend (PR #7180) since this branch builds on its mux.rs/server.rs changes; will rebase onto main once #7180 merges.

Test plan

  • cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --check all pass.
  • cargo build -p mux-tui succeeds.
  • scripts/smoke-tui.py and scripts/smoke-attach.py pass.
  • New unit tests in agent.rs cover: hook-override-then-clear-then-fallback, detection-does-not-clear-hook-authority, done-on-exit, unknown-is-real-classification, last_change-preserved-on-identical-re-report, detector-quiet-window.
  • New integration tests in tests/pty.rs: control_socket_reports_and_broadcasts_agent_state, control_socket_session_report_does_not_take_hook_authority.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
New background detector thread and event surface on the mux hot path (PTY reader → agent activity), but logic is isolated with unit/integration tests and hook authority rules are explicit.

Overview
Adds per-PTY-surface agent tracking so UIs and attach clients can see which coding agent is on a tab and whether it is working, blocked, idle, or done.

A new agent module defines AgentStore (separate detection vs hook layers, hooks win), title heuristics (agent name in title, braille spinner, “action required”), and a debounced AgentDetector thread fed from PTY output. Mux wires reports, emits AgentStateChanged / Notification, and finishes agent state when a surface exits.

The control socket gains report-agent, list-agents, and notify, with matching subscribe events. PTY children get CMUX_MUX_SURFACE / CMUX_MUX_SURFACE_ID; configurable agent names flow from TUI config into SurfaceOptions. scripts/hooks/claude-code-hook.sh maps Claude Code hook events to report-agent over the unix socket.

Reviewed by Cursor Bugbot for commit 02de262. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Adds a per-surface agent state machine so surfaces can report which coding agent is active and whether it's working, blocked, idle, or done. Hook reports take authority over title-based detection, and a background detector debounces PTY output to classify agents after a quiet window.

New Features

  • Hook reports override detection; clearing a hook with no fallback emits a terminal Done, and session-only reports record the session ref without taking authority.
  • Background detector classifies agents by title after a quiet window (defaults: claude, codex, opencode, pi). Braille spinner ⇒ Working; "Action Required" ⇒ Blocked; otherwise Idle when quiet.
  • Control socket gains report-agent, list-agents, and notify commands plus agent-state-changed and notification broadcast events.
  • tabs.agents config feeds SurfaceOptions.agents; children receive CMUX_MUX_SURFACE/CMUX_MUX_SURFACE_ID.
  • Adds scripts/hooks/claude-code-hook.sh wiring Claude Code's Notification/Stop/SessionStart events to report-agent.

Migration

  • Set custom agent names via tabs.agents; defaults apply when unset.
  • Claude Code users should install the hook script with CMUX_MUX_SOCKET in env.
  • Clients subscribing to events should handle agent-state-changed and notification.

Written for commit 6e22a52. Summary will update on new commits.

Review in cubic

…tection

Adds AgentState/AgentSource/AgentStore in agent.rs: hook reports
(session lifecycle events from coding-agent hooks) take authority
over title-based detection, detection never clears an authoritative
hook entry, a hook clear with no detection fallback emits a terminal
Done transition, and a session-only report (no state) records the
session ref without entering the hook state map. A background
title-activity detector debounces PTY output and classifies agents
by title after a quiet window.

New control-socket commands: report-agent, list-agents, notify.
New broadcast events: agent-state-changed, notification.
Includes scripts/hooks/claude-code-hook.sh as the reference hook
wiring Claude Code's Notification/Stop/SessionStart events to
report-agent.
@vercel

vercel Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cmux Ready Ready Preview Sep 17, 2026 10:04pm UTC
cmux-staging Building Building Preview Sep 17, 2026 10:04pm UTC

@coderabbitai

coderabbitai Bot commented Jul 6, 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: b16ac0f1-8bc1-4b9a-b9f9-49af126b78b9

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-mux-agent-area

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.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 02de262. Configure here.

return None;
}
self.effective.remove(&surface);
None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Detection clear emits no event

Medium Severity

When title detection stops matching an agent and no hook holds authority, clear_detection drops the effective entry but always returns None, so clear_agent_detection never emits AgentStateChanged. Event subscribers can keep showing an agent that list-agents no longer includes.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 02de262. Configure here.

/// reaps the surface out of the tree itself, so frontends only need to
/// drop their render state.
pub fn surface_exited(&self, id: SurfaceId) {
self.finish_agent(id, AgentSource::Detection);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Exit Done mislabels hook source

Medium Severity

On PTY exit, surface_exited always calls finish_agent with AgentSource::Detection, and finish_and_clear puts that parameter on the terminal Done event. Surfaces whose effective state came from hooks are reported as detection when the process exits.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 02de262. Configure here.

@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: 02de2625f4

ℹ️ 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".

if message:
request["custom_status"] = message
elif event == "Stop":
request["state"] = "done"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear Stop hook state instead of persisting it

When Claude emits a Stop in a PTY that stays open, this sends state: done, and report_agent stores that as the effective hook entry. Hook entries continue to override detector updates (AgentStore::apply_detection returns without updating while a hook entry exists), while the later SessionStart report has no state and does not clear hook authority, so starting Claude again in the same surface can leave the agent stuck as done until another hook state such as a notification arrives. Treat Stop as a finish/clear of the hook state rather than a persistent hook report.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a per-surface agent state machine (AgentStore/AgentDetector) to the mux core, with lifecycle hook reports taking authority over background title-based detection. Three new control-socket commands (report-agent, list-agents, notify) and two broadcast events (agent-state-changed, notification) are wired through the server, and scripts/hooks/claude-code-hook.sh provides the reference Claude Code integration.

  • agent.rs implements the two-tier priority store (hook overrides detection, detection fallback on hook-clear) with debounced activity classification; unit tests cover the documented invariants.
  • surface.rs exports CMUX_MUX_SURFACE into every PTY child and forwards every PTY read as an activity event to the detector channel; this per-byte title clone and channel send creates measurable overhead on high-throughput surfaces.
  • The Stop hook event sends {state:"done"} via apply_hook, which leaves hook authority active and silently suppresses title-based detection for the next session on the same surface until the next hook event fires — contradicting the PR's own stated design (hook-clear emits terminal Done).

Confidence Score: 3/5

The hook-authority state machine is sound in its core design, but the Stop event leaves the hook map permanently occupied — new sessions on the same surface won't show their Working state until the next hook write — and a narrow race during surface exit can inject a ghost agent entry and broadcast a spurious state-change event.

The Stop→Done path in the hook script contradicts the design's own stated hook-clear semantics, producing stale Done state visible to all subscribers between consecutive sessions on a reused surface. The surface_exited ordering leaves a window where the detector thread can re-populate the agent store just after it was cleared, creating both a zombie list entry and a false AgentStateChanged broadcast.

mux/scripts/hooks/claude-code-hook.sh (Stop semantics), mux/crates/mux-core/src/mux.rs (surface_exited ordering), mux/crates/mux-core/src/agent.rs (AgentStateChange missing session_ref)

Important Files Changed

Filename Overview
mux/crates/mux-core/src/agent.rs New file: AgentStore state machine with hook/detection priority, AgentDetector debounce logic, and unit tests. Missing session_ref in AgentStateChange; title heuristics are the fallback source of truth when no hook is registered.
mux/crates/mux-core/src/mux.rs Adds agent detector background thread, report_agent/clear_agent_hook/notify public API, and surface_exited finish_agent call. Race between finish_agent and detector thread can produce ghost agent_store entries after surface exit.
mux/scripts/hooks/claude-code-hook.sh Reference hook wiring Claude Code lifecycle events to report-agent. Stop event sends {state:"done"} via apply_hook, leaving hook authority active and blocking detection of the next session on the same surface.
mux/crates/mux-core/src/server.rs Adds ReportAgent, ListAgents, Notify control-socket commands and agent-state-changed/notification broadcast events. Clean implementation; session_ref omission in broadcast is tracked separately.
mux/crates/mux-core/src/surface.rs Sets CMUX_MUX_SURFACE env vars and calls observe_agent_activity on every PTY read. Title is cloned from mutex on every non-title-change read, creating per-byte allocation and channel pressure on busy terminals.
mux/crates/mux-core/tests/pty.rs Adds two integration tests for control socket agent reporting and session-only hook authority semantics. Tests are well-structured and cover the documented invariants.
mux/crates/mux-tui/src/main.rs Threads config.tabs.agents into surface_options; minimal one-line change, correct.
mux/crates/mux-tui/src/session/remote.rs Uses the SUPPORTED_PROTOCOL_VERSION constant in the error message instead of a hardcoded literal — straightforward correctness fix.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    PTY[PTY output byte] --> OBS[observe_agent_activity\nevery read]
    OBS -->|DetectorEvent| DET[AgentDetector thread\ndebounce 150ms / quiet 2s]
    DET -->|DetectorAction::Active\nor Quiet| TITLE[apply_detection_from_title]
    TITLE -->|surface_is_pty check| STORE[AgentStore]

    HOOK[Hook script\nclaude-code-hook.sh] -->|report-agent cmd| SOCK[control socket]
    SOCK -->|ReportAgent| REPORT[Mux::report_agent]
    REPORT -->|source=hook, state=done| AH[apply_hook Done\nhook entry STAYS]
    REPORT -->|source=hook, no agent/state| CH[clear_agent_hook\nfinish_and_clear or detection fallback]
    REPORT -->|source=detection| AD[apply_detection\nsuppressed if hook present]

    AH --> STORE
    CH --> STORE
    AD --> STORE

    STORE -->|hook takes priority| EFF[effective map]
    EFF -->|AgentStateChanged event| SUB[subscribers]

    EXIT[surface_exited] --> FAC[finish_agent / clear store]
    FAC --> CS[close_surface]
    DET -.->|race window| GHOST[ghost re-insert between\nfinish_agent and close_surface]
    GHOST -.-> STORE

    style AH fill:#ffcccc
    style GHOST fill:#ffcccc
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    PTY[PTY output byte] --> OBS[observe_agent_activity\nevery read]
    OBS -->|DetectorEvent| DET[AgentDetector thread\ndebounce 150ms / quiet 2s]
    DET -->|DetectorAction::Active\nor Quiet| TITLE[apply_detection_from_title]
    TITLE -->|surface_is_pty check| STORE[AgentStore]

    HOOK[Hook script\nclaude-code-hook.sh] -->|report-agent cmd| SOCK[control socket]
    SOCK -->|ReportAgent| REPORT[Mux::report_agent]
    REPORT -->|source=hook, state=done| AH[apply_hook Done\nhook entry STAYS]
    REPORT -->|source=hook, no agent/state| CH[clear_agent_hook\nfinish_and_clear or detection fallback]
    REPORT -->|source=detection| AD[apply_detection\nsuppressed if hook present]

    AH --> STORE
    CH --> STORE
    AD --> STORE

    STORE -->|hook takes priority| EFF[effective map]
    EFF -->|AgentStateChanged event| SUB[subscribers]

    EXIT[surface_exited] --> FAC[finish_agent / clear store]
    FAC --> CS[close_surface]
    DET -.->|race window| GHOST[ghost re-insert between\nfinish_agent and close_surface]
    GHOST -.-> STORE

    style AH fill:#ffcccc
    style GHOST fill:#ffcccc
Loading

Reviews (1): Last reviewed commit: "mux: per-surface agent state machine wit..." | Re-trigger Greptile

Comment on lines +79 to +80
elif event == "Stop":
request["state"] = "done"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Stop stores Done in hook map, blocking detection of the next session

Sending {state: "done"} calls apply_hook(Done), which inserts a Done entry into the hook authority map (AgentStore::hook) but never clears it. From that point on, apply_detection always returns None because hook.contains_key(surface) is true (agent.rs line 173). When the user starts a new Claude Code session on the same surface, all title/spinner detection (Working/Idle) is silently suppressed until the next hook event fires — SessionStart only records the session ref, so the effective state stays Done until the first Notification or subsequent hook write.

Per the PR's own design intent ("a hook clear with no detection fallback emits a terminal Done transition"), Stop should use the hook-clear semantic: send {source:"hook"} with no agent and no state, which routes through clear_agent_hook. clear_hook then either falls back to the current detection state (if a new session is already visible in the title) or emits Done via finish_and_clear if nothing is detected — correct in both cases.

Comment on lines 911 to 914
pub fn surface_exited(&self, id: SurfaceId) {
self.finish_agent(id, AgentSource::Detection);
self.close_surface(id);
self.emit(MuxEvent::SurfaceExited(id));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Race between detector thread and surface_exited produces ghost agent entries

surface_exited clears the agent store via finish_agent (line 912), then removes the surface from state via close_surface (line 913). The detector thread holds a Weak<Mux> and can upgrade it at any time. In the window between those two lines, the detector thread can upgrade the weak ref, enter apply_detection_from_title, call surface_is_pty which returns true (surface still in state), re-insert a detection + effective entry into agent_store, and emit a spurious AgentStateChanged(Working) broadcast. After close_surface runs, the surface is gone from state but the newly re-inserted agent_store entry stays, permanently polluting list_agents results with an exited surface.

A robust fix is to filter list_agents output through surface_is_pty, and to re-check in apply_detection_from_title after acquiring the agent lock that the surface is still present.

Comment on lines 276 to +289
@@ -275,6 +284,9 @@ impl Surface {
if !responses.is_empty() {
let _ = surface.write_bytes(&responses);
}
if let Some(mux) = mux.upgrade() {
mux.observe_agent_activity(surface.id, current_title);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Title string cloned and sent to detector channel on every PTY read

When the title hasn't changed (the else branch at line 277), pty.title.lock() is acquired and the title String is cloned, then immediately sent as a new DetectorEvent to the detector channel. This happens for every PTY read, including high-throughput terminal output (large file streams, cat of big files, etc.), where reads can be thousands per second. Each iteration allocates a new String and sends it across the channel.

Since AgentDetector::ingest overwrites the pending entry for the surface anyway, only the latest title matters. Consider gating the observe_agent_activity call on title changes: skip the send when the title hasn't changed and cache the active_reported state so activity is still tracked without the per-byte clone and send.

Comment on lines +111 to +118
pub custom_status: Option<String>,
}

#[derive(Debug, Default)]
pub(crate) struct AgentStore {
detection: HashMap<SurfaceId, AgentInfo>,
hook: HashMap<SurfaceId, AgentInfo>,
effective: HashMap<SurfaceId, AgentInfo>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 AgentStateChange omits session_ref, so Done broadcasts lose session identity

AgentStateChange has no session_ref field. change_from silently drops it, even though finish_and_clear preserves current.session_ref in the done AgentInfo it constructs. A subscriber receiving agent-state-changed for a Done transition cannot identify which Claude Code session just completed without issuing a follow-up list-agents call — but by that point the entry has already been cleared, so the session ref is lost entirely.

Adding session_ref: Option<String> to AgentStateChange and threading it through change_from would let subscribers correlate Done events to specific sessions without a TOCTOU window.

Comment on lines +35 to +49
agents.iter().find(|agent| words.contains(&agent.as_str())).cloned()
}

pub fn title_has_action_required(title: &str) -> bool {
title.to_lowercase().contains("action required")
}

pub fn title_has_braille_spinner(title: &str) -> bool {
title.trim_start().chars().next().is_some_and(|ch| ('\u{2800}'..='\u{28ff}').contains(&ch))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AgentState {
Working,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Title-based detection emits authoritative state changes when hooks are absent

identify_agent, title_has_braille_spinner, and title_has_action_required derive agent type and lifecycle state from raw terminal title strings. When no hook is configured for a surface these heuristics are the sole source for AgentStateChanged events. A window title that coincidentally contains "codex" or "claude", a braille character from an unrelated tool, or a third-party tool echoing "action required" would all produce incorrect transitions.

When a hook is not configured, failing closed (no detection, no state emitted) would be more correct than guessing. Consider requiring an explicit opt-in flag to enable title-based fallback detection, or at minimum gating the detection path so it emits Unknown rather than Working/Blocked when no hook has ever reported for that surface.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Fleet instruction update for head 6e22a5204983d797ab277a02935757f7fdcfe56c: this PR is classified other. No macOS build tag is claimed. The current controller app recipe does not establish iOS/test readiness; that requires the appropriate validated recipe. Use cmux-ci for supported jobs, retain the returned ID and receipt, and wait on the same ID after any timeout. Do not use retired maclease allocation or post credentials. Exact-head tags will be posted only after the applicable build succeeds.

@teamleaderleo teamleaderleo added ready-to-land Reviewed and ready to land when CI is green area: agents Agent integrations (Claude Code, Codex, ACP), agent chat, hooks, status S2: major A crash, hang, lost state, broken connection, or a regression on a path people use labels Sep 30, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 6e22a520 Deployed Sep 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: agents Agent integrations (Claude Code, Codex, ACP), agent chat, hooks, status ready-to-land Reviewed and ready to land when CI is green S2: major A crash, hang, lost state, broken connection, or a regression on a path people use

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants