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
20 changes: 20 additions & 0 deletions .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "mainframe",
"interface": {
"displayName": "Mainframe"
},
"plugins": [
{
"name": "mainframe",
"source": {
"source": "url",
"url": "https://github.com/mainframecomputer/mainframe-plugins.git"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
24 changes: 24 additions & 0 deletions .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"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/codex/hooks.json",
"interface": {
"displayName": "Mainframe",
"shortDescription": "Create and share short video updates from agent work.",
"longDescription": "Watch what your agents did instead of reading through all of it. Each task wraps up with a short narrated video in your own voice and company branding, so you stay up to date at a glance and can share it with your team.",
"developerName": "Mainframe",
"category": "Productivity",
"logo": "./assets/logo.png"
}
}
4 changes: 2 additions & 2 deletions .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
"email": "support@mainframe.app"
},
"metadata": {
"description": "Create and share short Mainframe video updates from coding-agent work."
"description": "Create and share short video updates from agent work."
},
"plugins": [
{
"name": "mainframe",
"source": ".",
"description": "Create and share short Mainframe video updates from coding-agent work."
"description": "Create and share short video updates from agent work."
}
]
}
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "mainframe",
"version": "0.1.0",
"description": "Create and share short Mainframe video updates from coding-agent work.",
"description": "Create and share short video updates from agent work.",
"author": {
"name": "Mainframe",
"email": "support@mainframe.app"
Expand Down
18 changes: 10 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
# Agent notes

This repository packages the Mainframe Cursor plugin. Keep it focused on the Cursor
manifest, hosted MCP wiring, the `share-video` skill, and the stop hook.
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.

## Repository boundaries

- User-visible copy should say "Mainframe", not legacy product names.
- Do not add secrets, customer data, private URLs, or private business context.
- Do not add non-Cursor host surfaces unless the product task explicitly asks for them.
- 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.
- Run `bun run verify` before considering changes ready.
- Generated Cursor manifest and marketplace files come from `tooling/generate.ts`; edit
the config there, then run `bun run generate`.
- Generated Cursor and Codex 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/`. 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/`, 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.

## Start here

Expand Down
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Mainframe skills
# Mainframe plugins

Mainframe is the video sharing platform for agents. This repository ships the `share-video` skill
and a Cursor plugin.
and Mainframe plugins for Cursor and Codex.

## Install

Expand All @@ -17,8 +17,21 @@ To preview the available skills without installing:
npx skills add mainframecomputer/mainframe-plugins --list
```

If you use Cursor, you can install the Mainframe plugin from the Cursor marketplace instead. The
plugin includes the `share-video` skill and Mainframe tools.
### Cursor

Install the Mainframe plugin from the Cursor marketplace. It gives your agent the `share-video`
skill and Mainframe tools so it can create and share short video updates of its work.

### Codex

Add this repository as a Codex plugin marketplace, then install Mainframe from the `/plugins`
browser in the Codex CLI:

```sh
codex plugin marketplace add mainframecomputer/mainframe-plugins
```

The Codex plugin gives Codex the same `share-video` skill and Mainframe tools as the Cursor plugin.

## Included skill

Expand Down
26 changes: 26 additions & 0 deletions dist/hooks/codex/stop-evaluator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { decideStop } from "../core/stop-policy.js";
import { parseJsonRecord } from "../core/json.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 };
}
4 changes: 4 additions & 0 deletions dist/hooks/codex/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 { evaluateCodexStopHook } from "./stop-evaluator.js";
runStopHookCli(evaluateCodexStopHook);
81 changes: 81 additions & 0 deletions dist/hooks/codex/transcript.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { hasMainframeVideoUrl, isNonEmptyString, nextUserTimeMs, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js";
import { isJsonRecord, parseJsonlRecords } from "../core/json.js";
export function summarizeCodexTranscriptFile(path) {
return summarizeTranscriptFile(path, parseCodexRows);
}
export function summarizeCodexTranscript(text) {
return summarizeTranscript(text, parseCodexRows);
}
// Codex rollout files are append-only JSONL where every line is
// `{ timestamp, type, payload }`. The format carries many event types that are
// irrelevant here, so unrecognized-but-valid rows are ignored. Anything that
// makes the transcript untrustworthy fails closed (returns "unreadable" so the
// hook never fires): non-JSON or non-object rows, a missing `session_meta` row,
// or user timestamps that move backwards.
function parseCodexRows(text) {
const records = parseJsonlRecords(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) {
return "unreadable";
}
return { sawUser, lastUserTimeMs, workHappened, alreadyShared };
}
function classifyCodexRow(record) {
if (record.type === "session_meta") {
return "session-meta";
}
const payload = record.payload;
if (!isJsonRecord(payload)) {
return "other";
}
if (record.type === "event_msg" &&
payload.type === "user_message" &&
isNonEmptyString(payload.message)) {
return "user";
}
if (record.type === "response_item" && isToolCallType(payload.type)) {
return "work";
}
return "other";
}
// Responses API tool invocations are persisted as `response_item` rows whose
// payload type ends in `_call` (`function_call`, `local_shell_call`,
// `custom_tool_call`, `web_search_call`, ...). Match the whole family so work
// done through any tool counts, not just plain function calls. Result rows end
// in `_output`, so they are excluded.
function isToolCallType(type) {
return typeof type === "string" && type.endsWith("_call");
}
24 changes: 24 additions & 0 deletions dist/hooks/core/json.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,27 @@ export function parseJsonRecord(text) {
return null;
}
}
// Parse JSONL into records, failing closed: any non-JSON or non-object line
// yields "unreadable". This is the single chokepoint for turning an untrusted
// transcript into trusted rows; blank lines are skipped.
export function parseJsonlRecords(text) {
const records = [];
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed === "") {
continue;
}
let parsed;
try {
parsed = JSON.parse(trimmed);
}
catch {
return "unreadable";
}
if (!isJsonRecord(parsed)) {
return "unreadable";
}
records.push(parsed);
}
return records;
}
8 changes: 8 additions & 0 deletions dist/hooks/core/run-stop-hook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { readFileSync } from "node:fs";
// Shared stop-hook CLI plumbing: read the host payload from stdin, run a pure
// evaluator, and emit its JSON result. Keeps host evaluators free of I/O.
export function runStopHookCli(evaluate) {
const stdin = readFileSync(0, "utf8");
const output = evaluate({ stdin });
process.stdout.write(`${JSON.stringify(output)}\n`);
}
6 changes: 2 additions & 4 deletions dist/hooks/core/stop-policy.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { evaluateAfkGate } from "./afk-gate.js";
import { summarizeTranscriptFile } from "./transcript.js";
export function evaluateStopPolicy(input) {
const summary = summarizeTranscriptFile(input.transcriptPath);
export function decideStop(summary, stopTimeMs) {
if (summary.kind !== "ready") {
return { kind: "skip" };
}
const gate = evaluateAfkGate({
stopTimeMs: input.stopTimeMs,
stopTimeMs,
lastUserTimeMs: summary.lastUserTimeMs,
workHappened: summary.workHappened,
alreadyShared: summary.alreadyShared,
Expand Down
Loading