Skip to content

fix(panel): scope session mute per session, not per project - #157

Merged
hiskudin merged 1 commit into
mainfrom
fix/per-session-mute-scope
Aug 18, 2026
Merged

fix(panel): scope session mute per session, not per project#157
hiskudin merged 1 commit into
mainfrom
fix/per-session-mute-scope

Conversation

@hiskudin

Copy link
Copy Markdown
Collaborator

Muting one session muted the others in the same repo. Two independent bugs.

1. Mute identity collapses when no integration supplies a tabId

SessionPersistence.key ends in a tabId, and drops the segment entirely when it's nil — so two sessions in one project become one key, one mute flag.

tabId is only populated by a registered integration (TerminalRegistry.integrations): iTerm2, Terminal.app, VSCode/Cursor, Warp, Ghostty. Verified against live sessions:

sessions terminal why tabId is nil
/redteaming ×2 Zed no conformer; exposes ZED_TERM, no TERM_SESSION_ID, so even the generic env-var conformer can't claim it
/Helix ×2 Cursor recognized, but neither process has VSCODE_IPC_HOOK_CLI, so enrich bails and tabId stays nil

Corroborated visually: the accent bar uses the same key shape, and both colliding rows render teal — claude::/Users/…/redteaming hashes to teal. Distinct tabIds would have given teal and indigo.

Fix: mutes key off a ladder — tabIdttyclaudeSessionID. ps already reported the tty; discover() parsed it, tested it for presence, and threw it away. It's the only discriminator every agent has:

  • codex~/.codex/sessions/…/rollout-*.jsonl is date-partitioned with no pid→session mapping
  • gemini — no per-session state at all
  • agyliveStatusByWorkspace() keys by workspace and collapses trajectories within it

so a Claude-only fix would have left the other three broken. TerminalAppIntegration already uses the tty as a tabId for Terminal.app, so this generalizes existing practice rather than adding a conformer per terminal.

Names deliberately keep the tabId key: they're also resolved from events, which carry a tab id but no tty, so widening that key would orphan every rename on disk.

2. Unmuting was not the inverse of muting

isMuted fell back to the project-wide entry; toggleMuted negated the stored flag, which is false for a session holding no entry of its own. So on an inherited mute, clicking unmute wrote muted: true — double-muted — and the session could never be cleared from its own row. Reachable via the legacy muted-sessions.json migration, or any mute performed from a tabId-less session.

toggleMuted now negates the effective state, and muted is tri-state so an explicit false outranks the project entry. A missing field still decodes as no opinion, so existing files and the migration are unaffected.

Verification

make build and make typecheck-tests pass. swift test can't run locally (Command Line Tools only), so the 20 assertions in SessionMuteTests were also run against the real SessionPersistence.swift via a standalone harness — all pass. CI runs them properly.

Covered: the scope ladder and its empty-string handling; two same-project sessions muting independently for both claude and codex; toggle round-trip; unmute leaving no entry behind; a project-wide mute being inherited, cleared on one session only, surviving reload, and re-applicable; legacy entries without a muted field; a rename surviving a mute round-trip.

Not covered by tests: that discover() populates the tty at runtime. Confirmed by hand instead — ps -axo pid=,etime=,tty=,args= gives ttys014/ttys016 for the two Zed sessions and ttys009/ttys018 for the two Cursor ones, ?? for none.

Known limits, not addressed here

  • ttys are recycled. A stale mute could reach an unrelated session, but only if agent, cwd, and recycled tty all match — far narrower than today's "anything in this directory". SessionEntry.lastSeenAt already exists for "a future dormancy-based eviction pass"; this is the case that would justify building it.
  • codex installed via npm appears to satisfy detectAgent twice (the node wrapper and the binary), on one tty. Pre-existing and separate — though tty keying makes the pair mute together, which is the behaviour you'd want.

Manual check

make reload, then run two agents in one repo under Zed or Cursor and mute one. Only that row should show the muted glyph, and clicking it again should clear it.

Two agents in one repo shared a single mute. The persistence key ends in
an integration-supplied tabId, and no integration covers Zed or a bare
shell — VSCode's also stayed nil whenever VSCODE_IPC_HOOK_CLI was absent
from the agent's environment. With tabId nil both sessions collapsed onto
"<agent>::<projectPath>", so muting either silenced both.

Key mutes off a ladder instead: tabId where an integration supplied one,
else the tty, else Claude's sidecar session id. ps already reported the
tty and discover() dropped it after testing it for presence. It is the
only discriminator every agent has — codex exposes no pid-to-session
mapping, gemini no per-session state at all, and Antigravity's RPC keys
by workspace and collapses trajectories within it, so a Claude-only fix
would have left the other three broken.

Names keep their existing tabId key: they are also resolved from events,
which carry a tab id but no tty, and widening the key would orphan every
rename already on disk.

Unmuting was also not the inverse of muting. isMuted fell back to the
project-wide entry but toggleMuted negated the stored flag, which is
false for a session holding no entry of its own — so clicking unmute on
an inherited mute wrote muted: true and the session could never be
cleared from its own row. toggleMuted now negates the effective state,
and `muted` is tri-state so an explicit false can outrank the project
entry. A missing field still decodes as no opinion, so legacy files and
the muted-sessions.json migration are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hiskudin
hiskudin merged commit 72a0117 into main Aug 18, 2026
6 checks passed
@hiskudin
hiskudin deleted the fix/per-session-mute-scope branch August 18, 2026 15:55
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