Repository navigation
Add Claude Code plugin sharing the host-agnostic stop-hook core - #7
Merged
Merged
Conversation
Claude Code uses the same Stop hook contract as Codex (read transcript_path
and stop_hook_active, continue a stopped turn with { decision: "block",
reason }). Extract that shared block/reason protocol into hooks/core/stop-hook.ts
so Codex and Claude both delegate to it, leaving only the per-host transcript
parser distinct.
Add the Claude host surface:
- hooks/claude/transcript.ts parses Claude's typed JSONL. Real user turns reset
the AFK timer; tool_result turns (same "user" type) and assistant text are
excluded, assistant tool_use counts as work, and a foreign or empty transcript
fails closed as unreadable so the hook never fires by mistake.
- hooks/claude/{stop.ts,stop-evaluator.ts,hooks.json} wire the Stop hook through
${CLAUDE_PLUGIN_ROOT}, mirroring the Cursor and Codex runtimes.
- Generate .claude-plugin/{plugin.json,marketplace.json} from tooling/generate.ts
and ship the mainframe-hook-claude bin plus the new runtime files.
Tests cover the transcript parser, the Stop hook decision, and the generated
manifest, marketplace, and package surface.
The append-only accumulation state machine — advance the non-decreasing user-time cursor (a regression fails closed), reset per-turn work/share flags on each user turn, and count only post-user work and shares — was duplicated character-for-character across the Codex and Claude transcript parsers. Extract it into `hooks/core/transcript.ts` as `accumulateClassifiedRows(records, classify)`, so this fail-closed ordering invariant lives in one place and each host supplies only a row classifier plus its own format anchor. - hooks/codex/transcript.ts and hooks/claude/transcript.ts now classify rows to "user" | "work" | "ignore" and delegate the loop to core; each keeps its own "is this my format?" anchor (a session_meta row for Codex, a user/assistant message entry for Claude). - Add hooks/core/transcript.test.ts covering the shared invariants format-agnostically. No behavior change: all existing host tests pass unchanged and the compiled Codex and Claude hooks produce identical output. Cursor keeps its own loop (it additionally enforces tool-call timestamp ordering and rejects unrecognized rows, which do not fit the shared reducer).
… aliases - hooks/core/transcript.ts: the accumulateClassifiedRows comment claimed the accumulation invariant was shared by "every host" and would not "drift between hosts", but Cursor deliberately keeps its own loop (it also validates per-tool-call timestamps against the last user turn). Scope the comment to the hosts that actually use the pass (Codex + Claude) and explain why Cursor stays bespoke, and add a matching note atop parseCursorRows so that tested fail-closed check is not dropped in a future "unification". - hooks/codex/stop-evaluator.ts and hooks/claude/stop-evaluator.ts: drop the unused identity type aliases and reference the shared core types (StopHookEvaluationInput, BlockReasonStopHookOutput) directly; nothing imported the aliases. No behavior change: all 112 tests pass and the compiled Codex and Claude hooks produce identical output.
The package-surface test that parses each host's hooks.json command accepted
mismatched braces (`${FOO` or `$FOO}`) because `\{?`/`\}?` were independent
optionals. Match the two whole forms via alternation so only `$FOO` or `${FOO}`
parse. Harmless in practice (it only reads our own committed, well-formed
hooks.json), but the tighter form documents intent.
Contributor
Author
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit e92b9aa. Configure here.
Claude Code logs tool results and system-injected notes with the same `type: "user"` shape as real prompts; per Anthropic's transcript format (anthropics/claude-code#26508) they are distinguished only by record-level markers, not message content. The classifier only skipped `tool_result` *blocks* inside `message.content` and accepted any non-empty string content as human input, so a tool result carrying string content (with `toolUseResult` / `sourceToolAssistantUUID`) or an `isMeta` note could reset `lastUserTimeMs` and keep the Stop hook silent while the user was actually away. Add `isSyntheticUserEntry` to classify a `type: "user"` row as "ignore" when it carries `toolUseResult`, `sourceToolAssistantUUID`, or `isMeta === true`, before the content-shape check. Real prompts never carry these markers, so there is no over-fire risk. Make the fixture's tool-result row realistic (it now includes the markers) and add regression tests for all three markers (confirmed they fail without the guard). Addresses Cursor Bugbot finding c0488df1 (Medium): "Tool results reset AFK timer".
Refines the previous tool-result/meta fix. Once `isSyntheticUserEntry` keys off the authoritative record-level markers, the content-level `tool_result` block check in the user classifier is redundant: a pure tool-result content array carries no `text` block, so the text check already rejects it, and real tool results always carry the markers. The content check was also marginally less precise -- it would exclude a hypothetical mixed text+tool_result turn the markers would not. Drop that content check and the now-unused `isToolResultBlock` helper, and rename `isRealUserMessage` to `messageHasText` -- a pure text predicate symmetric with `hasToolUseBlock`. Synthetic detection now lives in one place. No behavior change (115 tests green). Verified the marker gate is essential, not redundant: disabling it still fails exactly the string-content and isMeta regression tests, which content shape alone cannot catch.
Contributor
Author
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit e57239c. Configure here.
Match the Cursor and Codex sections: describe the share-video skill and Mainframe tools, not the internal stop-hook mechanism.
vincentmvdm
marked this pull request as ready for review
June 4, 2026 19:20
jordansinger
approved these changes
Jun 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tip
🎬 Watch the walkthrough: Add Claude Code plugin with host-agnostic stop-hook core
Generated by Mainframe. Mainframe automatically generates videos when new pull requests are opened. Configure here.
What
Adds a Mainframe plugin for Claude Code that works like the Cursor and Codex plugins: it ships the
share-videoskill, the hosted MCP wiring, and a Stop hook that nudges the agent to leave a short Mainframe video when the user has been away while work happened.Why this stays DRY and KISS
Claude Code's Stop hook contract is identical to Codex's (it reads
transcript_pathandstop_hook_active, and continues a stopped turn with{ decision: "block", reason }— Codex modeled its hooks on Claude Code). The only genuine per-host difference is the transcript format. So:hooks/core/stop-hook.ts, and both Codex and Claude delegate to it. The Codex evaluator collapses to a thin wrapper with no behavior change.hooks/core/transcript.tsasaccumulateClassifiedRows(records, classify). Codex and Claude classify rows to"user" | "work" | "ignore"and delegate the loop to core, so this fail-closed ordering invariant lives in one place.hook_event_name: "stop", astatusgate, integerloop_count,followup_messageoutput, and per-tool-call timestamp ordering), so folding it in would contort the code. The official Cursor docs confirm all four contract differences.Transcript parsing
Claude Code transcripts are typed JSONL. The Claude classifier fails closed (never fires by mistake):
type:"user"shape as real prompts, distinguished only by record-level markers (per anthropics/claude-code#26508). So an entry carryingtoolUseResult,sourceToolAssistantUUID, orisMeta === trueis treated as non-user and does not reset the timer — content shape alone is not trusted.tool_useblocks count as work; assistant text-only turns do not.unreadableand the hook stays silent.Plugin wiring
.claude-plugin/plugin.jsonand.claude-plugin/marketplace.jsonare generated fromtooling/generate.ts(the marketplace points at the repo root viasource: "./", where the plugin and marketplace live side by side).${CLAUDE_PLUGIN_ROOT}.mainframe-hook-claudebin and the compiled runtime files are added to the package surface.Install
Testing
bun run verifypasses (generated-drift, typecheck, build check, lint, format, 115 tests).bun run archive:releasebuilds the release tarball with the Claude files packaged.isMetasynthetic-entry exclusion — confirmed they fail without the guard), the Stop hook decision, the sharedaccumulateClassifiedRowsinvariants (format-agnostic), and the generated manifest / marketplace / package surface.dist/hooks/claude/stop.jsanddist/hooks/codex/stop.jsagainst realistic Stop payloads, including a transcript where a tool-result entry with string content arrives hours after the real prompt: the hook correctly measures elapsed time from the genuine user turn (not the tool result), blocks with the share-video reason when away + work happened, returns{}whenstop_hook_activeis true, and ignores non-Stop/unreadable transcripts. The reason copy contains only elapsed hours (no transcript content or secrets).Reviews performed
accumulateClassifiedRows; the shared-loop comment was scoped honestly (with a pointer note inparseCursorRows); unused identity type aliases were removed; and the plugin-root hook-command regex was tightened.type:"user"rows as real prompts and reset the AFK timer. Fixed by keying off Anthropic's documented record-level markers (toolUseResult/sourceToolAssistantUUID/isMeta); Bugbot's latest review reports no outstanding issues.Notes: the Claude Code transcript schema was reconstructed from the official hooks docs, the Anthropic transcript-format issue above, and community type definitions rather than a live session; the parser is deliberately conservative and fails closed on anything it does not recognize.