A Claude Code plugin that turns a headless Claude session into a live listener on a sem-cloud code review: it joins a diff, long-polls for reviewer questions ("branches"), investigates them in the repository, and streams answers back.
Three MCP tools do the work (crates/sem-mcp/src/server.rs, backed by the
client in crates/sem-mcp/src/agent_review.rs): join_review,
wait_for_branch, reply_to_branch. A Stop hook (hooks/keep-listening.sh)
is a backstop only — see "How it stays alive" below.
claude-review-listener/
├── .claude-plugin/
│ └── plugin.json # plugin identity: name "sem-review-listener"
├── .mcp.json # declares the "sem-review" MCP server (the sem binary, `mcp` subcommand)
├── hooks/
│ ├── hooks.json # registers the Stop-hook backstop
│ └── keep-listening.sh # the backstop script (read-only against sem-cloud)
└── README.md
- Build the
sembinary first:cargo build -p sem-clifromcrates/(.mcp.jsonpoints atcrates/target/debug/sem, a debug build — see "Contract doubts" below for why this path is fragile). - A running (or soon-to-be-running) sem-cloud instance and a diff id to listen on.
- The
claudeCLI (this recipe was verified againstclaude --version2.1.226; flag names below are copied fromclaude --helpon this machine — re-check if you're on a materially different version).
From the repo root (/path/to/sem), with sem-cloud running locally:
# Point the listener at your local sem-cloud + demo credentials, NOT your
# real `sem login` account (the plugin's .mcp.json reads these two first,
# falling back to ~/.sem/credentials.json only if unset — see sem-mcp's
# agent_review::AgentReviewConfig::from_env_or_credentials).
export SEM_CLOUD_URL="http://127.0.0.1:8080"
export SEM_API_KEY="<your demo sem-cloud API key>"
# Consumed only by the Stop-hook backstop (hooks/keep-listening.sh), not by
# the MCP server itself — the join_review tool call is what actually tells
# sem-cloud which diff to listen on.
export SEM_REVIEW_DIFF_ID="<the diff id to review>"
claude \
--plugin-dir integrations/claude-review-listener \
--model claude-opus-5 \
--allowedTools "mcp__plugin_sem-review-listener_sem-review__*" "Read" "Grep" "Glob" "Bash" \
--permission-mode bypassPermissions \
-p "Join the sem-cloud review for diff $SEM_REVIEW_DIFF_ID using join_review, then follow the listener protocol it returns. Never end the session yourself."Notes on the flags (all verified against claude --help on this machine):
--plugin-dir <path>— loads the plugin for this session only, no install step. Must point at this in-repo path (see "Contract doubts" below re:${CLAUDE_PLUGIN_ROOT}math).--model claude-opus-5— full model name; the alias form (--model opus) works too perclaude --help's "Provide an alias for the latest model (e.g. 'fable', 'opus', or 'sonnet') or a model's full name (e.g. 'claude-fable-5')".-p/--print— non-interactive: prints and exits when the turn ends rather than opening a REPL. Combined with the listener protocol's "never end the session yourself" instruction, the session is meant to run for as long as the review does.--allowedTools— pre-approves tool calls so a non-interactive session doesn't hang on (or get silently denied by) a permission prompt it can't answer. The MCP tool name is NOTmcp__sem-review__*— see "Contract doubts".Read/Grep/Glob/Bashare included because the listener protocol requires investigating each question "in this repository," not just calling MCP tools.--permission-mode bypassPermissions— belt-and-suspenders alongside--allowedTools; drop this for anything beyond a throwaway sandbox/demo run, perclaude --help's own warning ("Recommended only for sandboxes with no internet access").
The primary mechanism is the MCP tool loop, driven entirely by the
model following the protocol text join_review hands back verbatim:
join_review {diff_id}— announces presence, returns the protocol.wait_for_branch {diff_id}— long-polls (default 40s, max 45s) for the next reviewer question. Astatus: timeoutresult is normal idle behavior, not a stopping condition.- On a branch: investigate in the repo, then
reply_to_branchwithpartial: truechunks while composing and a finalpartial: false(or omitted) call to commit. - Back to step 2, forever.
The Stop hook (hooks/keep-listening.sh) is a backstop only — for
when the model tries to end its turn anyway (e.g. after context compaction
drops the protocol instructions). It never calls agent/next itself (that
would claim the open branch on the hook's behalf, racing the model's own
wait_for_branch call); it only does a read-only
GET /v1/diffs/{id}/comments and blocks the stop with a reminder if it sees
an open comment, or nudges once if it's unsure. See the script's own
comments for the full state machine.
Things the task spec assumed that turned out not to match reality, verified
against https://code.claude.com/docs/en/plugins.md,
plugins-reference.md, mcp.md, and hooks.md on 2026-08-08:
-
MCP tool naming. A plugin-bundled MCP server's tools are namespaced as
mcp__plugin_<plugin-name>_<server-name>__<tool-name>, not the flatmcp__<server-name>__<tool-name>the original recipe assumed. Withplugin.json'sname: "sem-review-listener"and.mcp.json's server key"sem-review", the three tools register as:mcp__plugin_sem-review-listener_sem-review__join_reviewmcp__plugin_sem-review-listener_sem-review__wait_for_branchmcp__plugin_sem-review-listener_sem-review__reply_to_branch
--allowedTools "mcp__sem-review__*"as originally proposed would silently allow nothing; the README above uses the correct scoped wildcard. -
${CLAUDE_PLUGIN_ROOT}/../../crates/target/debug/sem. Docs confirm${CLAUDE_PLUGIN_ROOT}substitutes incommand/args/envfor MCPstdioservers, but every documented example only appends subpaths (${CLAUDE_PLUGIN_ROOT}/servers/db-server) — none shows..traversal. It should work as ordinary path resolution once the substitution happens, and it does in practice (verified:crates/is two levels above this plugin's directory,integrations/claude-review-listener/, so../..lands at the repo root). But this only holds when the plugin is loaded via--plugin-dirpointed at this in-repo path; a marketplace-installed copy would live elsewhere (~/.claude/plugins/...) and this path would resolve to nothing. If you ever package this plugin for distribution, replace the hardcoded relative path with asemon$PATH, or resolve the repo root via${CLAUDE_PROJECT_DIR}instead (also substituted into MCP server fields per the docs, and not dependent on the plugin's own nesting depth). -
${SEM_CLOUD_URL:-}/${SEM_API_KEY:-}with an explicit empty default. Per the docs,.mcp.jsonenv-var expansion leaves the literal, unexpanded${VAR}text in place when a referenced variable is unset and has no default — not an empty string. Without the:-default, forgetting toexport SEM_API_KEYbefore launchingclaudewould hand the MCP server the literal string"${SEM_API_KEY}"as its API key, which is non-empty and would silently defeatAgentReviewConfig's env-first-then-~/.sem/credentials.jsonfallback (a non-empty bogus value shadows the credentials file instead of falling through to it). The:-empty default makes an unset var resolve to"", whichAgentReviewConfig::resolvealready treats as "unset" and correctly falls through. -
stop_hook_activedoes not exist. The task's Stop-hook spec assumed astop_hook_activefield on the hook's stdin JSON and an implicit "block cap is 8" enforced by Claude Code itself. Neither is in the current documented schema (session_id,prompt_id,transcript_path,cwd,permission_mode,hook_event_name,last_assistant_message,effort— confirmed via two separate doc fetches, one asking specifically about this field). There's also no documented cap on consecutive Stop-hook blocks.keep-listening.shsubstitutes its own bounded, plugin-scoped counter (persisted at$CLAUDE_PLUGIN_DATA/stop-count-<session_id>) that: blocks up to 8 consecutive times while an open comment is visible, blocks exactly once (not repeatedly) when no open comment is visible — as a courtesy nudge in case the model just hasn't joined yet — and always resets on an allowed stop. This is a best-effort emulation of the spec's intent, not a verified Claude Code mechanism. -
/v1/diffs/{id}/manifestand/v1/diffs/{id}/commentsresponse shapes are inferred. Both are sem-cloud endpoints outside this client's control and the task described them only loosely ("review summary" / "any root comment with state=="open"").agent_review:: render_manifest_summaryreads common field name candidates (label/title/name,files/fileCount,comments/commentCount) and falls back to raw JSON for anything else, so an unexpected shape degrades gracefully instead of erroring.keep-listening.shaccepts either a bare JSON array or a{"comments": [...]}wrapper from/comments. Re-check both against sem-cloud's actual implementation once it lands. -
404s during development. Every endpoint in
agent_review.rsis coded defensively against sem-cloud not existing yet:next_branch,reply,presence, andmanifestall surface a clearAgentReviewError::Http { status: 404, .. }rather than panicking, andjoin_review's tool result treats a manifest/presence failure as non-fatal (it still hands back the listener protocol so the loop can start once sem-cloud is up). Covered byagent_review::tests::next_branch_surfaces_404_as_http_error.