Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
16 changes: 16 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
23 changes: 13 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions dist/hooks/claude/stop-evaluator.js
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 4 additions & 0 deletions dist/hooks/claude/stop.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env node
import { runStopHookCli } from "../core/run-stop-hook.js";
import { evaluateClaudeStopHook } from "./stop-evaluator.js";
runStopHookCli(evaluateClaudeStopHook);
79 changes: 79 additions & 0 deletions dist/hooks/claude/transcript.js
Original file line number Diff line number Diff line change
@@ -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);
}
27 changes: 4 additions & 23 deletions dist/hooks/codex/stop-evaluator.js
Original file line number Diff line number Diff line change
@@ -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);
}
46 changes: 7 additions & 39 deletions dist/hooks/codex/transcript.js
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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" &&
Expand All @@ -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`,
Expand Down
25 changes: 25 additions & 0 deletions dist/hooks/core/stop-hook.js
Original file line number Diff line number Diff line change
@@ -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 };
}
37 changes: 37 additions & 0 deletions dist/hooks/core/transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions dist/hooks/cursor/transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
8 changes: 8 additions & 0 deletions hooks/claude/fixtures/stop.json
Original file line number Diff line number Diff line change
@@ -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"
}
4 changes: 4 additions & 0 deletions hooks/claude/fixtures/transcript.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
Loading