Rewind picker is unusable in sessions driven by plugin channel notifications (`isMeta:true` filter)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
TL;DR
When all user input in a Claude Code session arrives via a plugin's notifications/claude/channel MCP notification (e.g. the official discord plugin, or any future Slack / Telegram / email bridge built on the same channel API), Claude Code core stamps every such message with isMeta: true. The Esc+Esc / /rewind picker filters out all isMeta messages, so the picker shows "Nothing to rewind" even when the session contains dozens of real human messages from the user. The rewind feature is effectively dead for any "talk to Claude through a chat bridge" workflow.
The root cause is in Claude Code core, not in the plugin. The fix is small.
Environment
- Claude Code version: 2.1.92 (observed in session JSONL
versionfield) - Plugin:
discord@claude-plugins-officialv0.0.4 (gitCommitShab664e152af5742dd11b6a6e5d7a65848a8c5a261) - OS: Windows 11
- Reproducible on the latest plugin version (no newer version available at time of report)
Steps to Reproduce
- Install the official Discord plugin:
/plugin install discord@claude-plugins-official - Pair a Discord channel via
/discord:access - Start a fresh Claude Code session:
claude - Do not type anything from the TUI keyboard. Send all prompts to Claude exclusively via the Discord channel
- Have a multi-turn conversation (anything from 2 to N user messages, all from Discord)
- Press
Esc+Esc(or run/rewind) to open the rewind picker
Expected: the picker lists every user message in the session as a rewindable anchor, just as it does for messages typed directly into the TUI.
Actual: the picker shows "Nothing to rewind". None of the Discord-originated user messages appear as anchors.
Verification that this is specifically about channel-injected input
In the same session, type any message directly from the TUI keyboard (a single character is enough). Then press Esc+Esc again. The picker now shows that one TUI-typed message as an anchor — but still none of the Discord-originated messages. This isolates the bug to channel-injected input.
Root Cause
Investigation path
- Hypothesis 1 (wrong): Discord messages are stored as something other than
role:user. Inspected the session JSONL and found 18 records withtype:"user"— so they are stored as user messages. - Hypothesis 2 (correct): Of those 18, only 3 are real user input from Discord; the other 15 are
tool_resultrecords (which the Anthropic message protocol also wraps withrole:user). All 3 real Discord-originated messages haveisMeta: trueandorigin.kind: "channel". None of thetool_resultrecords haveisMeta. - Verified the plugin is not at fault: grepped 893 lines of
server.tsinclaude-plugins-official/discord/0.0.4/. Zero matches forisMeta. The plugin only emits the message via:
``typescript``
mcp.notification({
method: 'notifications/claude/channel',
params: {
content,
meta: { chat_id, message_id, user, user_id, ts, ... }
}
})
The plugin has no opportunity to set isMeta on the resulting Claude Code message — that field does not exist in the notification params schema. It is added by Claude Code core when it receives a notifications/claude/channel notification and converts it into a session message.
Evidence: session JSONL excerpts
A real Discord-originated user message in ~/.claude/projects/<project>/<session-id>.jsonl (with chat IDs and content redacted):
{
"parentUuid": null,
"isSidechain": false,
"promptId": "<uuid>",
"type": "user",
"message": {
"role": "user",
"content": "<channel source=\"plugin:discord:discord\" chat_id=\"<redacted>\" ...>\n<the user's actual prompt>\n</channel>"
},
"isMeta": true,
"uuid": "<uuid>",
"timestamp": "2026-04-07T18:24:35.888Z",
"permissionMode": "bypassPermissions",
"origin": { "kind": "channel", "server": "plugin:discord:discord" },
"userType": "external",
"entrypoint": "cli",
"sessionId": "<uuid>",
"version": "2.1.92",
"gitBranch": "main",
"cwd": "<path>"
}
Key fields:
type: "user"— stored as a user messagemessage.role: "user"— user role at the protocol levelisMeta: true— this is the field that hides it from the rewind pickerorigin.kind: "channel"— marks it as channel-injected (not direct TUI input)userType: "external"— distinguishes external (Discord user) from local TUI
A counts-by-type dump for a real session that talked exclusively via Discord (then a few subagent / tool calls):
29 assistant
18 user ← 3 are real Discord input (all isMeta:true), 15 are tool_result wrappers
6 queue-operation
3 attachment
3 system
1 permission-mode
1 custom-title
1 agent-name
After pressing Esc+Esc, the rewind picker correctly identifies 0 of the 3 real user messages as anchors, because they all carry isMeta: true.
Conclusion
isMeta: true is set by Claude Code core when receiving notifications/claude/channel notifications. The intent is presumably to distinguish "real human keystrokes in the TUI" from "system-injected pseudo-user content" (hooks, channel notifications, summarization injections, etc.). But the rewind picker treats isMeta as a blanket "skip me" signal, which lumps legitimate channel-routed human input together with system-internal pseudo messages and hides them all.
The semantic distinction the codebase wants to make is at least three-way:
- Real TUI user keystroke
- Channel-routed real human user message (Discord, future Slack, etc.)
- System-internal pseudo-user content (hook output, synthetic injections)
But isMeta only encodes a two-way distinction (1 vs 2+3), and the rewind picker treats categories 2 and 3 the same way. This is the bug.
Proposed Fixes
Three options, in order of how invasive they are:
Fix A — minimum-diff: stop filtering channel-origin messages from the rewind picker
In whatever code path filters rewind anchors, change:
// pseudo
const anchors = userMessages.filter(m => !m.isMeta)
to:
// pseudo
const anchors = userMessages.filter(m =>
!m.isMeta || m.origin?.kind === 'channel'
)
This treats origin.kind === 'channel' as a "real human input via a side channel" override that bypasses the meta filter. No protocol or storage change needed. Backwards compatible.
Fix B — stop tagging channel notifications as meta in the first place
When Claude Code core ingests a notifications/claude/channel notification and synthesizes the user message, do not set isMeta: true. The origin.kind: "channel" field is already enough to distinguish it from a TUI keystroke, and any code path that genuinely needs "exclude channel input" can check origin.kind instead of isMeta.
This is cleaner long-term but may have more downstream callers that currently rely on isMeta to gate channel input. Each one would need to be audited.
Fix C — three-way distinction with a new field
Introduce a new field, e.g. userMessageSource: "tui" | "channel" | "synthetic". Migrate isMeta consumers to check the new field. The rewind picker filters out only synthetic, not channel.
This is the most principled fix but the largest change.
Recommended: Fix A. It is one line, fully backwards compatible, and immediately restores rewind for every plugin that uses notifications/claude/channel.
Impact / Scope
This affects every plugin that uses notifications/claude/channel to inject user input into a Claude Code session, not just the Discord plugin. That includes:
- The official
discordplugin (current) - Any Telegram / Slack / Matrix / email bridge plugin built on the same channel API (existing or future)
- Any custom internal plugin a team builds on top of
notifications/claude/channel
For users whose primary interaction modality with Claude Code is a chat bridge rather than the local TUI (a growing pattern), /rewind is currently completely unusable, which removes one of the most important context-management tools in long-running sessions.
Current Workarounds
For users hitting this today, until it is fixed:
- Type any single message into the TUI keyboard during the session. This creates one non-meta user message that the rewind picker can anchor on. From there
Esc+Escworks as expected — though only for messages after the TUI keystroke, not before it, because the picker still cannot see the channel-injected messages. - Use
/compact <instructions>as an approximate substitute./compact only keep details related to task X, drop the task Y discussionwill compress the channel-injected content even though/rewindcannot reach it. The original messages remain in the session JSONL and can theoretically be reached by a future/rewindif Fix A or B lands. - Discipline: open a separate Claude Code session per task instead of multiplexing through one channel.
claude --name <task>makes this manageable, but it is a workaround for an architectural defect, not a real fix.
Related Issues
These are related but distinct — none of them describe the channel notification + isMeta interaction:
- anthropics/claude-code#31976 — general rewind UX, including soft-lock on Esc when history is empty (closed wontfix)
- anthropics/claude-code#44772 — rewind picker shows no pre-compact messages for sessions > 5 MB (different root cause: compaction tree truncation)
- anthropics/claude-code#44596 — long-running Bash tool conversation silently rewound via
isMetasynthetic message (related: anotherisMetaside effect, on a different code path) - anthropics/claude-code#37933 — Telegram plugin inbound messages not surfaced (notification delivery problem, fixed)
- anthropics/claude-code#36477 —
--channelsmode stops processing notifications after first response (open, different code path) - anthropics/claude-code#37498 — Telegram inbound notifications not appearing in conversation (closed)
This issue is the natural complement of #44596: that one is about an isMeta synthetic message rewinding too much; this one is about isMeta causing rewind to do nothing at all. Both point at the same underlying problem — isMeta is overloaded and the rewind subsystem treats all isMeta messages identically when they actually have very different semantics.
Suggested Next Steps for Maintainers
- Apply Fix A (one-line change in the rewind picker filter) as a fast unblock.
- Audit other consumers of
isMetato see whether any other user-visible features (history scrolling,/compactboundaries, summarization) are also incorrectly hiding channel-routed input. - Consider Fix C as a follow-up to give the codebase a three-way distinction it can rely on long-term.
I am happy to test a candidate fix locally against my Discord-driven session if a build is provided.
What Should Happen?
see above
Error Messages/Logs
Steps to Reproduce
see above
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.92
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
PowerShell
Additional Information
_No response_
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗