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..c99e9ac 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 and Mainframe tools 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..393d88d --- /dev/null +++ b/dist/hooks/claude/stop-evaluator.js @@ -0,0 +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/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..9a80d79 --- /dev/null +++ b/dist/hooks/claude/transcript.js @@ -0,0 +1,79 @@ +import { isJsonRecord, parseJsonlRecords } from "../core/json.js"; +import { accumulateClassifiedRows, isNonEmptyString, 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 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") { + return "unreadable"; + } + if (!records.some(isClaudeMessageEntry)) { + return "unreadable"; + } + 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 "ignore"; + } + const message = record.message; + if (!isJsonRecord(message)) { + return "ignore"; + } + 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 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. 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.toolUseResult !== undefined || + typeof record.sourceToolAssistantUUID === "string" || + record.isMeta === true); +} +// 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; + } + return Array.isArray(content) && content.some(isNonEmptyTextBlock); +} +function hasToolUseBlock(content) { + return Array.isArray(content) && content.some(isToolUseBlock); +} +function isToolUseBlock(block) { + return isJsonRecord(block) && block.type === "tool_use"; +} +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..3a7693b 100644 --- a/dist/hooks/codex/stop-evaluator.js +++ b/dist/hooks/codex/stop-evaluator.js @@ -1,26 +1,7 @@ -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"; +// 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) { - 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/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/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/dist/hooks/core/transcript.js b/dist/hooks/core/transcript.js index 305a4f8..737da4a 100644 --- a/dist/hooks/core/transcript.js +++ b/dist/hooks/core/transcript.js @@ -32,6 +32,43 @@ export function summarizeTranscript(text, parseRows) { alreadyShared: parsed.alreadyShared, }; } +// 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. 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; + 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/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/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..42058c3 --- /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"}]},"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/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..3aed0dc --- /dev/null +++ b/hooks/claude/stop-evaluator.ts @@ -0,0 +1,12 @@ +import { + type BlockReasonStopHookOutput, + evaluateBlockReasonStopHook, + type StopHookEvaluationInput, +} 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: StopHookEvaluationInput): BlockReasonStopHookOutput { + 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..2582aef --- /dev/null +++ b/hooks/claude/transcript.test.ts @@ -0,0 +1,440 @@ +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("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([ + 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 }] }, + 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 new file mode 100644 index 0000000..17b0fe6 --- /dev/null +++ b/hooks/claude/transcript.ts @@ -0,0 +1,106 @@ +import { isJsonRecord, type JsonRecord, parseJsonlRecords } from "../core/json.js"; +import { + accumulateClassifiedRows, + type ClassifiedRowKind, + isNonEmptyString, + type ParsedTranscript, + summarizeTranscript, + summarizeTranscriptFile, + type TranscriptSummary, +} from "../core/transcript.js"; + +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 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") { + return "unreadable"; + } + + if (!records.some(isClaudeMessageEntry)) { + return "unreadable"; + } + + 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): ClassifiedRowKind { + if (record.type !== "user" && record.type !== "assistant") { + return "ignore"; + } + + const message = record.message; + if (!isJsonRecord(message)) { + return "ignore"; + } + + 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 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. 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.toolUseResult !== undefined || + typeof record.sourceToolAssistantUUID === "string" || + record.isMeta === true + ); +} + +// 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; + } + + return Array.isArray(content) && 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 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..a9a00e2 100644 --- a/hooks/codex/stop-evaluator.ts +++ b/hooks/codex/stop-evaluator.ts @@ -1,44 +1,12 @@ -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; -}; - -// 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 }; - -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 }; +// 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: StopHookEvaluationInput): BlockReasonStopHookOutput { + return evaluateBlockReasonStopHook(input, summarizeCodexTranscriptFile); } 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/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/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..74e6987 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,56 @@ 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 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. 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, +): 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); 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") { 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..2e440b7 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,11 @@ 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}`); 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}`); } 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);