Skip to content

Desktop app (Windows): cross-session messages silently dropped — held for an approval the UI never offers, then expire (~5 min); regression since app 1.28929.0 #86298

Description

Bug report — desktop app (Windows): cross-session messages are silently dropped: held for an approval the UI never offers, then expire after ~5 minutes

Preflight

Summary

Since the desktop app updated to 1.28929.0 (Aug 11), mcp__ccd_session_mgmt__send_message between two local desktop sessions:

  1. returns a success receipt — Message sent to session … (idle target) or Message queued for session …; it will be processed after the in-flight turn finishes if that session stays healthy (busy target);
  2. lands in the app's own per-session event store — list_events shows the <cross-session-message from="…" name="…" encoded="1"> user event, and the recipient's window renders a "Message from {title}" card;
  3. never reaches the recipient CLI's transcript (JSONL), never starts or joins a turn, and is silently dropped after ~5 minutes. The card in the recipient's window offers no accept/approve/deny control of any kind (user-confirmed by inspection). No error or notification is emitted at any layer, on either side.

Before the app update this channel worked heavily on the same machine: ~200 delivered <cross-session-message> user turns across ~90 sessions and 10+ projects between Jul 25 and Aug 11, with idle recipients woken within seconds (Sending message to session … → Mapping internal session … to CLI session … → turn; fastest observed send→processed was ~119 ms). After the update, zero cross-session messages have reached any recipient's CLI transcript — verified by grepping unique marker strings across every sender/receiver JSONL involved (markers appear only in sender transcripts).

Environment

OS Windows 10
Desktop app 1.28929.0 (MSIX; auto-updated Aug 11, which is the regression boundary)
Bundled CLI 2.1.227 (%APPDATA%\Claude\claude-code\2.1.227\claude.exe); failure identical with a 2.1.229 binary in the same slot, so the CLI generation is not the variable
Sessions local desktop sessions, same user, same machine; no crossSessionInbound / dialogExpiry set in any settings scope (defaults apply)
Transport MCP server ccd_session_mgmt (list_sessions / send_message / list_events)

Steps to reproduce

  1. Open two desktop sessions A and B (different or same project folder — both reproduce).
  2. Let B go idle.
  3. From A, call mcp__ccd_session_mgmt__send_message with B's session id.
  4. Receipt: Message sent to session <id> ("<title>").
  5. Observe B: list_events shows the message as a user event; B's window renders the message card; B's CLI JSONL under ~/.claude/projects/... never receives it; no turn starts (app log: cycles end with hadFirstResponse=false; a later wake logs previous_message_not_found).
  6. Wait ≥5 minutes; the message is gone for good — replying/waking B later does not surface it to the model. Sender receipt still says "sent".

Busy targets ("queued" receipt): the message is only ever processed if the in-flight turn ends within the ~5-minute window; long turns lose every queued message silently.

What we believe is happening (from the app's resource JS + CLI strings + settings schema)

The app's resources/app.asar (readable, minified JS) shows the pipeline:

  • The MCP handler wraps the body as <cross-session-message from="<host session id>" name="<sender title>" encoded="1">… and calls the session manager's sendMessage(target, envelope, undefined, {origin: {kind: 'peer', from, name}}) — note: no permission-mode class is asserted in origin.
  • sendMessage has three paths: idle target → cold-resume startSession({message}) (returns delivered:true → "Message sent…"); running target → deferredSends queue drained at the next turn boundary (→ "Message queued…"); user steers → holdSteer (this path still works — user steers deliver fine, peer messages do not).
  • The CLI/SDK settings schema (embedded in both the app and the CLI binaries) defines crossSessionInbound: 'accept' | 'hold' | 'refuse', default "mode parity": "a message auto-delivers only when the sending session's permission-mode class matches yours …; a sender that asserts no class is held only while this session bypasses permission prompts" — and dialogExpiry: "…how long a HELD cross-session message awaits approval, before … its safe no-action default (cancelled / dropped-with-denial). Defaults to 5m…".
  • The CLI contains a full TUI approval flow for held messages ("Released N held cross-session message(s) to Claude's queue", "That held message was already resolved before your approval/denial…", a peer-origin preview with verifiedPeerPid). The desktop app renders the held message but exposes none of these actions.

Putting it together: the desktop bridge sends class-less origin:{kind:'peer'} messages; desktop-managed recipients run in a bypass-class permission mode; mode parity therefore holds every message for an approval the desktop UI cannot grant; dialogExpiry (5m) then resolves them to dropped-with-denial. Every receipt, store append, and render still happens, which makes the loss invisible. The consent-gate strings are byte-identical across CLI 2.1.226/2.1.227/2.1.229, and 2.1.226 was the working-era CLI — so the gate itself predates the break; what changed at the app update is how the bridge's packets engage it (and/or the loss of the wake/approval surface). This also unifies #86212 (recipient in normal permission mode: classes match / weaker tier — messages park in the CLI queue unapproved-but-undropped and flush on the next human input) and #85888 (held-for-approval, no approval surface, macOS dashboard).

The send_message tool's own description still promises: "The message arrives in the target session as a user turn labelled 'From {this session's title}'" — currently not true for any idle desktop recipient.

Workaround experiment: setting crossSessionInbound: "accept" in the user-scope settings file ("an explicit value always wins" per the schema) did not restore delivery to an already-running recipient (marker still absent from its transcript past the expiry window; fresh-session/app-restart behavior not yet verified). One consistent explanation: on this lane the hold is enforced in the app's embedded SDK layer in front of the recipient CLI — which would also explain why the recipient CLI transcript shows nothing at all, and why the desktop sender receipt says "sent" where the CLI lane's sender is told "held for the recipient user's approval" (#85888).

Log signatures (app log, %APPDATA%\Claude\logs\main.log)

Working era (pre-update log, same machine):

[info] Sending message to session local_<target>
[info] Mapping internal session local_<target> to CLI session <uuid>        <- seconds later, turn runs

Broken era (post-update): the send line still appears; no message-driven mapping line exists in the entire post-update log; instead:

[info] [CCD CycleHealth] healthy cycle for local_<target> (104s, hadFirstResponse=false)
[info] [CCD CycleHealth] unhealthy cycle for local_<target> ... (hadFirstResponse=false ... reason=no_response)
[info] [LocalSessionManager] flushed held steers (1 steer(s)) for local_<target>   <- only user steers flush

Expected

  • A cross-session message either reaches the recipient model (as documented by the tool description), or the sender gets an honest receipt ("held for recipient approval; expires in N minutes"), or an error.
  • A held message is approvable somewhere on the surface that displays it.
  • Expiry produces a visible outcome (sender notification and/or a persistent "message dropped" event), not silence.

Suggested fixes

  1. Give the desktop message card approve/deny controls (and the agents dashboard equivalent, per [BUG] Held-for-approval cross-session message to a background recipient has no approval UI — parked forever #85888) — the CLI already has the whole flow; only the affordance is missing.
  2. Trust same-user/same-machine/same-app-instance peer sends by default, or have the desktop bridge assert the sender's permission-mode class in origin so mode parity can evaluate honestly instead of falling into the class-less "held" branch.
  3. Honest receipts: return "held for recipient approval (expires in Nm)" instead of "Message sent…" when the message is held; distinguish "queued" from "queued but will expire at HH:MM".
  4. Notify the sender on expiry/denial (an error event or follow-up receipt) instead of dropping silently.
  5. Don't expire held messages for idle/unattended recipients — park them until the next human focus (the recipient is precisely the session whose user isn't looking at it).

Evidence available on request

Full marker-probe matrix (10 sends across 9 sessions, sender/receiver JSONL sweeps), app-log excerpts for both eras, and the resource-JS excerpts quoted above. Filed after independent investigation by multiple sessions on this machine reached the same conclusion at file level.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions