From ecb61651f608101fd7546ed32cfeecbd9a6e7015 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 22:53:50 +0000 Subject: [PATCH 1/7] Add Claude Code plugin sharing the host-agnostic stop-hook core 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. --- .claude-plugin/marketplace.json | 14 + .claude-plugin/plugin.json | 16 ++ AGENTS.md | 23 +- README.md | 16 +- dist/hooks/claude/stop-evaluator.js | 5 + dist/hooks/claude/stop.js | 4 + dist/hooks/claude/transcript.js | 97 +++++++ dist/hooks/codex/stop-evaluator.js | 25 +- dist/hooks/core/stop-hook.js | 25 ++ hooks/claude/fixtures/stop.json | 8 + hooks/claude/fixtures/transcript.jsonl | 4 + hooks/claude/hooks.json | 15 + hooks/claude/stop-evaluator.ts | 16 ++ hooks/claude/stop.test.ts | 162 +++++++++++ hooks/claude/stop.ts | 5 + hooks/claude/transcript.test.ts | 361 +++++++++++++++++++++++++ hooks/claude/transcript.ts | 129 +++++++++ hooks/codex/stop-evaluator.ts | 48 +--- hooks/core/stop-hook.ts | 49 ++++ package.json | 9 +- tooling/generate.ts | 34 ++- tooling/generated-drift-check.ts | 2 + tooling/manifest.test.ts | 32 +++ tooling/mark-executable.ts | 6 +- tooling/package-surface.test.ts | 24 +- tooling/package-surface.ts | 9 + tooling/release-archive.ts | 12 +- 27 files changed, 1068 insertions(+), 82 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 dist/hooks/claude/stop-evaluator.js create mode 100755 dist/hooks/claude/stop.js create mode 100644 dist/hooks/claude/transcript.js create mode 100644 dist/hooks/core/stop-hook.js create mode 100644 hooks/claude/fixtures/stop.json create mode 100644 hooks/claude/fixtures/transcript.jsonl create mode 100644 hooks/claude/hooks.json create mode 100644 hooks/claude/stop-evaluator.ts create mode 100644 hooks/claude/stop.test.ts create mode 100644 hooks/claude/stop.ts create mode 100644 hooks/claude/transcript.test.ts create mode 100644 hooks/claude/transcript.ts create mode 100644 hooks/core/stop-hook.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..46d9ef5 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "mainframe", + "owner": { + "name": "Mainframe", + "email": "support@mainframe.app" + }, + "plugins": [ + { + "name": "mainframe", + "source": "./", + "description": "Create and share short video updates from agent work." + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..630bb5b --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "mainframe", + "version": "0.1.0", + "description": "Create and share short video updates from agent work.", + "author": { + "name": "Mainframe", + "email": "support@mainframe.app" + }, + "homepage": "https://mainframe.app", + "repository": "https://github.com/mainframecomputer/mainframe-plugins.git", + "license": "UNLICENSED", + "keywords": ["mainframe", "agent-skills", "mcp", "hooks", "video"], + "skills": "./skills", + "mcpServers": "./.mcp.json", + "hooks": "./hooks/claude/hooks.json" +} diff --git a/AGENTS.md b/AGENTS.md index 4bb4bda..e1ba6cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,22 +1,25 @@ # Agent notes -This repository packages the Mainframe Cursor and Codex plugins. Keep it focused on the -Cursor and Codex manifests, hosted MCP wiring, the `share-video` skill, and the stop hooks. +This repository packages the Mainframe Cursor, Codex, and Claude Code plugins. Keep it focused on +the Cursor, Codex, and Claude Code manifests, hosted MCP wiring, the `share-video` skill, and the +stop hooks. ## Repository boundaries - User-visible copy should say "Mainframe", not legacy product names. - Do not add secrets, customer data, private URLs, or private business context. -- Cursor and Codex are the supported hosts. Both plugins share the repo root, the - `share-video` skill, the `./.mcp.json` wiring, and the `hooks/core` runtime. Do not add - other host surfaces unless the product task explicitly asks for them. +- Cursor, Codex, and Claude Code are the supported hosts. All three plugins share the repo root, + the `share-video` skill, the `./.mcp.json` wiring, and the `hooks/core` runtime. Codex and Claude + Code share the same Stop hook contract, so they both use `hooks/core/stop-hook.ts`; only the + transcript parser differs per host. Do not add other host surfaces unless the product task + explicitly asks for them. - Run `bun run verify` before considering changes ready. -- Generated Cursor and Codex manifest and marketplace files come from `tooling/generate.ts`; - edit the config there, then run `bun run generate`. +- Generated Cursor, Codex, and Claude Code manifest and marketplace files come from + `tooling/generate.ts`; edit the config there, then run `bun run generate`. - The canonical skill is `skills/share-video/SKILL.md`. -- Keep generated metadata in `.cursor-plugin/`, `.codex-plugin/`, and `.agents/plugins/`. Do - not add package-local docs or extra top-level markdown unless the user asks; improve - `README.md` or this file instead. +- Keep generated metadata in `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`, and + `.claude-plugin/`. Do not add package-local docs or extra top-level markdown unless the user + asks; improve `README.md` or this file instead. ## Start here diff --git a/README.md b/README.md index 8ec140f..3297175 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Mainframe plugins Mainframe is the video sharing platform for agents. This repository ships the `share-video` skill -and Mainframe plugins for Cursor and Codex. +and Mainframe plugins for Cursor, Codex, and Claude Code. ## Install @@ -33,6 +33,20 @@ codex plugin marketplace add mainframecomputer/mainframe-plugins The Codex plugin gives Codex the same `share-video` skill and Mainframe tools as the Cursor plugin. +### Claude Code + +Add this repository as a Claude Code plugin marketplace, then install Mainframe from the `/plugin` +browser: + +```sh +claude +/plugin marketplace add mainframecomputer/mainframe-plugins +/plugin install mainframe@mainframe +``` + +The Claude Code plugin gives Claude the same `share-video` skill, Mainframe tools, and stop hook as +the Cursor and Codex plugins. + ## Included skill - `share-video` — share a short video that explains what the agent did, useful for demos, diff --git a/dist/hooks/claude/stop-evaluator.js b/dist/hooks/claude/stop-evaluator.js new file mode 100644 index 0000000..c872b62 --- /dev/null +++ b/dist/hooks/claude/stop-evaluator.js @@ -0,0 +1,5 @@ +import { evaluateBlockReasonStopHook, } from "../core/stop-hook.js"; +import { summarizeClaudeTranscriptFile } from "./transcript.js"; +export function evaluateClaudeStopHook(input) { + return evaluateBlockReasonStopHook(input, summarizeClaudeTranscriptFile); +} diff --git a/dist/hooks/claude/stop.js b/dist/hooks/claude/stop.js new file mode 100755 index 0000000..4f98c5b --- /dev/null +++ b/dist/hooks/claude/stop.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runStopHookCli } from "../core/run-stop-hook.js"; +import { evaluateClaudeStopHook } from "./stop-evaluator.js"; +runStopHookCli(evaluateClaudeStopHook); diff --git a/dist/hooks/claude/transcript.js b/dist/hooks/claude/transcript.js new file mode 100644 index 0000000..72fcc7a --- /dev/null +++ b/dist/hooks/claude/transcript.js @@ -0,0 +1,97 @@ +import { isJsonRecord, parseJsonlRecords } from "../core/json.js"; +import { hasMainframeVideoUrl, isNonEmptyString, nextUserTimeMs, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; +export function summarizeClaudeTranscriptFile(path) { + return summarizeTranscriptFile(path, parseClaudeRows); +} +export function summarizeClaudeTranscript(text) { + return summarizeTranscript(text, parseClaudeRows); +} +// Claude Code transcripts are append-only JSONL where each line is a typed +// entry. A real user turn is `{ type: "user", message: { content } }` carrying +// human text; tool results reuse the same `user` type but carry `tool_result` +// blocks, so they must not reset the AFK timer. Assistant `tool_use` blocks +// count as work. Unrelated entry types (system, summary, ...) are ignored, but +// a transcript with no recognizable Claude message entry fails closed as +// "unreadable" so the hook never fires on a foreign or empty format. +function parseClaudeRows(text) { + const records = parseJsonlRecords(text); + if (records === "unreadable") { + return "unreadable"; + } + let sawClaudeEntry = false; + let sawUser = false; + let lastUserTimeMs = null; + let workHappened = false; + let alreadyShared = false; + let previousUserTimeMs = null; + for (const record of records) { + const kind = classifyClaudeRow(record); + if (kind !== "foreign") { + sawClaudeEntry = true; + } + if (kind === "user") { + const userTimeMs = nextUserTimeMs(record.timestamp, previousUserTimeMs); + if (userTimeMs === "unreadable") { + return "unreadable"; + } + sawUser = true; + lastUserTimeMs = userTimeMs; + if (userTimeMs !== null) { + previousUserTimeMs = userTimeMs; + } + workHappened = false; + alreadyShared = false; + continue; + } + if (sawUser) { + workHappened = workHappened || kind === "work"; + alreadyShared = alreadyShared || hasMainframeVideoUrl(record); + } + } + if (!sawClaudeEntry) { + return "unreadable"; + } + return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; +} +function classifyClaudeRow(record) { + if (record.type !== "user" && record.type !== "assistant") { + return "foreign"; + } + const message = record.message; + if (!isJsonRecord(message)) { + return "foreign"; + } + if (record.type === "assistant") { + return hasToolUseBlock(message.content) ? "work" : "claude-other"; + } + return isRealUserMessage(message) ? "user" : "claude-other"; +} +// A real user turn carries human text: either a plain string or content blocks +// that include a non-empty `text` block. Tool-result turns reuse the `user` +// type but carry `tool_result` blocks instead of prompt text, so they are +// excluded to keep the AFK timer anchored to genuine user activity. +function isRealUserMessage(message) { + const content = message.content; + if (isNonEmptyString(content)) { + return true; + } + if (!Array.isArray(content)) { + return false; + } + if (content.some(isToolResultBlock)) { + return false; + } + return content.some(isNonEmptyTextBlock); +} +function hasToolUseBlock(content) { + return Array.isArray(content) && content.some(isToolUseBlock); +} +function isToolUseBlock(block) { + return isJsonRecord(block) && block.type === "tool_use"; +} +function isToolResultBlock(block) { + return isJsonRecord(block) && block.type === "tool_result"; +} +function isNonEmptyTextBlock(block) { + return isJsonRecord(block) && block.type === "text" && isNonEmptyString(block.text); +} diff --git a/dist/hooks/codex/stop-evaluator.js b/dist/hooks/codex/stop-evaluator.js index 2776b7e..c4f43a3 100644 --- a/dist/hooks/codex/stop-evaluator.js +++ b/dist/hooks/codex/stop-evaluator.js @@ -1,26 +1,5 @@ -import { decideStop } from "../core/stop-policy.js"; -import { parseJsonRecord } from "../core/json.js"; +import { evaluateBlockReasonStopHook, } from "../core/stop-hook.js"; import { summarizeCodexTranscriptFile } from "./transcript.js"; export function evaluateCodexStopHook(input) { - const hookInput = parseCodexStopInput(input.stdin); - if (hookInput === null || hookInput.stopHookActive) { - return {}; - } - const summary = summarizeCodexTranscriptFile(hookInput.transcriptPath); - const decision = decideStop(summary, input.nowMs ?? Date.now()); - if (decision.kind === "skip") { - return {}; - } - return { decision: "block", reason: decision.message }; -} -function parseCodexStopInput(stdin) { - const input = parseJsonRecord(stdin); - if (input === null || input.hook_event_name !== "Stop") { - return null; - } - const transcriptPath = input.transcript_path; - if (typeof transcriptPath !== "string" || transcriptPath.trim() === "") { - return null; - } - return { transcriptPath, stopHookActive: input.stop_hook_active === true }; + return evaluateBlockReasonStopHook(input, summarizeCodexTranscriptFile); } diff --git a/dist/hooks/core/stop-hook.js b/dist/hooks/core/stop-hook.js new file mode 100644 index 0000000..d60c71e --- /dev/null +++ b/dist/hooks/core/stop-hook.js @@ -0,0 +1,25 @@ +import { parseJsonRecord } from "./json.js"; +import { decideStop } from "./stop-policy.js"; +export function evaluateBlockReasonStopHook(input, summarizeTranscriptFile) { + const hookInput = parseStopHookInput(input.stdin); + if (hookInput === null || hookInput.stopHookActive) { + return {}; + } + const summary = summarizeTranscriptFile(hookInput.transcriptPath); + const decision = decideStop(summary, input.nowMs ?? Date.now()); + if (decision.kind === "skip") { + return {}; + } + return { decision: "block", reason: decision.message }; +} +function parseStopHookInput(stdin) { + const input = parseJsonRecord(stdin); + if (input === null || input.hook_event_name !== "Stop") { + return null; + } + const transcriptPath = input.transcript_path; + if (typeof transcriptPath !== "string" || transcriptPath.trim() === "") { + return null; + } + return { transcriptPath, stopHookActive: input.stop_hook_active === true }; +} diff --git a/hooks/claude/fixtures/stop.json b/hooks/claude/fixtures/stop.json new file mode 100644 index 0000000..b328741 --- /dev/null +++ b/hooks/claude/fixtures/stop.json @@ -0,0 +1,8 @@ +{ + "session_id": "session-123", + "transcript_path": null, + "cwd": "/workspace/mainframe-plugins", + "hook_event_name": "Stop", + "stop_hook_active": false, + "permission_mode": "default" +} diff --git a/hooks/claude/fixtures/transcript.jsonl b/hooks/claude/fixtures/transcript.jsonl new file mode 100644 index 0000000..d6c199e --- /dev/null +++ b/hooks/claude/fixtures/transcript.jsonl @@ -0,0 +1,4 @@ +{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/mainframe-plugins","sessionId":"session-123","type":"user","message":{"role":"user","content":"Please implement this. SECRET_NEVER_LEAK"},"uuid":"11111111-1111-4111-8111-111111111111","timestamp":"2026-05-08T13:00:00.000Z"} +{"parentUuid":"11111111-1111-4111-8111-111111111111","isSidechain":false,"userType":"external","cwd":"/workspace/mainframe-plugins","sessionId":"session-123","type":"assistant","message":{"role":"assistant","model":"claude-opus-4-20250514","content":[{"type":"text","text":"On it."},{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"command":"bun run build"}}]},"uuid":"22222222-2222-4222-8222-222222222222","timestamp":"2026-05-08T13:05:00.000Z"} +{"parentUuid":"22222222-2222-4222-8222-222222222222","isSidechain":false,"userType":"external","cwd":"/workspace/mainframe-plugins","sessionId":"session-123","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"build succeeded"}]},"uuid":"33333333-3333-4333-8333-333333333333","timestamp":"2026-05-08T13:06:00.000Z"} +{"parentUuid":"33333333-3333-4333-8333-333333333333","isSidechain":false,"userType":"external","cwd":"/workspace/mainframe-plugins","sessionId":"session-123","type":"assistant","message":{"role":"assistant","model":"claude-opus-4-20250514","content":[{"type":"text","text":"Build finished."}]},"uuid":"44444444-4444-4444-8444-444444444444","timestamp":"2026-05-08T14:10:00.000Z"} diff --git a/hooks/claude/hooks.json b/hooks/claude/hooks.json new file mode 100644 index 0000000..fb12363 --- /dev/null +++ b/hooks/claude/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hooks/claude/stop.js\"", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/hooks/claude/stop-evaluator.ts b/hooks/claude/stop-evaluator.ts new file mode 100644 index 0000000..e8a6921 --- /dev/null +++ b/hooks/claude/stop-evaluator.ts @@ -0,0 +1,16 @@ +import { + type BlockReasonStopHookOutput, + evaluateBlockReasonStopHook, + type StopHookEvaluationInput, +} from "../core/stop-hook.js"; +import { summarizeClaudeTranscriptFile } from "./transcript.js"; + +export type ClaudeStopEvaluationInput = StopHookEvaluationInput; + +// Claude Code's Stop hook uses the shared block/reason protocol; only the +// transcript format is Claude-specific. +export type ClaudeStopHookOutput = BlockReasonStopHookOutput; + +export function evaluateClaudeStopHook(input: ClaudeStopEvaluationInput): ClaudeStopHookOutput { + return evaluateBlockReasonStopHook(input, summarizeClaudeTranscriptFile); +} diff --git a/hooks/claude/stop.test.ts b/hooks/claude/stop.test.ts new file mode 100644 index 0000000..96ee746 --- /dev/null +++ b/hooks/claude/stop.test.ts @@ -0,0 +1,162 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { evaluateClaudeStopHook } from "./stop-evaluator.js"; + +const fixtureDir = dirname(fileURLToPath(import.meta.url)); +const stopPath = join(fixtureDir, "fixtures", "stop.json"); +const transcriptPath = join(fixtureDir, "fixtures", "transcript.jsonl"); +const stopTimeMs = Date.parse("2026-05-08T15:30:00.000Z"); + +describe("Claude stop hook", () => { + it("blocks with a share-video prompt after the user is away", () => { + const output = evaluateClaudeStopHook({ + stdin: stopInput({ transcript_path: transcriptPath }), + nowMs: stopTimeMs, + }); + + expect(output.decision).toBe("block"); + expect(output.reason).toContain("2.5 hours"); + expect(output.reason).toContain("share-video"); + expect(output.reason).not.toContain("SECRET_NEVER_LEAK"); + }); + + it("does not fire before the fixed one-hour threshold", () => { + expect( + evaluateClaudeStopHook({ + stdin: stopInput({ transcript_path: transcriptPath }), + nowMs: Date.parse("2026-05-08T13:30:00.000Z"), + }), + ).toEqual({}); + }); + + it("does not fire for non-stop hook events", () => { + for (const hook_event_name of ["SessionStart", "stop", "UserPromptSubmit", "SubagentStop"]) { + expect( + evaluateClaudeStopHook({ + stdin: stopInput({ hook_event_name, transcript_path: transcriptPath }), + nowMs: stopTimeMs, + }), + ).toEqual({}); + } + }); + + it("does not fire after a continuation already looped", () => { + expect( + evaluateClaudeStopHook({ + stdin: stopInput({ stop_hook_active: true, transcript_path: transcriptPath }), + nowMs: stopTimeMs, + }), + ).toEqual({}); + }); + + it("does not fire when the transcript path is missing or empty", () => { + for (const transcript_path of [undefined, null, ""]) { + expect( + evaluateClaudeStopHook({ + stdin: stopInput({ transcript_path }), + nowMs: stopTimeMs, + }), + ).toEqual({}); + } + }); + + it("does not fire when no work happened since the last user message", () => { + const directory = mkdtempSync(join(tmpdir(), "mainframe-claude-stop-test-")); + const path = join(directory, "transcript.jsonl"); + writeFileSync( + path, + [ + JSON.stringify({ + type: "user", + timestamp: "2026-05-08T13:00:00.000Z", + sessionId: "session-1", + uuid: "u1", + parentUuid: null, + message: { role: "user", content: "please answer" }, + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-05-08T13:30:00.000Z", + sessionId: "session-1", + uuid: "a1", + parentUuid: "u1", + message: { role: "assistant", content: [{ type: "text", text: "Here is my answer." }] }, + }), + ].join("\n"), + ); + + expect( + evaluateClaudeStopHook({ + stdin: stopInput({ transcript_path: path }), + nowMs: stopTimeMs, + }), + ).toEqual({}); + }); + + it("does not fire after a Mainframe video URL appears in tool output", () => { + const directory = mkdtempSync(join(tmpdir(), "mainframe-claude-stop-test-")); + const path = join(directory, "transcript.jsonl"); + writeFileSync( + path, + [ + JSON.stringify({ + type: "user", + timestamp: "2026-05-08T13:00:00.000Z", + sessionId: "session-1", + uuid: "u1", + parentUuid: null, + message: { role: "user", content: "please work on this" }, + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-05-08T13:05:00.000Z", + sessionId: "session-1", + uuid: "a1", + parentUuid: "u1", + message: { + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_1", name: "mcp__mainframe__create_video", input: {} }, + ], + }, + }), + JSON.stringify({ + type: "user", + timestamp: "2026-05-08T13:30:00.000Z", + sessionId: "session-1", + uuid: "u2", + parentUuid: "a1", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_1", + content: "Shared: https://mainframe.app/v/37507089004e8f3700deb918a48b2556", + }, + ], + }, + }), + ].join("\n"), + ); + + expect( + evaluateClaudeStopHook({ + stdin: stopInput({ transcript_path: path }), + nowMs: stopTimeMs, + }), + ).toEqual({}); + }); +}); + +function stopInput(overrides: Record): string { + return JSON.stringify({ + ...JSON.parse(readFileSync(stopPath, "utf8")), + ...overrides, + }); +} diff --git a/hooks/claude/stop.ts b/hooks/claude/stop.ts new file mode 100644 index 0000000..79c975d --- /dev/null +++ b/hooks/claude/stop.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { runStopHookCli } from "../core/run-stop-hook.js"; +import { evaluateClaudeStopHook } from "./stop-evaluator.js"; + +runStopHookCli(evaluateClaudeStopHook); diff --git a/hooks/claude/transcript.test.ts b/hooks/claude/transcript.test.ts new file mode 100644 index 0000000..80a607b --- /dev/null +++ b/hooks/claude/transcript.test.ts @@ -0,0 +1,361 @@ +import { mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { summarizeClaudeTranscript, summarizeClaudeTranscriptFile } from "./transcript.js"; + +describe("summarizeClaudeTranscript", () => { + it("summarizes assistant tool_use work after the last user message", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T12:00:00.000Z", "earlier request"), + userMessage("2026-05-08T13:00:00.000Z", "final request"), + assistantToolUse("2026-05-08T13:05:00.000Z"), + ]), + ); + + expect(summary).toEqual({ + kind: "ready", + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.000Z"), + workHappened: true, + alreadyShared: false, + }); + }); + + it("resets work state on each user turn", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T13:00:00.000Z", "first request"), + assistantToolUse("2026-05-08T13:05:00.000Z"), + userMessage("2026-05-08T14:00:00.000Z", "second request"), + ]), + ); + + expect(summary).toEqual({ + kind: "ready", + lastUserTimeMs: Date.parse("2026-05-08T14:00:00.000Z"), + workHappened: false, + alreadyShared: false, + }); + }); + + it("does not treat a tool_result user entry as a real user message", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + assistantToolUse("2026-05-08T13:05:00.000Z"), + toolResult("2026-05-08T13:06:00.000Z", "command output"), + ]), + ); + + expect(summary).toEqual({ + kind: "ready", + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.000Z"), + workHappened: true, + alreadyShared: false, + }); + }); + + it("treats a text-block array user message as a real user turn", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T13:00:00.000Z", [{ type: "text", text: "please work on this" }]), + assistantToolUse("2026-05-08T13:05:00.000Z"), + ]), + ); + + expect(summary).toMatchObject({ + kind: "ready", + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.000Z"), + workHappened: true, + }); + }); + + it("counts work done through MCP tool_use blocks", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + { + type: "assistant", + timestamp: "2026-05-08T13:05:00.000Z", + message: { + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_1", name: "mcp__mainframe__create_video", input: {} }, + ], + }, + }, + ]), + ); + + expect(summary).toMatchObject({ kind: "ready", workHappened: true }); + }); + + it("ignores unrelated entry types and assistant text-only turns", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + { type: "summary", summary: "Prior session", leafUuid: "leaf-1" }, + { type: "system", subtype: "info", content: "context loaded" }, + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + assistantText("2026-05-08T13:02:00.000Z", "Thinking out loud."), + assistantToolUse("2026-05-08T13:05:00.000Z"), + ]), + ); + + expect(summary).toEqual({ + kind: "ready", + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.000Z"), + workHappened: true, + alreadyShared: false, + }); + }); + + it("does not count an assistant text-only turn as work", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T13:00:00.000Z", "please answer"), + assistantText("2026-05-08T13:30:00.000Z", "Here is my answer."), + ]), + ); + + expect(summary).toEqual({ + kind: "ready", + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.000Z"), + workHappened: false, + alreadyShared: false, + }); + }); + + it("detects an existing Mainframe share from tool_result output", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + assistantToolUse("2026-05-08T13:05:00.000Z"), + toolResult( + "2026-05-08T13:30:00.000Z", + "Shared: https://mainframe.app/v/37507089004e8f3700deb918a48b2556", + ), + ]), + ); + + expect(summary).toMatchObject({ kind: "ready", workHappened: true, alreadyShared: true }); + }); + + it("detects a Mainframe share from an assistant message", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + assistantToolUse("2026-05-08T13:05:00.000Z"), + assistantText( + "2026-05-08T13:30:00.000Z", + "Done: https://mainframe.app/v/37507089004e8f3700deb918a48b2556", + ), + ]), + ); + + expect(summary).toMatchObject({ kind: "ready", alreadyShared: true }); + }); + + it("does not treat a video URL in the latest user message as an existing share", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage( + "2026-05-08T13:00:00.000Z", + "please look at https://mainframe.app/v/37507089004e8f3700deb918a48b2556", + ), + assistantToolUse("2026-05-08T13:05:00.000Z"), + ]), + ); + + expect(summary).toMatchObject({ kind: "ready", workHappened: true, alreadyShared: false }); + }); + + it("accepts ISO timestamps with sub-millisecond precision", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([userMessage("2026-05-08T13:00:00.123456789+00:00", "please work on this")]), + ); + + expect(summary).toMatchObject({ + kind: "ready", + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.123Z"), + }); + }); + + it("treats out-of-order user timestamps as unreadable", () => { + expect( + summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T15:00:00.000Z", "latest request"), + userMessage("2026-05-08T13:00:00.000Z", "older request emitted later"), + assistantToolUse("2026-05-08T13:05:00.000Z"), + ]), + ), + ).toEqual({ kind: "unreadable" }); + }); + + it("reports missing user time when the latest user timestamp is malformed", () => { + expect( + summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T13:00:00.000Z", "first request"), + userMessage("not-a-timestamp", "latest request"), + ]), + ), + ).toEqual({ kind: "missing-user-time" }); + }); + + it("uses a discriminated state when no real user message is present", () => { + expect( + summarizeClaudeTranscript( + claudeTranscript([assistantText("2026-05-08T13:30:00.000Z", "done")]), + ), + ).toEqual({ kind: "no-user" }); + }); + + it("does not treat a tool_result-only turn as a real user message", () => { + expect( + summarizeClaudeTranscript( + claudeTranscript([toolResult("2026-05-08T13:00:00.000Z", "stray output")]), + ), + ).toEqual({ kind: "no-user" }); + }); + + it("treats a Cursor-style transcript as unreadable", () => { + expect( + summarizeClaudeTranscript( + JSON.stringify({ + timestamp: "2026-05-08T13:00:00.000Z", + event: "user_message", + text: "please work on this", + }), + ), + ).toEqual({ kind: "unreadable" }); + }); + + it("treats a Codex-style transcript as unreadable", () => { + expect( + summarizeClaudeTranscript( + [ + JSON.stringify({ + timestamp: "2026-05-08T12:55:00.000Z", + type: "session_meta", + payload: { id: "session-1", cwd: "/workspace" }, + }), + JSON.stringify({ + timestamp: "2026-05-08T13:00:00.000Z", + type: "event_msg", + payload: { type: "user_message", message: "please work on this", kind: "plain" }, + }), + ].join("\n"), + ), + ).toEqual({ kind: "unreadable" }); + }); + + it("treats a user entry without a message object as unreadable", () => { + expect( + summarizeClaudeTranscript( + JSON.stringify({ type: "user", timestamp: "2026-05-08T13:00:00.000Z" }), + ), + ).toEqual({ kind: "unreadable" }); + }); + + it("treats corrupt JSONL as unreadable", () => { + expect( + summarizeClaudeTranscript( + [ + JSON.stringify(userMessage("2026-05-08T13:00:00.000Z", "please work on this")), + "{not-json", + ].join("\n"), + ), + ).toEqual({ kind: "unreadable" }); + }); + + it("treats non-object rows as unreadable", () => { + expect(summarizeClaudeTranscript(JSON.stringify(["not", "a", "claude", "row"]))).toEqual({ + kind: "unreadable", + }); + }); + + it("uses a discriminated state when the transcript file cannot be read", () => { + expect(summarizeClaudeTranscriptFile("/tmp/mainframe-missing-claude-transcript.jsonl")).toEqual( + { kind: "unreadable" }, + ); + }); + + it("treats symlink transcript paths as unreadable", () => { + const directory = mkdtempSync(join(tmpdir(), "mainframe-claude-transcript-test-")); + const sourcePath = join(directory, "source.jsonl"); + const symlinkPath = join(directory, "linked.jsonl"); + writeFileSync( + sourcePath, + claudeTranscript([userMessage("2026-05-08T13:00:00.000Z", "please work on this")]), + ); + symlinkSync(sourcePath, symlinkPath); + + expect(summarizeClaudeTranscriptFile(symlinkPath)).toEqual({ kind: "unreadable" }); + }); +}); + +function claudeTranscript(rows: Array>): string { + return rows.map((row) => JSON.stringify(row)).join("\n"); +} + +function userMessage(timestamp: string, content: unknown): Record { + return { + parentUuid: null, + isSidechain: false, + userType: "external", + sessionId: "session-1", + type: "user", + message: { role: "user", content }, + uuid: `user-${timestamp}`, + timestamp, + }; +} + +function assistantToolUse(timestamp: string): Record { + return { + parentUuid: null, + isSidechain: false, + sessionId: "session-1", + type: "assistant", + message: { + role: "assistant", + model: "claude-opus-4-20250514", + content: [{ type: "tool_use", id: "toolu_1", name: "Bash", input: { command: "ls" } }], + }, + uuid: `assistant-${timestamp}`, + timestamp, + }; +} + +function assistantText(timestamp: string, text: string): Record { + return { + parentUuid: null, + isSidechain: false, + sessionId: "session-1", + type: "assistant", + message: { + role: "assistant", + model: "claude-opus-4-20250514", + content: [{ type: "text", text }], + }, + uuid: `assistant-${timestamp}`, + timestamp, + }; +} + +function toolResult(timestamp: string, content: string): Record { + return { + parentUuid: null, + isSidechain: false, + userType: "external", + sessionId: "session-1", + type: "user", + message: { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content }] }, + uuid: `tool-result-${timestamp}`, + timestamp, + }; +} diff --git a/hooks/claude/transcript.ts b/hooks/claude/transcript.ts new file mode 100644 index 0000000..2562f63 --- /dev/null +++ b/hooks/claude/transcript.ts @@ -0,0 +1,129 @@ +import { isJsonRecord, type JsonRecord, parseJsonlRecords } from "../core/json.js"; +import { + hasMainframeVideoUrl, + isNonEmptyString, + nextUserTimeMs, + type ParsedTranscript, + summarizeTranscript, + summarizeTranscriptFile, + type TranscriptSummary, +} from "../core/transcript.js"; + +type ClaudeRowKind = "user" | "work" | "claude-other" | "foreign"; + +export function summarizeClaudeTranscriptFile(path: string): TranscriptSummary { + return summarizeTranscriptFile(path, parseClaudeRows); +} + +export function summarizeClaudeTranscript(text: string): TranscriptSummary { + return summarizeTranscript(text, parseClaudeRows); +} + +// Claude Code transcripts are append-only JSONL where each line is a typed +// entry. A real user turn is `{ type: "user", message: { content } }` carrying +// human text; tool results reuse the same `user` type but carry `tool_result` +// blocks, so they must not reset the AFK timer. Assistant `tool_use` blocks +// count as work. Unrelated entry types (system, summary, ...) are ignored, but +// a transcript with no recognizable Claude message entry fails closed as +// "unreadable" so the hook never fires on a foreign or empty format. +function parseClaudeRows(text: string): ParsedTranscript | "unreadable" { + const records = parseJsonlRecords(text); + if (records === "unreadable") { + return "unreadable"; + } + + let sawClaudeEntry = false; + let sawUser = false; + let lastUserTimeMs: number | null = null; + let workHappened = false; + let alreadyShared = false; + let previousUserTimeMs: number | null = null; + + for (const record of records) { + const kind = classifyClaudeRow(record); + if (kind !== "foreign") { + sawClaudeEntry = true; + } + + if (kind === "user") { + const userTimeMs = nextUserTimeMs(record.timestamp, previousUserTimeMs); + if (userTimeMs === "unreadable") { + return "unreadable"; + } + + sawUser = true; + lastUserTimeMs = userTimeMs; + if (userTimeMs !== null) { + previousUserTimeMs = userTimeMs; + } + workHappened = false; + alreadyShared = false; + continue; + } + + if (sawUser) { + workHappened = workHappened || kind === "work"; + alreadyShared = alreadyShared || hasMainframeVideoUrl(record); + } + } + + if (!sawClaudeEntry) { + return "unreadable"; + } + + return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; +} + +function classifyClaudeRow(record: JsonRecord): ClaudeRowKind { + if (record.type !== "user" && record.type !== "assistant") { + return "foreign"; + } + + const message = record.message; + if (!isJsonRecord(message)) { + return "foreign"; + } + + if (record.type === "assistant") { + return hasToolUseBlock(message.content) ? "work" : "claude-other"; + } + + return isRealUserMessage(message) ? "user" : "claude-other"; +} + +// A real user turn carries human text: either a plain string or content blocks +// that include a non-empty `text` block. Tool-result turns reuse the `user` +// type but carry `tool_result` blocks instead of prompt text, so they are +// excluded to keep the AFK timer anchored to genuine user activity. +function isRealUserMessage(message: JsonRecord): boolean { + const content = message.content; + if (isNonEmptyString(content)) { + return true; + } + + if (!Array.isArray(content)) { + return false; + } + + if (content.some(isToolResultBlock)) { + return false; + } + + return content.some(isNonEmptyTextBlock); +} + +function hasToolUseBlock(content: unknown): boolean { + return Array.isArray(content) && content.some(isToolUseBlock); +} + +function isToolUseBlock(block: unknown): boolean { + return isJsonRecord(block) && block.type === "tool_use"; +} + +function isToolResultBlock(block: unknown): boolean { + return isJsonRecord(block) && block.type === "tool_result"; +} + +function isNonEmptyTextBlock(block: unknown): boolean { + return isJsonRecord(block) && block.type === "text" && isNonEmptyString(block.text); +} diff --git a/hooks/codex/stop-evaluator.ts b/hooks/codex/stop-evaluator.ts index 527370b..f6a176d 100644 --- a/hooks/codex/stop-evaluator.ts +++ b/hooks/codex/stop-evaluator.ts @@ -1,44 +1,16 @@ -import { decideStop } from "../core/stop-policy.js"; -import { parseJsonRecord } from "../core/json.js"; +import { + type BlockReasonStopHookOutput, + evaluateBlockReasonStopHook, + type StopHookEvaluationInput, +} from "../core/stop-hook.js"; import { summarizeCodexTranscriptFile } from "./transcript.js"; -export type CodexStopEvaluationInput = { - stdin: string; - nowMs?: number; -}; +export type CodexStopEvaluationInput = StopHookEvaluationInput; -// Codex continues a stopped turn when a `Stop` hook returns -// `{ decision: "block", reason }`, using `reason` as a fresh prompt. An empty -// object lets the turn stop normally. -export type CodexStopHookOutput = { decision?: "block"; reason?: string }; +// Codex adopted Claude Code's Stop hook contract, so the shared block/reason +// protocol applies unchanged; only the transcript format differs. +export type CodexStopHookOutput = BlockReasonStopHookOutput; export function evaluateCodexStopHook(input: CodexStopEvaluationInput): CodexStopHookOutput { - const hookInput = parseCodexStopInput(input.stdin); - if (hookInput === null || hookInput.stopHookActive) { - return {}; - } - - const summary = summarizeCodexTranscriptFile(hookInput.transcriptPath); - const decision = decideStop(summary, input.nowMs ?? Date.now()); - if (decision.kind === "skip") { - return {}; - } - - return { decision: "block", reason: decision.message }; -} - -function parseCodexStopInput( - stdin: string, -): { transcriptPath: string; stopHookActive: boolean } | null { - const input = parseJsonRecord(stdin); - if (input === null || input.hook_event_name !== "Stop") { - return null; - } - - const transcriptPath = input.transcript_path; - if (typeof transcriptPath !== "string" || transcriptPath.trim() === "") { - return null; - } - - return { transcriptPath, stopHookActive: input.stop_hook_active === true }; + return evaluateBlockReasonStopHook(input, summarizeCodexTranscriptFile); } diff --git a/hooks/core/stop-hook.ts b/hooks/core/stop-hook.ts new file mode 100644 index 0000000..aabcc5f --- /dev/null +++ b/hooks/core/stop-hook.ts @@ -0,0 +1,49 @@ +import { parseJsonRecord } from "./json.js"; +import { decideStop } from "./stop-policy.js"; +import type { TranscriptSummary } from "./transcript.js"; + +export type StopHookEvaluationInput = { + stdin: string; + nowMs?: number; +}; + +// Claude Code and Codex share one Stop hook contract: read `transcript_path` +// and `stop_hook_active` from the host payload, and continue a stopped turn by +// returning `{ decision: "block", reason }`. An empty object lets the turn stop +// normally. `stop_hook_active` is true once a prior block already re-prompted +// the agent, so we must not block again or the turn would never end. +export type BlockReasonStopHookOutput = { decision?: "block"; reason?: string }; + +export function evaluateBlockReasonStopHook( + input: StopHookEvaluationInput, + summarizeTranscriptFile: (transcriptPath: string) => TranscriptSummary, +): BlockReasonStopHookOutput { + const hookInput = parseStopHookInput(input.stdin); + if (hookInput === null || hookInput.stopHookActive) { + return {}; + } + + const summary = summarizeTranscriptFile(hookInput.transcriptPath); + const decision = decideStop(summary, input.nowMs ?? Date.now()); + if (decision.kind === "skip") { + return {}; + } + + return { decision: "block", reason: decision.message }; +} + +function parseStopHookInput( + stdin: string, +): { transcriptPath: string; stopHookActive: boolean } | null { + const input = parseJsonRecord(stdin); + if (input === null || input.hook_event_name !== "Stop") { + return null; + } + + const transcriptPath = input.transcript_path; + if (typeof transcriptPath !== "string" || transcriptPath.trim() === "") { + return null; + } + + return { transcriptPath, stopHookActive: input.stop_hook_active === true }; +} diff --git a/package.json b/package.json index c8b29ae..9e84996 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@mainframe/plugins", "version": "0.1.0", "private": true, - "description": "Mainframe Cursor and Codex plugin manifests, skill, MCP wiring, and stop hooks.", + "description": "Mainframe Cursor, Codex, and Claude Code plugin manifests, skill, MCP wiring, and stop hooks.", "keywords": [ "agent-skills", "hooks", @@ -17,11 +17,13 @@ "url": "https://github.com/mainframecomputer/mainframe-plugins.git" }, "bin": { + "mainframe-hook-claude": "./dist/hooks/claude/stop.js", "mainframe-hook-codex": "./dist/hooks/codex/stop.js", "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js" }, "files": [ ".agents", + ".claude-plugin", ".codex-plugin", ".cursor-plugin", ".mcp.json", @@ -29,6 +31,7 @@ "README.md", "assets/logo.png", "dist", + "hooks/claude/hooks.json", "hooks/codex/hooks.json", "hooks/cursor/hooks.json", "skills" @@ -38,9 +41,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "build": "rm -rf dist && tsc -p tsconfig.build.json && bun tooling/mark-executable.ts", "build:check": "bun run build && git diff --exit-code -- dist", - "generate": "bun tooling/generate.ts && oxfmt --write package.json .cursor-plugin .codex-plugin .agents", + "generate": "bun tooling/generate.ts && oxfmt --write package.json .cursor-plugin .codex-plugin .agents .claude-plugin", "check:generated": "bun tooling/generated-drift-check.ts", - "archive:release": "bun run generate && bun run build && git diff --exit-code -- package.json .cursor-plugin .codex-plugin .agents dist && bun tooling/release-archive.ts", + "archive:release": "bun run generate && bun run build && git diff --exit-code -- package.json .cursor-plugin .codex-plugin .agents .claude-plugin dist && bun tooling/release-archive.ts", "pack:release": "bun run verify && bun run archive:release", "lint": "oxlint . --deny-warnings --report-unused-disable-directives", "lint:fix": "oxlint . --fix", diff --git a/tooling/generate.ts b/tooling/generate.ts index 95e9bf3..7a35d7b 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -29,6 +29,7 @@ const MetadataSchema = z.object({ const CURSOR_HOOKS = "./hooks/cursor/hooks.json"; const CODEX_HOOKS = "./hooks/codex/hooks.json"; +const CLAUDE_HOOKS = "./hooks/claude/hooks.json"; const metadata = MetadataSchema.parse({ name: "mainframe", @@ -76,6 +77,9 @@ function main(): void { writeJson(".codex-plugin/plugin.json", codexManifest()); writeJson(".agents/plugins/marketplace.json", codexMarketplace()); + writeJson(".claude-plugin/plugin.json", claudeManifest()); + writeJson(".claude-plugin/marketplace.json", claudeMarketplace()); + updatePackageJson(); } @@ -140,12 +144,39 @@ function codexMarketplace() { }; } +function claudeManifest() { + return { + ...sharedManifest, + skills: metadata.skillDirectory, + mcpServers: metadata.mcpServers, + hooks: CLAUDE_HOOKS, + }; +} + +// Claude Code loads the plugin in place from the repository root, where its +// `.claude-plugin/marketplace.json` and `.claude-plugin/plugin.json` live side +// by side. The relative `"./"` source points at that root so the Claude plugin +// shares the same skill, MCP wiring, and hook runtime as the other hosts. +function claudeMarketplace() { + return { + name: metadata.name, + owner: metadata.author, + plugins: [ + { + name: metadata.name, + source: "./", + description: metadata.description, + }, + ], + }; +} + function updatePackageJson(): void { const packageJson = JsonRecordSchema.parse(JSON.parse(readFileSync("package.json", "utf8"))); packageJson.name = metadata.packageName; packageJson.version = metadata.version; packageJson.description = - "Mainframe Cursor and Codex plugin manifests, skill, MCP wiring, and stop hooks."; + "Mainframe Cursor, Codex, and Claude Code plugin manifests, skill, MCP wiring, and stop hooks."; packageJson.private = true; packageJson.license = metadata.license; packageJson.homepage = metadata.homepage; @@ -157,6 +188,7 @@ function updatePackageJson(): void { packageJson.bin = { "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js", "mainframe-hook-codex": "./dist/hooks/codex/stop.js", + "mainframe-hook-claude": "./dist/hooks/claude/stop.js", }; packageJson.files = PACKAGE_FILES; diff --git a/tooling/generated-drift-check.ts b/tooling/generated-drift-check.ts index cf49e61..b97d339 100644 --- a/tooling/generated-drift-check.ts +++ b/tooling/generated-drift-check.ts @@ -6,6 +6,8 @@ const generatedPaths = [ ".cursor-plugin/marketplace.json", ".codex-plugin/plugin.json", ".agents/plugins/marketplace.json", + ".claude-plugin/plugin.json", + ".claude-plugin/marketplace.json", "package.json", ]; diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index 0db7069..799f5fd 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -58,6 +58,10 @@ const CodexManifestSchema = SharedManifestSchema.extend({ .strict(), }).strict(); +const ClaudeManifestSchema = SharedManifestSchema.extend({ + hooks: z.literal("./hooks/claude/hooks.json"), +}).strict(); + describe("generated plugin manifests", () => { it(".cursor-plugin/plugin.json matches the Cursor plugin schema", () => { const manifest = CursorManifestSchema.parse( @@ -75,6 +79,14 @@ describe("generated plugin manifests", () => { expect(manifest.hooks).toBe("./hooks/codex/hooks.json"); }); + it(".claude-plugin/plugin.json matches the Claude Code plugin schema", () => { + const manifest = ClaudeManifestSchema.parse( + JSON.parse(readFileSync(".claude-plugin/plugin.json", "utf8")), + ); + + expect(manifest.hooks).toBe("./hooks/claude/hooks.json"); + }); + it("Cursor marketplace metadata matches the Cursor marketplace schema", () => { const marketplaceSchema = z .object({ @@ -134,4 +146,24 @@ describe("generated plugin manifests", () => { marketplaceSchema.parse(JSON.parse(readFileSync(".agents/plugins/marketplace.json", "utf8"))); }); + + it("Claude marketplace metadata matches the Claude marketplace schema", () => { + const marketplaceSchema = z + .object({ + name: z.literal("mainframe"), + owner: AuthorSchema, + plugins: z.tuple([ + z + .object({ + name: z.literal("mainframe"), + source: z.literal("./"), + description: DescriptionSchema, + }) + .strict(), + ]), + }) + .strict(); + + marketplaceSchema.parse(JSON.parse(readFileSync(".claude-plugin/marketplace.json", "utf8"))); + }); }); diff --git a/tooling/mark-executable.ts b/tooling/mark-executable.ts index 06c9e7e..d8f0a33 100644 --- a/tooling/mark-executable.ts +++ b/tooling/mark-executable.ts @@ -1,6 +1,10 @@ import { chmodSync } from "node:fs"; -const files = ["dist/hooks/cursor/stop.js", "dist/hooks/codex/stop.js"]; +const files = [ + "dist/hooks/cursor/stop.js", + "dist/hooks/codex/stop.js", + "dist/hooks/claude/stop.js", +]; for (const file of files) { chmodSync(file, 0o755); diff --git a/tooling/package-surface.test.ts b/tooling/package-surface.test.ts index befe928..10c84fa 100644 --- a/tooling/package-surface.test.ts +++ b/tooling/package-surface.test.ts @@ -27,7 +27,7 @@ const CursorHooksSchema = z }) .passthrough(); -const CodexHooksSchema = z +const NestedStopHooksSchema = z .object({ hooks: z.object({ Stop: z.tuple([ @@ -77,7 +77,7 @@ describe("package runtime surface", () => { it("ships the executable Codex hook runtime referenced by hooks.json and package bin", () => { const packageJson = PackageSchema.parse(readJson("package.json")); - const hooks = CodexHooksSchema.parse(readJson("hooks/codex/hooks.json")); + const hooks = NestedStopHooksSchema.parse(readJson("hooks/codex/hooks.json")); const hookTarget = readPluginRootNodeTarget( hooks.hooks.Stop[0].hooks[0].command, @@ -91,6 +91,22 @@ describe("package runtime surface", () => { expect(statSync(hookTarget).mode & 0o111).not.toBe(0); }); + it("ships the executable Claude hook runtime referenced by hooks.json and package bin", () => { + const packageJson = PackageSchema.parse(readJson("package.json")); + const hooks = NestedStopHooksSchema.parse(readJson("hooks/claude/hooks.json")); + + const hookTarget = readPluginRootNodeTarget( + hooks.hooks.Stop[0].hooks[0].command, + "CLAUDE_PLUGIN_ROOT", + ); + const binTargets = Object.values(packageJson.bin).map((target) => target.replace(/^\.\//, "")); + + expect(hookTarget).toBe("dist/hooks/claude/stop.js"); + expect(binTargets).toContain(hookTarget); + expect(isPackaged(hookTarget, packageJson.files)).toBe(true); + expect(statSync(hookTarget).mode & 0o111).not.toBe(0); + }); + it("does not include symlinks in package files", () => { const packageJson = PackageSchema.parse(readJson("package.json")); @@ -104,8 +120,10 @@ function readJson(path: string): unknown { return JSON.parse(readFileSync(path, "utf8")); } +// Hosts reference the bundled runtime through their plugin-root env var, which +// may be written bare (`$PLUGIN_ROOT`) or braced (`${CLAUDE_PLUGIN_ROOT}`). function readPluginRootNodeTarget(command: string, rootEnv: string): string { - const match = new RegExp(`^node "\\$${rootEnv}/(.+)"$`).exec(command); + const match = new RegExp(`^node "\\$\\{?${rootEnv}\\}?/(.+)"$`).exec(command); if (match === null) { throw new Error(`Unexpected hook command: ${command}`); } diff --git a/tooling/package-surface.ts b/tooling/package-surface.ts index da3e6ea..e18c3f2 100644 --- a/tooling/package-surface.ts +++ b/tooling/package-surface.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; export const PACKAGE_FILES = [ ".agents", + ".claude-plugin", ".codex-plugin", ".cursor-plugin", ".mcp.json", @@ -10,6 +11,7 @@ export const PACKAGE_FILES = [ "README.md", "assets/logo.png", "dist", + "hooks/claude/hooks.json", "hooks/codex/hooks.json", "hooks/cursor/hooks.json", "skills", @@ -17,6 +19,8 @@ export const PACKAGE_FILES = [ export const SHIPPED_FILES = [ ".agents/plugins/marketplace.json", + ".claude-plugin/marketplace.json", + ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".cursor-plugin/marketplace.json", ".cursor-plugin/plugin.json", @@ -24,17 +28,22 @@ export const SHIPPED_FILES = [ "LICENSE", "README.md", "assets/logo.png", + "dist/hooks/claude/stop-evaluator.js", + "dist/hooks/claude/stop.js", + "dist/hooks/claude/transcript.js", "dist/hooks/codex/stop-evaluator.js", "dist/hooks/codex/stop.js", "dist/hooks/codex/transcript.js", "dist/hooks/core/afk-gate.js", "dist/hooks/core/json.js", "dist/hooks/core/run-stop-hook.js", + "dist/hooks/core/stop-hook.js", "dist/hooks/core/stop-policy.js", "dist/hooks/core/transcript.js", "dist/hooks/cursor/stop-evaluator.js", "dist/hooks/cursor/stop.js", "dist/hooks/cursor/transcript.js", + "hooks/claude/hooks.json", "hooks/codex/hooks.json", "hooks/cursor/hooks.json", "skills/share-video/SKILL.md", diff --git a/tooling/release-archive.ts b/tooling/release-archive.ts index d6e6b36..85d691b 100644 --- a/tooling/release-archive.ts +++ b/tooling/release-archive.ts @@ -27,7 +27,9 @@ const PluginManifestSchema = z.object({ skills: z.string().min(1), }); -const CodexManifestSchema = z.object({ +// Codex and Claude Code manifests share the same component layout: a hooks +// file plus the shared MCP wiring and skill directory. +const NestedHostManifestSchema = z.object({ hooks: z.string().min(1), mcpServers: z.string().min(1), skills: z.string().min(1), @@ -37,9 +39,12 @@ const packageJson = PackageSchema.parse(JSON.parse(readFileSync("package.json", const pluginManifest = PluginManifestSchema.parse( JSON.parse(readFileSync(".cursor-plugin/plugin.json", "utf8")), ); -const codexManifest = CodexManifestSchema.parse( +const codexManifest = NestedHostManifestSchema.parse( JSON.parse(readFileSync(".codex-plugin/plugin.json", "utf8")), ); +const claudeManifest = NestedHostManifestSchema.parse( + JSON.parse(readFileSync(".claude-plugin/plugin.json", "utf8")), +); const archiveName = `${packageJson.name.replace(/^@/, "").replace("/", "-")}-${packageJson.version}.tgz`; const releaseDir = resolve("release"); const archivePath = join(releaseDir, archiveName); @@ -59,6 +64,9 @@ const manifestPaths = [ codexManifest.hooks, codexManifest.mcpServers, codexManifest.skills, + claudeManifest.hooks, + claudeManifest.mcpServers, + claudeManifest.skills, ].map((path) => path.replace(/^\.\//, "")); assertPluginPackageSurface(packageJson.files); From 2fffd992c490c92090d6cda5b67bc696952ff6dc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 23:22:45 +0000 Subject: [PATCH 2/7] Extract shared transcript accumulation loop into core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- dist/hooks/claude/transcript.js | 49 ++++--------- dist/hooks/codex/transcript.js | 46 ++---------- dist/hooks/core/transcript.js | 35 ++++++++++ hooks/claude/transcript.ts | 61 ++++------------ hooks/codex/transcript.ts | 58 +++------------- hooks/core/transcript.test.ts | 119 ++++++++++++++++++++++++++++++++ hooks/core/transcript.ts | 50 +++++++++++++- 7 files changed, 246 insertions(+), 172 deletions(-) create mode 100644 hooks/core/transcript.test.ts diff --git a/dist/hooks/claude/transcript.js b/dist/hooks/claude/transcript.js index 72fcc7a..2518fa2 100644 --- a/dist/hooks/claude/transcript.js +++ b/dist/hooks/claude/transcript.js @@ -1,5 +1,5 @@ import { isJsonRecord, parseJsonlRecords } from "../core/json.js"; -import { hasMainframeVideoUrl, isNonEmptyString, nextUserTimeMs, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; +import { accumulateClassifiedRows, isNonEmptyString, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; export function summarizeClaudeTranscriptFile(path) { return summarizeTranscriptFile(path, parseClaudeRows); } @@ -18,53 +18,28 @@ function parseClaudeRows(text) { if (records === "unreadable") { return "unreadable"; } - let sawClaudeEntry = false; - let sawUser = false; - let lastUserTimeMs = null; - let workHappened = false; - let alreadyShared = false; - let previousUserTimeMs = null; - for (const record of records) { - const kind = classifyClaudeRow(record); - if (kind !== "foreign") { - sawClaudeEntry = true; - } - if (kind === "user") { - const userTimeMs = nextUserTimeMs(record.timestamp, previousUserTimeMs); - if (userTimeMs === "unreadable") { - return "unreadable"; - } - sawUser = true; - lastUserTimeMs = userTimeMs; - if (userTimeMs !== null) { - previousUserTimeMs = userTimeMs; - } - workHappened = false; - alreadyShared = false; - continue; - } - if (sawUser) { - workHappened = workHappened || kind === "work"; - alreadyShared = alreadyShared || hasMainframeVideoUrl(record); - } - } - if (!sawClaudeEntry) { + if (!records.some(isClaudeMessageEntry)) { return "unreadable"; } - return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; + return accumulateClassifiedRows(records, classifyClaudeRow); +} +// A Claude transcript always carries user/assistant message entries; their +// absence means this is a foreign or empty format, so fail closed. +function isClaudeMessageEntry(record) { + return (record.type === "user" || record.type === "assistant") && isJsonRecord(record.message); } function classifyClaudeRow(record) { if (record.type !== "user" && record.type !== "assistant") { - return "foreign"; + return "ignore"; } const message = record.message; if (!isJsonRecord(message)) { - return "foreign"; + return "ignore"; } if (record.type === "assistant") { - return hasToolUseBlock(message.content) ? "work" : "claude-other"; + return hasToolUseBlock(message.content) ? "work" : "ignore"; } - return isRealUserMessage(message) ? "user" : "claude-other"; + return isRealUserMessage(message) ? "user" : "ignore"; } // A real user turn carries human text: either a plain string or content blocks // that include a non-empty `text` block. Tool-result turns reuse the `user` diff --git a/dist/hooks/codex/transcript.js b/dist/hooks/codex/transcript.js index ff9efc6..ecb0b6e 100644 --- a/dist/hooks/codex/transcript.js +++ b/dist/hooks/codex/transcript.js @@ -1,4 +1,4 @@ -import { hasMainframeVideoUrl, isNonEmptyString, nextUserTimeMs, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; +import { accumulateClassifiedRows, isNonEmptyString, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; import { isJsonRecord, parseJsonlRecords } from "../core/json.js"; export function summarizeCodexTranscriptFile(path) { return summarizeTranscriptFile(path, parseCodexRows); @@ -17,49 +17,17 @@ function parseCodexRows(text) { if (records === "unreadable") { return "unreadable"; } - let sawSessionMeta = false; - let sawUser = false; - let lastUserTimeMs = null; - let workHappened = false; - let alreadyShared = false; - let previousUserTimeMs = null; - for (const record of records) { - const kind = classifyCodexRow(record); - if (kind === "session-meta") { - sawSessionMeta = true; - continue; - } - if (kind === "user") { - const userTimeMs = nextUserTimeMs(record.timestamp, previousUserTimeMs); - if (userTimeMs === "unreadable") { - return "unreadable"; - } - sawUser = true; - lastUserTimeMs = userTimeMs; - if (userTimeMs !== null) { - previousUserTimeMs = userTimeMs; - } - workHappened = false; - alreadyShared = false; - continue; - } - if (sawUser) { - workHappened = workHappened || kind === "work"; - alreadyShared = alreadyShared || hasMainframeVideoUrl(record); - } - } - if (!sawSessionMeta) { + // Every Codex rollout opens with a `session_meta` row; its absence means this + // isn't a Codex rollout, so fail closed. + if (!records.some((record) => record.type === "session_meta")) { return "unreadable"; } - return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; + return accumulateClassifiedRows(records, classifyCodexRow); } function classifyCodexRow(record) { - if (record.type === "session_meta") { - return "session-meta"; - } const payload = record.payload; if (!isJsonRecord(payload)) { - return "other"; + return "ignore"; } if (record.type === "event_msg" && payload.type === "user_message" && @@ -69,7 +37,7 @@ function classifyCodexRow(record) { if (record.type === "response_item" && isToolCallType(payload.type)) { return "work"; } - return "other"; + return "ignore"; } // Responses API tool invocations are persisted as `response_item` rows whose // payload type ends in `_call` (`function_call`, `local_shell_call`, diff --git a/dist/hooks/core/transcript.js b/dist/hooks/core/transcript.js index 305a4f8..8fa75d7 100644 --- a/dist/hooks/core/transcript.js +++ b/dist/hooks/core/transcript.js @@ -32,6 +32,41 @@ export function summarizeTranscript(text, parseRows) { alreadyShared: parsed.alreadyShared, }; } +// The host-agnostic accumulation pass. Hosts classify rows; this owns the +// append-only invariants every host shares: each user turn advances the +// non-decreasing time cursor (a regression fails closed as "unreadable") and +// resets the per-turn work/share flags, and only post-user rows count toward +// work or an existing share. Keeping this in one place stops the fail-closed +// ordering rule from drifting between hosts. +export function accumulateClassifiedRows(records, classify) { + let sawUser = false; + let lastUserTimeMs = null; + let workHappened = false; + let alreadyShared = false; + let previousUserTimeMs = null; + for (const record of records) { + const kind = classify(record); + if (kind === "user") { + const userTimeMs = nextUserTimeMs(record.timestamp, previousUserTimeMs); + if (userTimeMs === "unreadable") { + return "unreadable"; + } + sawUser = true; + lastUserTimeMs = userTimeMs; + if (userTimeMs !== null) { + previousUserTimeMs = userTimeMs; + } + workHappened = false; + alreadyShared = false; + continue; + } + if (sawUser) { + workHappened = workHappened || kind === "work"; + alreadyShared = alreadyShared || hasMainframeVideoUrl(record); + } + } + return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; +} function readTranscriptText(path) { try { const stat = lstatSync(path); diff --git a/hooks/claude/transcript.ts b/hooks/claude/transcript.ts index 2562f63..98505f6 100644 --- a/hooks/claude/transcript.ts +++ b/hooks/claude/transcript.ts @@ -1,16 +1,14 @@ import { isJsonRecord, type JsonRecord, parseJsonlRecords } from "../core/json.js"; import { - hasMainframeVideoUrl, + accumulateClassifiedRows, + type ClassifiedRowKind, isNonEmptyString, - nextUserTimeMs, type ParsedTranscript, summarizeTranscript, summarizeTranscriptFile, type TranscriptSummary, } from "../core/transcript.js"; -type ClaudeRowKind = "user" | "work" | "claude-other" | "foreign"; - export function summarizeClaudeTranscriptFile(path: string): TranscriptSummary { return summarizeTranscriptFile(path, parseClaudeRows); } @@ -32,63 +30,34 @@ function parseClaudeRows(text: string): ParsedTranscript | "unreadable" { return "unreadable"; } - let sawClaudeEntry = false; - let sawUser = false; - let lastUserTimeMs: number | null = null; - let workHappened = false; - let alreadyShared = false; - let previousUserTimeMs: number | null = null; - - for (const record of records) { - const kind = classifyClaudeRow(record); - if (kind !== "foreign") { - sawClaudeEntry = true; - } - - if (kind === "user") { - const userTimeMs = nextUserTimeMs(record.timestamp, previousUserTimeMs); - if (userTimeMs === "unreadable") { - return "unreadable"; - } - - sawUser = true; - lastUserTimeMs = userTimeMs; - if (userTimeMs !== null) { - previousUserTimeMs = userTimeMs; - } - workHappened = false; - alreadyShared = false; - continue; - } - - if (sawUser) { - workHappened = workHappened || kind === "work"; - alreadyShared = alreadyShared || hasMainframeVideoUrl(record); - } - } - - if (!sawClaudeEntry) { + if (!records.some(isClaudeMessageEntry)) { return "unreadable"; } - return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; + return accumulateClassifiedRows(records, classifyClaudeRow); +} + +// A Claude transcript always carries user/assistant message entries; their +// absence means this is a foreign or empty format, so fail closed. +function isClaudeMessageEntry(record: JsonRecord): boolean { + return (record.type === "user" || record.type === "assistant") && isJsonRecord(record.message); } -function classifyClaudeRow(record: JsonRecord): ClaudeRowKind { +function classifyClaudeRow(record: JsonRecord): ClassifiedRowKind { if (record.type !== "user" && record.type !== "assistant") { - return "foreign"; + return "ignore"; } const message = record.message; if (!isJsonRecord(message)) { - return "foreign"; + return "ignore"; } if (record.type === "assistant") { - return hasToolUseBlock(message.content) ? "work" : "claude-other"; + return hasToolUseBlock(message.content) ? "work" : "ignore"; } - return isRealUserMessage(message) ? "user" : "claude-other"; + return isRealUserMessage(message) ? "user" : "ignore"; } // A real user turn carries human text: either a plain string or content blocks diff --git a/hooks/codex/transcript.ts b/hooks/codex/transcript.ts index 7ad78de..24e21ee 100644 --- a/hooks/codex/transcript.ts +++ b/hooks/codex/transcript.ts @@ -1,7 +1,7 @@ import { - hasMainframeVideoUrl, + accumulateClassifiedRows, + type ClassifiedRowKind, isNonEmptyString, - nextUserTimeMs, type ParsedTranscript, summarizeTranscript, summarizeTranscriptFile, @@ -9,8 +9,6 @@ import { } from "../core/transcript.js"; import { isJsonRecord, type JsonRecord, parseJsonlRecords } from "../core/json.js"; -type CodexRowKind = "session-meta" | "user" | "work" | "other"; - export function summarizeCodexTranscriptFile(path: string): TranscriptSummary { return summarizeTranscriptFile(path, parseCodexRows); } @@ -31,57 +29,19 @@ function parseCodexRows(text: string): ParsedTranscript | "unreadable" { return "unreadable"; } - let sawSessionMeta = false; - let sawUser = false; - let lastUserTimeMs: number | null = null; - let workHappened = false; - let alreadyShared = false; - let previousUserTimeMs: number | null = null; - - for (const record of records) { - const kind = classifyCodexRow(record); - if (kind === "session-meta") { - sawSessionMeta = true; - continue; - } - - if (kind === "user") { - const userTimeMs = nextUserTimeMs(record.timestamp, previousUserTimeMs); - if (userTimeMs === "unreadable") { - return "unreadable"; - } - - sawUser = true; - lastUserTimeMs = userTimeMs; - if (userTimeMs !== null) { - previousUserTimeMs = userTimeMs; - } - workHappened = false; - alreadyShared = false; - continue; - } - - if (sawUser) { - workHappened = workHappened || kind === "work"; - alreadyShared = alreadyShared || hasMainframeVideoUrl(record); - } - } - - if (!sawSessionMeta) { + // Every Codex rollout opens with a `session_meta` row; its absence means this + // isn't a Codex rollout, so fail closed. + if (!records.some((record) => record.type === "session_meta")) { return "unreadable"; } - return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; + return accumulateClassifiedRows(records, classifyCodexRow); } -function classifyCodexRow(record: JsonRecord): CodexRowKind { - if (record.type === "session_meta") { - return "session-meta"; - } - +function classifyCodexRow(record: JsonRecord): ClassifiedRowKind { const payload = record.payload; if (!isJsonRecord(payload)) { - return "other"; + return "ignore"; } if ( @@ -96,7 +56,7 @@ function classifyCodexRow(record: JsonRecord): CodexRowKind { return "work"; } - return "other"; + return "ignore"; } // Responses API tool invocations are persisted as `response_item` rows whose diff --git a/hooks/core/transcript.test.ts b/hooks/core/transcript.test.ts new file mode 100644 index 0000000..f1c4807 --- /dev/null +++ b/hooks/core/transcript.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import type { JsonRecord } from "./json.js"; +import { accumulateClassifiedRows, type ClassifiedRowKind } from "./transcript.js"; + +// A synthetic classifier so these tests cover the shared accumulation invariants +// independently of any host's wire format: rows carry their own kind. +function classify(record: JsonRecord): ClassifiedRowKind { + if (record.kind === "user" || record.kind === "work") { + return record.kind; + } + return "ignore"; +} + +function row(kind: ClassifiedRowKind, extra: Record = {}): JsonRecord { + return { kind, ...extra }; +} + +function accumulate(records: JsonRecord[]) { + return accumulateClassifiedRows(records, classify); +} + +describe("accumulateClassifiedRows", () => { + it("accumulates work after the last user turn", () => { + expect( + accumulate([ + row("user", { timestamp: "2026-05-08T12:00:00.000Z" }), + row("user", { timestamp: "2026-05-08T13:00:00.000Z" }), + row("work", { timestamp: "2026-05-08T13:05:00.000Z" }), + ]), + ).toEqual({ + sawUser: true, + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.000Z"), + workHappened: true, + alreadyShared: false, + }); + }); + + it("resets work and share state on each user turn", () => { + expect( + accumulate([ + row("user", { timestamp: "2026-05-08T13:00:00.000Z" }), + row("work", { timestamp: "2026-05-08T13:05:00.000Z" }), + row("user", { timestamp: "2026-05-08T14:00:00.000Z" }), + ]), + ).toEqual({ + sawUser: true, + lastUserTimeMs: Date.parse("2026-05-08T14:00:00.000Z"), + workHappened: false, + alreadyShared: false, + }); + }); + + it("fails closed when user timestamps move backwards", () => { + expect( + accumulate([ + row("user", { timestamp: "2026-05-08T15:00:00.000Z" }), + row("user", { timestamp: "2026-05-08T13:00:00.000Z" }), + ]), + ).toBe("unreadable"); + }); + + it("does not count work or shares that precede the first user turn", () => { + expect( + accumulate([ + row("work", { timestamp: "2026-05-08T12:00:00.000Z" }), + row("ignore", { note: "https://mainframe.app/v/37507089004e8f3700deb918a48b2556" }), + row("user", { timestamp: "2026-05-08T13:00:00.000Z" }), + ]), + ).toEqual({ + sawUser: true, + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.000Z"), + workHappened: false, + alreadyShared: false, + }); + }); + + it("detects a Mainframe video URL in any post-user row", () => { + expect( + accumulate([ + row("user", { timestamp: "2026-05-08T13:00:00.000Z" }), + row("work", { timestamp: "2026-05-08T13:05:00.000Z" }), + row("ignore", { + note: "Shared: https://mainframe.app/v/37507089004e8f3700deb918a48b2556", + }), + ]), + ).toMatchObject({ workHappened: true, alreadyShared: true }); + }); + + it("does not treat a video URL in the user turn itself as an existing share", () => { + expect( + accumulate([ + row("user", { + timestamp: "2026-05-08T13:00:00.000Z", + note: "https://mainframe.app/v/37507089004e8f3700deb918a48b2556", + }), + row("work", { timestamp: "2026-05-08T13:05:00.000Z" }), + ]), + ).toMatchObject({ workHappened: true, alreadyShared: false }); + }); + + it("reports no user when no user turn is present", () => { + expect(accumulate([row("work", { timestamp: "2026-05-08T13:00:00.000Z" })])).toEqual({ + sawUser: false, + lastUserTimeMs: null, + workHappened: false, + alreadyShared: false, + }); + }); + + it("keeps lastUserTimeMs null when the latest user timestamp is malformed", () => { + expect(accumulate([row("user", { timestamp: "not-a-timestamp" })])).toEqual({ + sawUser: true, + lastUserTimeMs: null, + workHappened: false, + alreadyShared: false, + }); + }); +}); diff --git a/hooks/core/transcript.ts b/hooks/core/transcript.ts index 2951d91..ff0d5a8 100644 --- a/hooks/core/transcript.ts +++ b/hooks/core/transcript.ts @@ -1,6 +1,6 @@ import { lstatSync, readFileSync } from "node:fs"; -import { isJsonRecord } from "./json.js"; +import { isJsonRecord, type JsonRecord } from "./json.js"; const MAX_TRANSCRIPT_BYTES = 5 * 1024 * 1024; const MIN_EPOCH_SECONDS = 946_684_800; @@ -71,6 +71,54 @@ export function summarizeTranscript( }; } +// Each transcript row reduces to one of three kinds for accumulation: a real +// user turn, agent work, or anything else to ignore. Hosts decide which rows +// are which; the shared loop below decides what to do with them. +export type ClassifiedRowKind = "user" | "work" | "ignore"; + +// The host-agnostic accumulation pass. Hosts classify rows; this owns the +// append-only invariants every host shares: each user turn advances the +// non-decreasing time cursor (a regression fails closed as "unreadable") and +// resets the per-turn work/share flags, and only post-user rows count toward +// work or an existing share. Keeping this in one place stops the fail-closed +// ordering rule from drifting between hosts. +export function accumulateClassifiedRows( + records: readonly JsonRecord[], + classify: (record: JsonRecord) => ClassifiedRowKind, +): ParsedTranscript | "unreadable" { + let sawUser = false; + let lastUserTimeMs: number | null = null; + let workHappened = false; + let alreadyShared = false; + let previousUserTimeMs: number | null = null; + + for (const record of records) { + const kind = classify(record); + if (kind === "user") { + const userTimeMs = nextUserTimeMs(record.timestamp, previousUserTimeMs); + if (userTimeMs === "unreadable") { + return "unreadable"; + } + + sawUser = true; + lastUserTimeMs = userTimeMs; + if (userTimeMs !== null) { + previousUserTimeMs = userTimeMs; + } + workHappened = false; + alreadyShared = false; + continue; + } + + if (sawUser) { + workHappened = workHappened || kind === "work"; + alreadyShared = alreadyShared || hasMainframeVideoUrl(record); + } + } + + return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; +} + function readTranscriptText(path: string): string | null { try { const stat = lstatSync(path); From 947eb14a952f3b6311baa81681aa2cc36a8c4e35 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 14:40:57 +0000 Subject: [PATCH 3/7] Address code-quality review: scope shared-loop comment, drop identity 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. --- dist/hooks/claude/stop-evaluator.js | 2 ++ dist/hooks/codex/stop-evaluator.js | 2 ++ dist/hooks/core/transcript.js | 10 ++++++---- dist/hooks/cursor/transcript.js | 4 ++++ hooks/claude/stop-evaluator.ts | 6 +----- hooks/codex/stop-evaluator.ts | 6 +----- hooks/core/transcript.ts | 10 ++++++---- hooks/cursor/transcript.ts | 4 ++++ 8 files changed, 26 insertions(+), 18 deletions(-) diff --git a/dist/hooks/claude/stop-evaluator.js b/dist/hooks/claude/stop-evaluator.js index c872b62..393d88d 100644 --- a/dist/hooks/claude/stop-evaluator.js +++ b/dist/hooks/claude/stop-evaluator.js @@ -1,5 +1,7 @@ import { evaluateBlockReasonStopHook, } from "../core/stop-hook.js"; import { summarizeClaudeTranscriptFile } from "./transcript.js"; +// Claude Code's Stop hook uses the shared block/reason protocol; only the +// transcript format is Claude-specific. export function evaluateClaudeStopHook(input) { return evaluateBlockReasonStopHook(input, summarizeClaudeTranscriptFile); } diff --git a/dist/hooks/codex/stop-evaluator.js b/dist/hooks/codex/stop-evaluator.js index c4f43a3..3a7693b 100644 --- a/dist/hooks/codex/stop-evaluator.js +++ b/dist/hooks/codex/stop-evaluator.js @@ -1,5 +1,7 @@ import { evaluateBlockReasonStopHook, } from "../core/stop-hook.js"; import { summarizeCodexTranscriptFile } from "./transcript.js"; +// Codex adopted Claude Code's Stop hook contract, so the shared block/reason +// protocol applies unchanged; only the transcript format differs. export function evaluateCodexStopHook(input) { return evaluateBlockReasonStopHook(input, summarizeCodexTranscriptFile); } diff --git a/dist/hooks/core/transcript.js b/dist/hooks/core/transcript.js index 8fa75d7..737da4a 100644 --- a/dist/hooks/core/transcript.js +++ b/dist/hooks/core/transcript.js @@ -32,12 +32,14 @@ export function summarizeTranscript(text, parseRows) { alreadyShared: parsed.alreadyShared, }; } -// The host-agnostic accumulation pass. Hosts classify rows; this owns the -// append-only invariants every host shares: each user turn advances the +// The shared row-accumulation pass for hosts whose transcripts need nothing +// beyond row classification (currently Codex and Claude Code). It owns the +// append-only invariants those hosts share: each user turn advances the // non-decreasing time cursor (a regression fails closed as "unreadable") and // resets the per-turn work/share flags, and only post-user rows count toward -// work or an existing share. Keeping this in one place stops the fail-closed -// ordering rule from drifting between hosts. +// work or an existing share. Cursor keeps its own loop because it additionally +// validates per-tool-call timestamps against the last user turn (see +// hooks/cursor/transcript.ts), which this classify-only pass cannot express. export function accumulateClassifiedRows(records, classify) { let sawUser = false; let lastUserTimeMs = null; diff --git a/dist/hooks/cursor/transcript.js b/dist/hooks/cursor/transcript.js index 2982327..2e55ad4 100644 --- a/dist/hooks/cursor/transcript.js +++ b/dist/hooks/cursor/transcript.js @@ -6,6 +6,10 @@ export function summarizeCursorTranscriptFile(path) { export function summarizeCursorTranscript(text) { return summarizeTranscript(text, parseCursorRows); } +// Unlike Codex and Claude, Cursor keeps a bespoke loop rather than core's +// `accumulateClassifiedRows`: it also fails closed when a post-user tool_call +// lacks a timestamp or predates the last user turn (see `readToolWorkTimeMs`), +// a per-row, stateful check the shared classify-only pass cannot express. function parseCursorRows(text) { const records = parseJsonlRecords(text); if (records === "unreadable") { diff --git a/hooks/claude/stop-evaluator.ts b/hooks/claude/stop-evaluator.ts index e8a6921..3aed0dc 100644 --- a/hooks/claude/stop-evaluator.ts +++ b/hooks/claude/stop-evaluator.ts @@ -5,12 +5,8 @@ import { } from "../core/stop-hook.js"; import { summarizeClaudeTranscriptFile } from "./transcript.js"; -export type ClaudeStopEvaluationInput = StopHookEvaluationInput; - // Claude Code's Stop hook uses the shared block/reason protocol; only the // transcript format is Claude-specific. -export type ClaudeStopHookOutput = BlockReasonStopHookOutput; - -export function evaluateClaudeStopHook(input: ClaudeStopEvaluationInput): ClaudeStopHookOutput { +export function evaluateClaudeStopHook(input: StopHookEvaluationInput): BlockReasonStopHookOutput { return evaluateBlockReasonStopHook(input, summarizeClaudeTranscriptFile); } diff --git a/hooks/codex/stop-evaluator.ts b/hooks/codex/stop-evaluator.ts index f6a176d..a9a00e2 100644 --- a/hooks/codex/stop-evaluator.ts +++ b/hooks/codex/stop-evaluator.ts @@ -5,12 +5,8 @@ import { } from "../core/stop-hook.js"; import { summarizeCodexTranscriptFile } from "./transcript.js"; -export type CodexStopEvaluationInput = StopHookEvaluationInput; - // Codex adopted Claude Code's Stop hook contract, so the shared block/reason // protocol applies unchanged; only the transcript format differs. -export type CodexStopHookOutput = BlockReasonStopHookOutput; - -export function evaluateCodexStopHook(input: CodexStopEvaluationInput): CodexStopHookOutput { +export function evaluateCodexStopHook(input: StopHookEvaluationInput): BlockReasonStopHookOutput { return evaluateBlockReasonStopHook(input, summarizeCodexTranscriptFile); } diff --git a/hooks/core/transcript.ts b/hooks/core/transcript.ts index ff0d5a8..74e6987 100644 --- a/hooks/core/transcript.ts +++ b/hooks/core/transcript.ts @@ -76,12 +76,14 @@ export function summarizeTranscript( // are which; the shared loop below decides what to do with them. export type ClassifiedRowKind = "user" | "work" | "ignore"; -// The host-agnostic accumulation pass. Hosts classify rows; this owns the -// append-only invariants every host shares: each user turn advances the +// The shared row-accumulation pass for hosts whose transcripts need nothing +// beyond row classification (currently Codex and Claude Code). It owns the +// append-only invariants those hosts share: each user turn advances the // non-decreasing time cursor (a regression fails closed as "unreadable") and // resets the per-turn work/share flags, and only post-user rows count toward -// work or an existing share. Keeping this in one place stops the fail-closed -// ordering rule from drifting between hosts. +// work or an existing share. Cursor keeps its own loop because it additionally +// validates per-tool-call timestamps against the last user turn (see +// hooks/cursor/transcript.ts), which this classify-only pass cannot express. export function accumulateClassifiedRows( records: readonly JsonRecord[], classify: (record: JsonRecord) => ClassifiedRowKind, diff --git a/hooks/cursor/transcript.ts b/hooks/cursor/transcript.ts index 7754918..c748439 100644 --- a/hooks/cursor/transcript.ts +++ b/hooks/cursor/transcript.ts @@ -24,6 +24,10 @@ export function summarizeCursorTranscript(text: string): TranscriptSummary { return summarizeTranscript(text, parseCursorRows); } +// Unlike Codex and Claude, Cursor keeps a bespoke loop rather than core's +// `accumulateClassifiedRows`: it also fails closed when a post-user tool_call +// lacks a timestamp or predates the last user turn (see `readToolWorkTimeMs`), +// a per-row, stateful check the shared classify-only pass cannot express. function parseCursorRows(text: string): ParsedTranscript | "unreadable" { const records = parseJsonlRecords(text); if (records === "unreadable") { From e92b9aae8ccf15de34327b57a707eec862457217 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 14:53:20 +0000 Subject: [PATCH 4/7] Tighten plugin-root hook-command regex to require matched braces 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. --- tooling/package-surface.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tooling/package-surface.test.ts b/tooling/package-surface.test.ts index 10c84fa..2e440b7 100644 --- a/tooling/package-surface.test.ts +++ b/tooling/package-surface.test.ts @@ -121,9 +121,10 @@ function readJson(path: string): unknown { } // Hosts reference the bundled runtime through their plugin-root env var, which -// may be written bare (`$PLUGIN_ROOT`) or braced (`${CLAUDE_PLUGIN_ROOT}`). +// may be written bare (`$PLUGIN_ROOT`) or braced (`${CLAUDE_PLUGIN_ROOT}`); the +// brace must be matched (no `${FOO` or `$FOO}`). function readPluginRootNodeTarget(command: string, rootEnv: string): string { - const match = new RegExp(`^node "\\$\\{?${rootEnv}\\}?/(.+)"$`).exec(command); + const match = new RegExp(`^node "\\$(?:\\{${rootEnv}\\}|${rootEnv})/(.+)"$`).exec(command); if (match === null) { throw new Error(`Unexpected hook command: ${command}`); } From 6ff1c739f640a1a9cb5d4b66e1147a76ada8f9b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 15:58:33 +0000 Subject: [PATCH 5/7] Exclude tool-result and meta user entries from the AFK timer 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". --- dist/hooks/claude/transcript.js | 32 ++++++++--- hooks/claude/fixtures/transcript.jsonl | 2 +- hooks/claude/transcript.test.ts | 77 ++++++++++++++++++++++++++ hooks/claude/transcript.ts | 36 +++++++++--- 4 files changed, 128 insertions(+), 19 deletions(-) diff --git a/dist/hooks/claude/transcript.js b/dist/hooks/claude/transcript.js index 2518fa2..c9a634b 100644 --- a/dist/hooks/claude/transcript.js +++ b/dist/hooks/claude/transcript.js @@ -8,11 +8,12 @@ export function summarizeClaudeTranscript(text) { } // Claude Code transcripts are append-only JSONL where each line is a typed // entry. A real user turn is `{ type: "user", message: { content } }` carrying -// human text; tool results reuse the same `user` type but carry `tool_result` -// blocks, so they must not reset the AFK timer. Assistant `tool_use` blocks -// count as work. Unrelated entry types (system, summary, ...) are ignored, but -// a transcript with no recognizable Claude message entry fails closed as -// "unreadable" so the hook never fires on a foreign or empty format. +// human text. Tool results and system-injected notes reuse the same +// `type: "user"` shape and are distinguished only by record-level markers (see +// `isSyntheticUserEntry`), so they must not reset the AFK timer. Assistant +// `tool_use` blocks count as work. Unrelated entry types (system, summary, ...) +// are ignored, but a transcript with no recognizable Claude message entry fails +// closed as "unreadable" so the hook never fires on a foreign or empty format. function parseClaudeRows(text) { const records = parseJsonlRecords(text); if (records === "unreadable") { @@ -39,12 +40,25 @@ function classifyClaudeRow(record) { if (record.type === "assistant") { return hasToolUseBlock(message.content) ? "work" : "ignore"; } + if (isSyntheticUserEntry(record)) { + return "ignore"; + } return isRealUserMessage(message) ? "user" : "ignore"; } -// A real user turn carries human text: either a plain string or content blocks -// that include a non-empty `text` block. Tool-result turns reuse the `user` -// type but carry `tool_result` blocks instead of prompt text, so they are -// excluded to keep the AFK timer anchored to genuine user activity. +// Claude Code logs tool results and system-injected notes with the same +// `type: "user"` shape as real prompts, told apart only by record-level markers +// rather than message content (https://github.com/anthropics/claude-code/issues/26508): +// `toolUseResult`/`sourceToolAssistantUUID` mark a tool result, and `isMeta` +// marks an injected note. Excluding these keeps the AFK timer anchored to +// genuine prompts so the hook still fires after the user has actually left. +function isSyntheticUserEntry(record) { + return (record.isMeta === true || + record.toolUseResult !== undefined || + typeof record.sourceToolAssistantUUID === "string"); +} +// Content-level check for a genuine prompt, applied once record-level synthetic +// markers are ruled out: accept a plain string or content blocks with a +// non-empty `text` block, and still reject any residual `tool_result` blocks. function isRealUserMessage(message) { const content = message.content; if (isNonEmptyString(content)) { diff --git a/hooks/claude/fixtures/transcript.jsonl b/hooks/claude/fixtures/transcript.jsonl index d6c199e..42058c3 100644 --- a/hooks/claude/fixtures/transcript.jsonl +++ b/hooks/claude/fixtures/transcript.jsonl @@ -1,4 +1,4 @@ {"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/mainframe-plugins","sessionId":"session-123","type":"user","message":{"role":"user","content":"Please implement this. SECRET_NEVER_LEAK"},"uuid":"11111111-1111-4111-8111-111111111111","timestamp":"2026-05-08T13:00:00.000Z"} {"parentUuid":"11111111-1111-4111-8111-111111111111","isSidechain":false,"userType":"external","cwd":"/workspace/mainframe-plugins","sessionId":"session-123","type":"assistant","message":{"role":"assistant","model":"claude-opus-4-20250514","content":[{"type":"text","text":"On it."},{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"command":"bun run build"}}]},"uuid":"22222222-2222-4222-8222-222222222222","timestamp":"2026-05-08T13:05:00.000Z"} -{"parentUuid":"22222222-2222-4222-8222-222222222222","isSidechain":false,"userType":"external","cwd":"/workspace/mainframe-plugins","sessionId":"session-123","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"build succeeded"}]},"uuid":"33333333-3333-4333-8333-333333333333","timestamp":"2026-05-08T13:06:00.000Z"} +{"parentUuid":"22222222-2222-4222-8222-222222222222","isSidechain":false,"userType":"external","cwd":"/workspace/mainframe-plugins","sessionId":"session-123","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"build succeeded"}]},"toolUseResult":{"stdout":"build succeeded","stderr":"","interrupted":false,"isImage":false},"sourceToolAssistantUUID":"22222222-2222-4222-8222-222222222222","uuid":"33333333-3333-4333-8333-333333333333","timestamp":"2026-05-08T13:06:00.000Z"} {"parentUuid":"33333333-3333-4333-8333-333333333333","isSidechain":false,"userType":"external","cwd":"/workspace/mainframe-plugins","sessionId":"session-123","type":"assistant","message":{"role":"assistant","model":"claude-opus-4-20250514","content":[{"type":"text","text":"Build finished."}]},"uuid":"44444444-4444-4444-8444-444444444444","timestamp":"2026-05-08T14:10:00.000Z"} diff --git a/hooks/claude/transcript.test.ts b/hooks/claude/transcript.test.ts index 80a607b..80920d3 100644 --- a/hooks/claude/transcript.test.ts +++ b/hooks/claude/transcript.test.ts @@ -73,6 +73,83 @@ describe("summarizeClaudeTranscript", () => { }); }); + it("does not let a tool-result entry with string content reset the AFK timer", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + assistantToolUse("2026-05-08T13:05:00.000Z"), + { + type: "user", + timestamp: "2026-05-08T13:06:00.000Z", + sessionId: "session-1", + uuid: "tool-result-1", + parentUuid: "assistant-1", + sourceToolAssistantUUID: "assistant-1", + toolUseResult: { stdout: "build succeeded", stderr: "", interrupted: false }, + message: { role: "user", content: "build succeeded" }, + }, + ]), + ); + + expect(summary).toEqual({ + kind: "ready", + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.000Z"), + workHappened: true, + alreadyShared: false, + }); + }); + + it("does not treat an entry carrying only sourceToolAssistantUUID as a user turn", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + assistantToolUse("2026-05-08T13:05:00.000Z"), + { + type: "user", + timestamp: "2026-05-08T13:06:00.000Z", + sessionId: "session-1", + uuid: "tool-result-2", + parentUuid: "assistant-1", + sourceToolAssistantUUID: "assistant-1", + message: { role: "user", content: "some tool output text" }, + }, + ]), + ); + + expect(summary).toMatchObject({ + kind: "ready", + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.000Z"), + workHappened: true, + }); + }); + + it("does not let an isMeta system-injected note reset the AFK timer", () => { + const summary = summarizeClaudeTranscript( + claudeTranscript([ + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + assistantToolUse("2026-05-08T13:05:00.000Z"), + { + type: "user", + timestamp: "2026-05-08T14:00:00.000Z", + sessionId: "session-1", + uuid: "meta-1", + parentUuid: "assistant-1", + isMeta: true, + message: { + role: "user", + content: "You are running low on context.", + }, + }, + ]), + ); + + expect(summary).toMatchObject({ + kind: "ready", + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.000Z"), + workHappened: true, + }); + }); + it("counts work done through MCP tool_use blocks", () => { const summary = summarizeClaudeTranscript( claudeTranscript([ diff --git a/hooks/claude/transcript.ts b/hooks/claude/transcript.ts index 98505f6..bb6bb34 100644 --- a/hooks/claude/transcript.ts +++ b/hooks/claude/transcript.ts @@ -19,11 +19,12 @@ export function summarizeClaudeTranscript(text: string): TranscriptSummary { // Claude Code transcripts are append-only JSONL where each line is a typed // entry. A real user turn is `{ type: "user", message: { content } }` carrying -// human text; tool results reuse the same `user` type but carry `tool_result` -// blocks, so they must not reset the AFK timer. Assistant `tool_use` blocks -// count as work. Unrelated entry types (system, summary, ...) are ignored, but -// a transcript with no recognizable Claude message entry fails closed as -// "unreadable" so the hook never fires on a foreign or empty format. +// human text. Tool results and system-injected notes reuse the same +// `type: "user"` shape and are distinguished only by record-level markers (see +// `isSyntheticUserEntry`), so they must not reset the AFK timer. Assistant +// `tool_use` blocks count as work. Unrelated entry types (system, summary, ...) +// are ignored, but a transcript with no recognizable Claude message entry fails +// closed as "unreadable" so the hook never fires on a foreign or empty format. function parseClaudeRows(text: string): ParsedTranscript | "unreadable" { const records = parseJsonlRecords(text); if (records === "unreadable") { @@ -57,13 +58,30 @@ function classifyClaudeRow(record: JsonRecord): ClassifiedRowKind { return hasToolUseBlock(message.content) ? "work" : "ignore"; } + if (isSyntheticUserEntry(record)) { + return "ignore"; + } + return isRealUserMessage(message) ? "user" : "ignore"; } -// A real user turn carries human text: either a plain string or content blocks -// that include a non-empty `text` block. Tool-result turns reuse the `user` -// type but carry `tool_result` blocks instead of prompt text, so they are -// excluded to keep the AFK timer anchored to genuine user activity. +// Claude Code logs tool results and system-injected notes with the same +// `type: "user"` shape as real prompts, told apart only by record-level markers +// rather than message content (https://github.com/anthropics/claude-code/issues/26508): +// `toolUseResult`/`sourceToolAssistantUUID` mark a tool result, and `isMeta` +// marks an injected note. Excluding these keeps the AFK timer anchored to +// genuine prompts so the hook still fires after the user has actually left. +function isSyntheticUserEntry(record: JsonRecord): boolean { + return ( + record.isMeta === true || + record.toolUseResult !== undefined || + typeof record.sourceToolAssistantUUID === "string" + ); +} + +// Content-level check for a genuine prompt, applied once record-level synthetic +// markers are ruled out: accept a plain string or content blocks with a +// non-empty `text` block, and still reject any residual `tool_result` blocks. function isRealUserMessage(message: JsonRecord): boolean { const content = message.content; if (isNonEmptyString(content)) { From e57239c6f02cf382fb16eb2b6b890de0479bf040 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 16:06:54 +0000 Subject: [PATCH 6/7] Simplify Claude user classification to one authoritative synthetic gate 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. --- dist/hooks/claude/transcript.js | 35 +++++++++++++------------------- hooks/claude/transcript.test.ts | 2 ++ hooks/claude/transcript.ts | 36 ++++++++++++--------------------- 3 files changed, 29 insertions(+), 44 deletions(-) diff --git a/dist/hooks/claude/transcript.js b/dist/hooks/claude/transcript.js index c9a634b..9a80d79 100644 --- a/dist/hooks/claude/transcript.js +++ b/dist/hooks/claude/transcript.js @@ -40,37 +40,33 @@ function classifyClaudeRow(record) { if (record.type === "assistant") { return hasToolUseBlock(message.content) ? "work" : "ignore"; } + // A genuine user turn is a non-synthetic entry that carries human text. The + // marker check is the authoritative gate; the text check then separates a real + // prompt from a contentless or image-only entry. if (isSyntheticUserEntry(record)) { return "ignore"; } - return isRealUserMessage(message) ? "user" : "ignore"; + return messageHasText(message.content) ? "user" : "ignore"; } // Claude Code logs tool results and system-injected notes with the same // `type: "user"` shape as real prompts, told apart only by record-level markers // rather than message content (https://github.com/anthropics/claude-code/issues/26508): // `toolUseResult`/`sourceToolAssistantUUID` mark a tool result, and `isMeta` -// marks an injected note. Excluding these keeps the AFK timer anchored to -// genuine prompts so the hook still fires after the user has actually left. +// marks an injected note. A tool result can even carry plain-string content, so +// these markers — not the content shape — are the authoritative signal that +// keeps the AFK timer anchored to genuine prompts. function isSyntheticUserEntry(record) { - return (record.isMeta === true || - record.toolUseResult !== undefined || - typeof record.sourceToolAssistantUUID === "string"); + return (record.toolUseResult !== undefined || + typeof record.sourceToolAssistantUUID === "string" || + record.isMeta === true); } -// Content-level check for a genuine prompt, applied once record-level synthetic -// markers are ruled out: accept a plain string or content blocks with a -// non-empty `text` block, and still reject any residual `tool_result` blocks. -function isRealUserMessage(message) { - const content = message.content; +// Whether a user message carries human prompt text: a plain string, or content +// blocks that include a non-empty `text` block. +function messageHasText(content) { if (isNonEmptyString(content)) { return true; } - if (!Array.isArray(content)) { - return false; - } - if (content.some(isToolResultBlock)) { - return false; - } - return content.some(isNonEmptyTextBlock); + return Array.isArray(content) && content.some(isNonEmptyTextBlock); } function hasToolUseBlock(content) { return Array.isArray(content) && content.some(isToolUseBlock); @@ -78,9 +74,6 @@ function hasToolUseBlock(content) { function isToolUseBlock(block) { return isJsonRecord(block) && block.type === "tool_use"; } -function isToolResultBlock(block) { - return isJsonRecord(block) && block.type === "tool_result"; -} function isNonEmptyTextBlock(block) { return isJsonRecord(block) && block.type === "text" && isNonEmptyString(block.text); } diff --git a/hooks/claude/transcript.test.ts b/hooks/claude/transcript.test.ts index 80920d3..2582aef 100644 --- a/hooks/claude/transcript.test.ts +++ b/hooks/claude/transcript.test.ts @@ -432,6 +432,8 @@ function toolResult(timestamp: string, content: string): Record sessionId: "session-1", type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content }] }, + toolUseResult: { stdout: content, stderr: "", interrupted: false }, + sourceToolAssistantUUID: "assistant-1", uuid: `tool-result-${timestamp}`, timestamp, }; diff --git a/hooks/claude/transcript.ts b/hooks/claude/transcript.ts index bb6bb34..17b0fe6 100644 --- a/hooks/claude/transcript.ts +++ b/hooks/claude/transcript.ts @@ -58,45 +58,39 @@ function classifyClaudeRow(record: JsonRecord): ClassifiedRowKind { return hasToolUseBlock(message.content) ? "work" : "ignore"; } + // A genuine user turn is a non-synthetic entry that carries human text. The + // marker check is the authoritative gate; the text check then separates a real + // prompt from a contentless or image-only entry. if (isSyntheticUserEntry(record)) { return "ignore"; } - return isRealUserMessage(message) ? "user" : "ignore"; + return messageHasText(message.content) ? "user" : "ignore"; } // Claude Code logs tool results and system-injected notes with the same // `type: "user"` shape as real prompts, told apart only by record-level markers // rather than message content (https://github.com/anthropics/claude-code/issues/26508): // `toolUseResult`/`sourceToolAssistantUUID` mark a tool result, and `isMeta` -// marks an injected note. Excluding these keeps the AFK timer anchored to -// genuine prompts so the hook still fires after the user has actually left. +// marks an injected note. A tool result can even carry plain-string content, so +// these markers — not the content shape — are the authoritative signal that +// keeps the AFK timer anchored to genuine prompts. function isSyntheticUserEntry(record: JsonRecord): boolean { return ( - record.isMeta === true || record.toolUseResult !== undefined || - typeof record.sourceToolAssistantUUID === "string" + typeof record.sourceToolAssistantUUID === "string" || + record.isMeta === true ); } -// Content-level check for a genuine prompt, applied once record-level synthetic -// markers are ruled out: accept a plain string or content blocks with a -// non-empty `text` block, and still reject any residual `tool_result` blocks. -function isRealUserMessage(message: JsonRecord): boolean { - const content = message.content; +// Whether a user message carries human prompt text: a plain string, or content +// blocks that include a non-empty `text` block. +function messageHasText(content: unknown): boolean { if (isNonEmptyString(content)) { return true; } - if (!Array.isArray(content)) { - return false; - } - - if (content.some(isToolResultBlock)) { - return false; - } - - return content.some(isNonEmptyTextBlock); + return Array.isArray(content) && content.some(isNonEmptyTextBlock); } function hasToolUseBlock(content: unknown): boolean { @@ -107,10 +101,6 @@ function isToolUseBlock(block: unknown): boolean { return isJsonRecord(block) && block.type === "tool_use"; } -function isToolResultBlock(block: unknown): boolean { - return isJsonRecord(block) && block.type === "tool_result"; -} - function isNonEmptyTextBlock(block: unknown): boolean { return isJsonRecord(block) && block.type === "text" && isNonEmptyString(block.text); } From 324445324a4bcd3f7ebf12eb3b5b4815b2d06808 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 16:33:43 +0000 Subject: [PATCH 7/7] Keep README Claude Code copy user-focused Match the Cursor and Codex sections: describe the share-video skill and Mainframe tools, not the internal stop-hook mechanism. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3297175..c99e9ac 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ claude /plugin install mainframe@mainframe ``` -The Claude Code plugin gives Claude the same `share-video` skill, Mainframe tools, and stop hook as -the Cursor and Codex plugins. +The Claude Code plugin gives Claude the same `share-video` skill and Mainframe tools as the Cursor +and Codex plugins. ## Included skill