Skip to content

Add Claude Code plugin sharing the host-agnostic stop-hook core - #7

Merged
vincentmvdm merged 7 commits into
mainfrom
cursor/add-claude-plugin-2c40
Jun 4, 2026
Merged

vincentmvdm merged 7 commits into
mainfrom
cursor/add-claude-plugin-2c40

Conversation

@vincentmvdm

@vincentmvdm vincentmvdm commented Jun 3, 2026 •

Copy link
Copy Markdown
Contributor

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-video skill, 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_path and stop_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:

  • The shared block/reason Stop protocol lives in 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.
  • The shared append-only transcript accumulation loop (advance the non-decreasing user-time cursor, reset per-turn work/share flags, count only post-user work/shares) lives in hooks/core/transcript.ts as accumulateClassifiedRows(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.
  • Cursor is left separate — it genuinely differs (hook_event_name: "stop", a status gate, integer loop_count, followup_message output, 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):

  • Real user turns reset the AFK timer. Crucially, Claude logs tool results and system-injected notes with the same type:"user" shape as real prompts, distinguished only by record-level markers (per anthropics/claude-code#26508). So an entry carrying toolUseResult, sourceToolAssistantUUID, or isMeta === true is treated as non-user and does not reset the timer — content shape alone is not trusted.
  • Assistant tool_use blocks count as work; assistant text-only turns do not.
  • A Mainframe video URL appearing after the last user turn marks the work as already shared.
  • A foreign (Cursor/Codex-style) or empty transcript yields no recognized message entry, so the parser returns unreadable and the hook stays silent.

Plugin wiring

  • .claude-plugin/plugin.json and .claude-plugin/marketplace.json are generated from tooling/generate.ts (the marketplace points at the repo root via source: "./", where the plugin and marketplace live side by side).
  • Hooks resolve the bundled runtime through ${CLAUDE_PLUGIN_ROOT}.
  • A new mainframe-hook-claude bin and the compiled runtime files are added to the package surface.

Install

claude
/plugin marketplace add mainframecomputer/mainframe-plugins
/plugin install mainframe@mainframe

Testing

  • bun run verify passes (generated-drift, typecheck, build check, lint, format, 115 tests).
  • bun run archive:release builds the release tarball with the Claude files packaged.
  • New tests cover the Claude transcript parser (including tool-result/isMeta synthetic-entry exclusion — confirmed they fail without the guard), the Stop hook decision, the shared accumulateClassifiedRows invariants (format-agnostic), and the generated manifest / marketplace / package surface.
  • Smoke-tested the compiled dist/hooks/claude/stop.js and dist/hooks/codex/stop.js against 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 {} when stop_hook_active is true, and ignores non-Stop/unreadable transcripts. The reason copy contains only elapsed hours (no transcript content or secrets).

Reviews performed

  • A multi-agent "council" plus the official Cursor docs confirmed Cursor genuinely differs from the shared contract on all four axes, validating the keep-Cursor-separate decision.
  • Three independent thermo-nuclear code-quality audits all approved the branch. Findings addressed: the transcript accumulation loop was de-duplicated into accumulateClassifiedRows; the shared-loop comment was scoped honestly (with a pointer note in parseCursorRows); unused identity type aliases were removed; and the plugin-root hook-command regex was tightened.
  • Cursor Bugbot flagged that the Claude classifier could treat synthetic 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.
  • One finding remains consciously deferred as out-of-scope for this feature branch: re-tiering the transcript tests so the shared timestamp/JSONL primitives get direct core coverage and the per-host suites drop duplicated core-invariant cases (those primitives and the parallel Codex suite predate this branch).

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.

Open in Web Open in Cursor 

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.
Comment thread hooks/claude/transcript.ts Resolved
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.
@vincentmvdm

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.
@vincentmvdm

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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
vincentmvdm requested review from atlou and jordansinger June 4, 2026 19:20
@vincentmvdm
vincentmvdm marked this pull request as ready for review June 4, 2026 19:20
@vincentmvdm
vincentmvdm merged commit d668fff into main Jun 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants