[FEATURE] Permission relay: notify channel server when prompt is resolved locally (close server-side ghost state)

Resolved 💬 3 comments Opened Apr 29, 2026 by mnw Closed May 30, 2026

Summary

The claude/channel/permission relay (v2.1.81+) is asymmetric: the channel server is told when a permission prompt opens, but never told when it closes locally. As a result, every channel implementation that renders the prompt in its own UI is left with a stale "pending" artifact whenever the user answers in the local terminal dialog instead of through the channel.

The docs for the feature explicitly describe this as the intended behavior. From channels-reference#how-relay-works:

Both stay live: you can answer in the terminal or on your phone, and Claude Code applies whichever answer arrives first and closes the other.
If someone at the terminal answers before the remote verdict arrives, that answer is applied instead and the pending remote request is dropped.

"Dropped" is from Claude Code's perspective. The channel server never receives a signal that its outstanding relay is now moot, so its UI artifact (Telegram message, Discord prompt, web UI card) sits there forever.

Current protocol

| Direction | Method | Trigger |
|---|---|---|
| Claude Code → channel | notifications/claude/channel/permission_request | Local dialog opens |
| channel → Claude Code | notifications/claude/channel/permission | Channel forwards a verdict back |

There is no notification fired when the local dialog wins, times out, or is otherwise abandoned.

Concrete symptom

Builds against this protocol that surface the prompt as a stateful UI element (a card, a chat message awaiting reply, a status line) end up with permanent ghost artifacts whenever the user resolves locally — which is the common case during attended sessions. Workarounds known to the community:

  • Manual dismiss button. Adds UI weight, splits the audit trail (locally-resolved cards never record an allow/deny verdict).
  • Screen-scrape the tmux pane to detect the dialog disappearing. Brittle: depends on dialog wording, terminal width, render mode; breaks on every Claude Code release that touches the prompt UI.
  • Assume timeout after N seconds. Loses correctness — auto-dismissing a dialog the user is still considering creates a worse failure mode.

None of these are clean. The clean signal lives in Claude Code itself.

Proposed fix

Add one symmetric outbound notification:

// Claude Code → channel server
notifications/claude/channel/permission_resolved
  params: {
    request_id: string,                       // matches the original permission_request
    behavior: "allow" | "deny" | "canceled",  // canceled = local timeout / claude moved on / session ended
    source: "local" | "channel"               // who resolved it
  }

Fire it whenever the request leaves the "open" state, regardless of cause:

| Resolution path | behavior | source |
|---|---|---|
| User answers in local terminal dialog | "allow" or "deny" | "local" |
| Channel sends notifications/claude/channel/permission and Claude Code applies it | "allow" or "deny" | "channel" |
| Local dialog times out / claude abandons the request | "canceled" | "local" |

The source: "channel" case is technically redundant with the channel server's own state (it just sent the verdict), but emitting it uniformly lets server implementations have one observation point for "this request_id is now closed" instead of two code paths.

Why this shape

  • Mirrors the existing inbound schema. The verdict notification into Claude Code already uses { request_id, behavior }. Adding source and the canceled value is the minimum delta.
  • Stateless on the wire. Channel servers correlate on request_id; no new ID space, no new lifecycle to track.
  • Doesn't change the user-facing contract. The local dialog still wins on first-answer, channel still forwards, etc. This is observability, not policy.
  • Backwards compatible. Servers that don't care about resolution can ignore the notification. Servers that do care need only register a handler — same pattern as the existing permission_request handler.

Affected implementations

Every channel that renders the prompt as a stateful UI element:

  • Anthropic's own telegram, discord, imessage, fakechat plugins (source)
  • Any third-party channel using the v2.1.81 relay capability

I'd guess most of these silently accumulate ghost messages today; they're easy to miss because the "happy path" (channel-first resolution) works correctly.

Out of scope for this issue

  • Whether the local dialog should display the request_id (separate UX question, doesn't block this).
  • Whether to expose the relay as a tool-call rather than a notification (would be a larger redesign).

Related

  • Follow-up to #36854 (docs landed, feature gap remained).

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗