diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..4ba572f --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -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" + } + ] +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..040b345 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -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" + } +} diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 370a4c8..24a7656 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -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." } ] } diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 96bd6e5..2e85ad6 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -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" diff --git a/AGENTS.md b/AGENTS.md index 4d56ad1..4bb4bda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 21f36a2..8ec140f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/dist/hooks/codex/stop-evaluator.js b/dist/hooks/codex/stop-evaluator.js new file mode 100644 index 0000000..2776b7e --- /dev/null +++ b/dist/hooks/codex/stop-evaluator.js @@ -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 }; +} diff --git a/dist/hooks/codex/stop.js b/dist/hooks/codex/stop.js new file mode 100755 index 0000000..4ac336f --- /dev/null +++ b/dist/hooks/codex/stop.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runStopHookCli } from "../core/run-stop-hook.js"; +import { evaluateCodexStopHook } from "./stop-evaluator.js"; +runStopHookCli(evaluateCodexStopHook); diff --git a/dist/hooks/codex/transcript.js b/dist/hooks/codex/transcript.js new file mode 100644 index 0000000..ff9efc6 --- /dev/null +++ b/dist/hooks/codex/transcript.js @@ -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"); +} diff --git a/dist/hooks/core/json.js b/dist/hooks/core/json.js index 97d0ea7..c28ebfb 100644 --- a/dist/hooks/core/json.js +++ b/dist/hooks/core/json.js @@ -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; +} diff --git a/dist/hooks/core/run-stop-hook.js b/dist/hooks/core/run-stop-hook.js new file mode 100644 index 0000000..a168190 --- /dev/null +++ b/dist/hooks/core/run-stop-hook.js @@ -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`); +} diff --git a/dist/hooks/core/stop-policy.js b/dist/hooks/core/stop-policy.js index a3837ac..65a98d1 100644 --- a/dist/hooks/core/stop-policy.js +++ b/dist/hooks/core/stop-policy.js @@ -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, diff --git a/dist/hooks/core/transcript.js b/dist/hooks/core/transcript.js index 2069721..305a4f8 100644 --- a/dist/hooks/core/transcript.js +++ b/dist/hooks/core/transcript.js @@ -5,39 +5,61 @@ const MIN_EPOCH_SECONDS = 946_684_800; const MAX_EPOCH_SECONDS = 4_102_444_800; const MIN_EPOCH_MS = MIN_EPOCH_SECONDS * 1000; const MAX_EPOCH_MS = MAX_EPOCH_SECONDS * 1000; -const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/; +const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; const MAINFRAME_VIDEO_URL_PREFIX = "https://mainframe.app/v/"; -export function summarizeTranscriptFile(path) { - try { - const stat = lstatSync(path); - if (!stat.isFile() || stat.size > MAX_TRANSCRIPT_BYTES) { - return { kind: "unreadable" }; - } - return summarizeTranscript(readFileSync(path, "utf8")); - } - catch { +export function summarizeTranscriptFile(path, parseRows) { + const text = readTranscriptText(path); + if (text === null) { return { kind: "unreadable" }; } + return summarizeTranscript(text, parseRows); } -export function summarizeTranscript(text) { - const summary = summarizeCursorRows(text); - if (summary.kind === "unreadable") { - return summary; +export function summarizeTranscript(text, parseRows) { + const parsed = parseRows(text); + if (parsed === "unreadable") { + return { kind: "unreadable" }; } - if (!summary.sawUser) { + if (!parsed.sawUser) { return { kind: "no-user" }; } - if (summary.lastUserTimeMs === null) { + if (parsed.lastUserTimeMs === null) { return { kind: "missing-user-time" }; } return { kind: "ready", - lastUserTimeMs: summary.lastUserTimeMs, - workHappened: summary.workHappened, - alreadyShared: summary.alreadyShared, + lastUserTimeMs: parsed.lastUserTimeMs, + workHappened: parsed.workHappened, + alreadyShared: parsed.alreadyShared, }; } -function parseTimestampMs(value) { +function readTranscriptText(path) { + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.size > MAX_TRANSCRIPT_BYTES) { + return null; + } + return readFileSync(path, "utf8"); + } + catch { + return null; + } +} +export function isNonEmptyString(value) { + return typeof value === "string" && value.trim() !== ""; +} +// Advance the user-message time cursor while enforcing non-decreasing order. +// Returns the parsed time (which may be null when absent/ambiguous), or +// "unreadable" when a user timestamp moves backwards: transcripts are +// append-only, so a regression means the input can't be trusted and the hook +// must fail closed. Shared so every host applies the same ordering rule. +export function nextUserTimeMs(rawTimestamp, previousUserTimeMs) { + const userTimeMs = parseTimestampMs(rawTimestamp); + if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { + return "unreadable"; + } + return userTimeMs; +} +export function parseTimestampMs(value) { if (typeof value === "number" && Number.isFinite(value)) { return normalizeEpochMs(value); } @@ -59,85 +81,17 @@ function parseTimestampMs(value) { } return null; } -function summarizeCursorRows(text) { - let sawUser = false; - let lastUserTimeMs = null; - let workHappened = false; - let alreadyShared = false; - let previousUserTimeMs = null; - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed === "") { - continue; - } - try { - const parsed = JSON.parse(trimmed); - if (!isJsonRecord(parsed)) { - return { kind: "unreadable" }; - } - const row = parseCursorTranscriptRow(parsed); - if (row === null) { - return { kind: "unreadable" }; - } - if (row.event === "user_message") { - const userTimeMs = parseTimestampMs(row.timestamp); - if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { - return { kind: "unreadable" }; - } - sawUser = true; - lastUserTimeMs = userTimeMs; - if (userTimeMs !== null) { - previousUserTimeMs = userTimeMs; - } - workHappened = false; - alreadyShared = false; - continue; - } - if (sawUser) { - const workTimeMs = readToolWorkTimeMs(row, lastUserTimeMs); - if (workTimeMs === "unreadable") { - return { kind: "unreadable" }; - } - workHappened = workHappened || workTimeMs !== null; - alreadyShared = alreadyShared || hasMainframeVideoUrl(parsed); - } - } - catch { - return { kind: "unreadable" }; - } - } - return { kind: "parsed", sawUser, lastUserTimeMs, workHappened, alreadyShared }; -} -function parseCursorTranscriptRow(record) { - if (record.event === "user_message" && isNonEmptyString(record.text)) { - return { event: record.event, timestamp: record.timestamp }; - } - if (record.event === "tool_call" && typeof record.name === "string") { - return { event: record.event, timestamp: record.timestamp }; - } - if (record.event === "assistant_message") { - return { event: record.event }; - } - if (record.event === "tool_result") { - return { event: record.event }; - } - return null; -} -function isNonEmptyString(value) { - return typeof value === "string" && value.trim() !== ""; -} -function readToolWorkTimeMs(row, lastUserTimeMs) { - if (row.event !== "tool_call") { - return null; +export function hasMainframeVideoUrl(value) { + if (typeof value === "string") { + return value.includes(MAINFRAME_VIDEO_URL_PREFIX); } - const toolTimeMs = parseTimestampMs(row.timestamp); - if (toolTimeMs === null) { - return "unreadable"; + if (Array.isArray(value)) { + return value.some((entry) => hasMainframeVideoUrl(entry)); } - if (lastUserTimeMs !== null && toolTimeMs < lastUserTimeMs) { - return "unreadable"; + if (isJsonRecord(value)) { + return Object.values(value).some((entry) => hasMainframeVideoUrl(entry)); } - return toolTimeMs; + return false; } function normalizeEpochMs(value) { if (value >= MIN_EPOCH_SECONDS && value <= MAX_EPOCH_SECONDS) { @@ -148,15 +102,3 @@ function normalizeEpochMs(value) { } return null; } -function hasMainframeVideoUrl(value) { - if (typeof value === "string") { - return value.includes(MAINFRAME_VIDEO_URL_PREFIX); - } - if (Array.isArray(value)) { - return value.some((entry) => hasMainframeVideoUrl(entry)); - } - if (isJsonRecord(value)) { - return Object.values(value).some((entry) => hasMainframeVideoUrl(entry)); - } - return false; -} diff --git a/dist/hooks/cursor/stop-evaluator.js b/dist/hooks/cursor/stop-evaluator.js index bd726cf..c7b0f19 100644 --- a/dist/hooks/cursor/stop-evaluator.js +++ b/dist/hooks/cursor/stop-evaluator.js @@ -1,25 +1,18 @@ -import { readFileSync } from "node:fs"; -import { evaluateStopPolicy } from "../core/stop-policy.js"; +import { decideStop } from "../core/stop-policy.js"; import { parseJsonRecord } from "../core/json.js"; +import { summarizeCursorTranscriptFile } from "./transcript.js"; export function evaluateCursorStopHook(input) { const hookInput = parseCursorStopInput(input.stdin); if (hookInput === null || hookInput.loopCount > 0) { return {}; } - const decision = evaluateStopPolicy({ - transcriptPath: hookInput.transcriptPath, - stopTimeMs: input.nowMs ?? Date.now(), - }); + const summary = summarizeCursorTranscriptFile(hookInput.transcriptPath); + const decision = decideStop(summary, input.nowMs ?? Date.now()); if (decision.kind === "skip") { return {}; } return { followup_message: decision.message }; } -export function runCursorStopHookCli() { - const stdin = readFileSync(0, "utf8"); - const output = evaluateCursorStopHook({ stdin }); - process.stdout.write(`${JSON.stringify(output)}\n`); -} function parseCursorStopInput(stdin) { const input = parseJsonRecord(stdin); if (input === null || input.hook_event_name !== "stop" || input.status !== "completed") { diff --git a/dist/hooks/cursor/stop.js b/dist/hooks/cursor/stop.js index cdfb52e..35851c2 100755 --- a/dist/hooks/cursor/stop.js +++ b/dist/hooks/cursor/stop.js @@ -1,3 +1,4 @@ #!/usr/bin/env node -import { runCursorStopHookCli } from "./stop-evaluator.js"; -runCursorStopHookCli(); +import { runStopHookCli } from "../core/run-stop-hook.js"; +import { evaluateCursorStopHook } from "./stop-evaluator.js"; +runStopHookCli(evaluateCursorStopHook); diff --git a/dist/hooks/cursor/transcript.js b/dist/hooks/cursor/transcript.js new file mode 100644 index 0000000..2982327 --- /dev/null +++ b/dist/hooks/cursor/transcript.js @@ -0,0 +1,76 @@ +import { hasMainframeVideoUrl, isNonEmptyString, nextUserTimeMs, parseTimestampMs, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; +import { parseJsonlRecords } from "../core/json.js"; +export function summarizeCursorTranscriptFile(path) { + return summarizeTranscriptFile(path, parseCursorRows); +} +export function summarizeCursorTranscript(text) { + return summarizeTranscript(text, parseCursorRows); +} +function parseCursorRows(text) { + const records = parseJsonlRecords(text); + if (records === "unreadable") { + return "unreadable"; + } + let sawUser = false; + let lastUserTimeMs = null; + let workHappened = false; + let alreadyShared = false; + let previousUserTimeMs = null; + for (const record of records) { + const row = parseCursorTranscriptRow(record); + if (row === null) { + return "unreadable"; + } + if (row.event === "user_message") { + const userTimeMs = nextUserTimeMs(row.timestamp, previousUserTimeMs); + if (userTimeMs === "unreadable") { + return "unreadable"; + } + sawUser = true; + lastUserTimeMs = userTimeMs; + if (userTimeMs !== null) { + previousUserTimeMs = userTimeMs; + } + workHappened = false; + alreadyShared = false; + continue; + } + if (sawUser) { + const workTimeMs = readToolWorkTimeMs(row, lastUserTimeMs); + if (workTimeMs === "unreadable") { + return "unreadable"; + } + workHappened = workHappened || workTimeMs !== null; + alreadyShared = alreadyShared || hasMainframeVideoUrl(record); + } + } + return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; +} +function parseCursorTranscriptRow(record) { + if (record.event === "user_message" && isNonEmptyString(record.text)) { + return { event: record.event, timestamp: record.timestamp }; + } + if (record.event === "tool_call" && typeof record.name === "string") { + return { event: record.event, timestamp: record.timestamp }; + } + if (record.event === "assistant_message") { + return { event: record.event }; + } + if (record.event === "tool_result") { + return { event: record.event }; + } + return null; +} +function readToolWorkTimeMs(row, lastUserTimeMs) { + if (row.event !== "tool_call") { + return null; + } + const toolTimeMs = parseTimestampMs(row.timestamp); + if (toolTimeMs === null) { + return "unreadable"; + } + if (lastUserTimeMs !== null && toolTimeMs < lastUserTimeMs) { + return "unreadable"; + } + return toolTimeMs; +} diff --git a/hooks/codex/fixtures/stop.json b/hooks/codex/fixtures/stop.json new file mode 100644 index 0000000..f046e46 --- /dev/null +++ b/hooks/codex/fixtures/stop.json @@ -0,0 +1,11 @@ +{ + "session_id": "session-123", + "transcript_path": null, + "cwd": "/workspace/mainframe-plugins", + "hook_event_name": "Stop", + "model": "gpt-5.5-codex", + "permission_mode": "default", + "turn_id": "turn-456", + "stop_hook_active": false, + "last_assistant_message": "Build finished." +} diff --git a/hooks/codex/fixtures/transcript.jsonl b/hooks/codex/fixtures/transcript.jsonl new file mode 100644 index 0000000..22d9880 --- /dev/null +++ b/hooks/codex/fixtures/transcript.jsonl @@ -0,0 +1,4 @@ +{"timestamp":"2026-05-08T12:55:00.000Z","type":"session_meta","payload":{"id":"session-123","timestamp":"2026-05-08T12:55:00.000Z","cwd":"/workspace/mainframe-plugins","originator":"codex","cli_version":"0.130.0"}} +{"timestamp":"2026-05-08T13:00:00.000Z","type":"event_msg","payload":{"type":"user_message","message":"Please implement this. SECRET_NEVER_LEAK","kind":"plain"}} +{"timestamp":"2026-05-08T13:05:00.000Z","type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"{\"command\":\"bun run build\"}","call_id":"call_1"}} +{"timestamp":"2026-05-08T14:10:00.000Z","type":"event_msg","payload":{"type":"agent_message","message":"Build finished."}} diff --git a/hooks/codex/hooks.json b/hooks/codex/hooks.json new file mode 100644 index 0000000..dd1a0b6 --- /dev/null +++ b/hooks/codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$PLUGIN_ROOT/dist/hooks/codex/stop.js\"", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/hooks/codex/stop-evaluator.ts b/hooks/codex/stop-evaluator.ts new file mode 100644 index 0000000..527370b --- /dev/null +++ b/hooks/codex/stop-evaluator.ts @@ -0,0 +1,44 @@ +import { decideStop } from "../core/stop-policy.js"; +import { parseJsonRecord } from "../core/json.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 }; +} diff --git a/hooks/codex/stop.test.ts b/hooks/codex/stop.test.ts new file mode 100644 index 0000000..cda4267 --- /dev/null +++ b/hooks/codex/stop.test.ts @@ -0,0 +1,147 @@ +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 { evaluateCodexStopHook } 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("Codex stop hook", () => { + it("continues with a share-video prompt after the user is away", () => { + const output = evaluateCodexStopHook({ + 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( + evaluateCodexStopHook({ + 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"]) { + expect( + evaluateCodexStopHook({ + stdin: stopInput({ hook_event_name, transcript_path: transcriptPath }), + nowMs: stopTimeMs, + }), + ).toEqual({}); + } + }); + + it("does not fire after a continuation already looped", () => { + expect( + evaluateCodexStopHook({ + 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( + evaluateCodexStopHook({ + 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-codex-stop-test-")); + const path = join(directory, "rollout.jsonl"); + writeFileSync( + path, + [ + 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" }, + }), + JSON.stringify({ + timestamp: "2026-05-08T13:30:00.000Z", + type: "event_msg", + payload: { type: "agent_message", message: "Here is my answer." }, + }), + ].join("\n"), + ); + + expect( + evaluateCodexStopHook({ + 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-codex-stop-test-")); + const path = join(directory, "rollout.jsonl"); + writeFileSync( + path, + [ + 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" }, + }), + JSON.stringify({ + timestamp: "2026-05-08T13:05:00.000Z", + type: "response_item", + payload: { type: "function_call", name: "shell", arguments: "{}", call_id: "call_1" }, + }), + JSON.stringify({ + timestamp: "2026-05-08T13:30:00.000Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_1", + output: "Shared: https://mainframe.app/v/37507089004e8f3700deb918a48b2556", + }, + }), + ].join("\n"), + ); + + expect( + evaluateCodexStopHook({ + 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/codex/stop.ts b/hooks/codex/stop.ts new file mode 100644 index 0000000..488a1b9 --- /dev/null +++ b/hooks/codex/stop.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { runStopHookCli } from "../core/run-stop-hook.js"; +import { evaluateCodexStopHook } from "./stop-evaluator.js"; + +runStopHookCli(evaluateCodexStopHook); diff --git a/hooks/codex/transcript.test.ts b/hooks/codex/transcript.test.ts new file mode 100644 index 0000000..7c0461e --- /dev/null +++ b/hooks/codex/transcript.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from "vitest"; + +import { summarizeCodexTranscript, summarizeCodexTranscriptFile } from "./transcript.js"; + +const sessionMeta = { + timestamp: "2026-05-08T12:55:00.000Z", + type: "session_meta", + payload: { id: "session-1", cwd: "/workspace", originator: "codex", cli_version: "0.130.0" }, +}; + +describe("summarizeCodexTranscript", () => { + it("summarizes function-call work after the last user message", () => { + const summary = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + userMessage("2026-05-08T12:00:00.000Z", "earlier request"), + userMessage("2026-05-08T13:00:00.000Z", "final request"), + functionCall("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 = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + userMessage("2026-05-08T13:00:00.000Z", "first request"), + functionCall("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("counts work done through non-function Codex tool-call types", () => { + for (const callType of ["local_shell_call", "custom_tool_call", "web_search_call"]) { + const summary = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + { + timestamp: "2026-05-08T13:05:00.000Z", + type: "response_item", + payload: { type: callType, call_id: "call_1" }, + }, + ]), + ); + + expect(summary).toMatchObject({ kind: "ready", workHappened: true }); + } + }); + + it("treats out-of-order user timestamps as unreadable", () => { + expect( + summarizeCodexTranscript( + codexRollout([ + sessionMeta, + userMessage("2026-05-08T15:00:00.000Z", "latest request"), + userMessage("2026-05-08T13:00:00.000Z", "older request emitted later"), + functionCall("2026-05-08T13:05:00.000Z"), + ]), + ), + ).toEqual({ kind: "unreadable" }); + }); + + it("ignores unrelated rollout event types", () => { + const summary = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + { + timestamp: "2026-05-08T12:59:00.000Z", + type: "event_msg", + payload: { type: "task_started" }, + }, + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + { + timestamp: "2026-05-08T13:01:00.000Z", + type: "event_msg", + payload: { type: "token_count" }, + }, + { + timestamp: "2026-05-08T13:02:00.000Z", + type: "response_item", + payload: { type: "reasoning", summary: ["thinking"] }, + }, + functionCall("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("detects an existing Mainframe share from function-call output", () => { + const summary = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + functionCall("2026-05-08T13:05:00.000Z"), + { + timestamp: "2026-05-08T13:30:00.000Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_1", + output: "Shared: https://mainframe.app/v/37507089004e8f3700deb918a48b2556", + }, + }, + ]), + ); + + expect(summary).toMatchObject({ kind: "ready", workHappened: true, alreadyShared: true }); + }); + + it("detects a Mainframe share from an agent message", () => { + const summary = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + { + timestamp: "2026-05-08T13:30:00.000Z", + type: "event_msg", + payload: { + type: "agent_message", + message: "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 = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + userMessage( + "2026-05-08T13:00:00.000Z", + "please look at https://mainframe.app/v/37507089004e8f3700deb918a48b2556", + ), + functionCall("2026-05-08T13:05:00.000Z"), + ]), + ); + + expect(summary).toMatchObject({ kind: "ready", workHappened: true, alreadyShared: false }); + }); + + it("accepts RFC3339 timestamps with sub-millisecond precision", () => { + const summary = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + 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("uses a discriminated state when no real user message is present", () => { + const summary = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + { + timestamp: "2026-05-08T13:00:00.000Z", + type: "event_msg", + payload: { type: "agent_message", message: "done" }, + }, + ]), + ); + + expect(summary).toEqual({ kind: "no-user" }); + }); + + it("does not treat a synthetic response_item user row as a real user", () => { + const summary = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + { + timestamp: "2026-05-08T13:00:00.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "cwd" }, + ], + }, + }, + ]), + ); + + expect(summary).toEqual({ kind: "no-user" }); + }); + + it("keeps the real user turn when a synthetic user row is injected after work", () => { + const summary = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + userMessage("2026-05-08T13:00:00.000Z", "please work on this"), + functionCall("2026-05-08T13:05:00.000Z"), + { + timestamp: "2026-05-08T13:10:00.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "cwd" }, + ], + }, + }, + ]), + ); + + expect(summary).toEqual({ + kind: "ready", + lastUserTimeMs: Date.parse("2026-05-08T13:00:00.000Z"), + workHappened: true, + alreadyShared: false, + }); + }); + + it("reports missing user time when the latest user timestamp is malformed", () => { + const summary = summarizeCodexTranscript( + codexRollout([ + sessionMeta, + userMessage("2026-05-08T13:00:00.000Z", "first request"), + userMessage("not-a-timestamp", "latest request"), + ]), + ); + + expect(summary).toEqual({ kind: "missing-user-time" }); + }); + + it("fails closed when the latest user timestamp is outside the supported epoch range", () => { + for (const timestamp of ["1999-12-31T23:59:59.000Z", "2101-01-01T00:00:00.000Z"]) { + expect( + summarizeCodexTranscript( + codexRollout([sessionMeta, userMessage(timestamp, "please work on this")]), + ), + ).toEqual({ kind: "missing-user-time" }); + } + }); + + it("treats a transcript without a session_meta row as unreadable", () => { + expect( + summarizeCodexTranscript(codexRollout([userMessage("2026-05-08T13:00:00.000Z", "hi")])), + ).toEqual({ kind: "unreadable" }); + }); + + it("treats a Cursor-style transcript as unreadable", () => { + expect( + summarizeCodexTranscript( + JSON.stringify({ + timestamp: "2026-05-08T13:00:00.000Z", + event: "user_message", + text: "please work on this", + }), + ), + ).toEqual({ kind: "unreadable" }); + }); + + it("treats corrupt JSONL as unreadable", () => { + expect(summarizeCodexTranscript([JSON.stringify(sessionMeta), "{not-json"].join("\n"))).toEqual( + { kind: "unreadable" }, + ); + }); + + it("treats non-object rows as unreadable", () => { + expect(summarizeCodexTranscript(JSON.stringify(["not", "a", "codex", "row"]))).toEqual({ + kind: "unreadable", + }); + }); + + it("uses a discriminated state when the transcript file cannot be read", () => { + expect(summarizeCodexTranscriptFile("/tmp/mainframe-missing-rollout.jsonl")).toEqual({ + kind: "unreadable", + }); + }); +}); + +function codexRollout(rows: Array>): string { + return rows.map((row) => JSON.stringify(row)).join("\n"); +} + +function userMessage(timestamp: string, message: string): Record { + return { + timestamp, + type: "event_msg", + payload: { type: "user_message", message, kind: "plain" }, + }; +} + +function functionCall(timestamp: string): Record { + return { + timestamp, + type: "response_item", + payload: { type: "function_call", name: "shell", arguments: "{}", call_id: "call_1" }, + }; +} diff --git a/hooks/codex/transcript.ts b/hooks/codex/transcript.ts new file mode 100644 index 0000000..7ad78de --- /dev/null +++ b/hooks/codex/transcript.ts @@ -0,0 +1,109 @@ +import { + hasMainframeVideoUrl, + isNonEmptyString, + nextUserTimeMs, + type ParsedTranscript, + summarizeTranscript, + summarizeTranscriptFile, + type TranscriptSummary, +} 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); +} + +export function summarizeCodexTranscript(text: string): TranscriptSummary { + 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: string): ParsedTranscript | "unreadable" { + const records = parseJsonlRecords(text); + if (records === "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) { + return "unreadable"; + } + + return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; +} + +function classifyCodexRow(record: JsonRecord): CodexRowKind { + 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: unknown): boolean { + return typeof type === "string" && type.endsWith("_call"); +} diff --git a/hooks/core/json.ts b/hooks/core/json.ts index 4cbe4c5..c0729f4 100644 --- a/hooks/core/json.ts +++ b/hooks/core/json.ts @@ -12,3 +12,30 @@ export function parseJsonRecord(text: string): JsonRecord | null { 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: string): JsonRecord[] | "unreadable" { + const records: JsonRecord[] = []; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === "") { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return "unreadable"; + } + if (!isJsonRecord(parsed)) { + return "unreadable"; + } + + records.push(parsed); + } + + return records; +} diff --git a/hooks/core/run-stop-hook.ts b/hooks/core/run-stop-hook.ts new file mode 100644 index 0000000..d054eed --- /dev/null +++ b/hooks/core/run-stop-hook.ts @@ -0,0 +1,9 @@ +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: (input: { stdin: string }) => object): void { + const stdin = readFileSync(0, "utf8"); + const output = evaluate({ stdin }); + process.stdout.write(`${JSON.stringify(output)}\n`); +} diff --git a/hooks/core/stop-policy.ts b/hooks/core/stop-policy.ts index 9495fe4..4baa719 100644 --- a/hooks/core/stop-policy.ts +++ b/hooks/core/stop-policy.ts @@ -1,21 +1,15 @@ import { evaluateAfkGate } from "./afk-gate.js"; -import { summarizeTranscriptFile } from "./transcript.js"; +import type { TranscriptSummary } from "./transcript.js"; -export type StopPolicyInput = { - transcriptPath: string; - stopTimeMs: number; -}; +export type StopDecision = { kind: "skip" } | { kind: "suggest"; message: string }; -export type StopPolicyDecision = { kind: "skip" } | { kind: "suggest"; message: string }; - -export function evaluateStopPolicy(input: StopPolicyInput): StopPolicyDecision { - const summary = summarizeTranscriptFile(input.transcriptPath); +export function decideStop(summary: TranscriptSummary, stopTimeMs: number): StopDecision { if (summary.kind !== "ready") { return { kind: "skip" }; } const gate = evaluateAfkGate({ - stopTimeMs: input.stopTimeMs, + stopTimeMs, lastUserTimeMs: summary.lastUserTimeMs, workHappened: summary.workHappened, alreadyShared: summary.alreadyShared, diff --git a/hooks/core/transcript.ts b/hooks/core/transcript.ts index 4b30f1e..2951d91 100644 --- a/hooks/core/transcript.ts +++ b/hooks/core/transcript.ts @@ -1,6 +1,6 @@ import { lstatSync, readFileSync } from "node:fs"; -import { isJsonRecord, type JsonRecord } from "./json.js"; +import { isJsonRecord } from "./json.js"; const MAX_TRANSCRIPT_BYTES = 5 * 1024 * 1024; const MIN_EPOCH_SECONDS = 946_684_800; @@ -8,7 +8,7 @@ const MAX_EPOCH_SECONDS = 4_102_444_800; const MIN_EPOCH_MS = MIN_EPOCH_SECONDS * 1000; const MAX_EPOCH_MS = MAX_EPOCH_SECONDS * 1000; const ISO_TIMESTAMP_PATTERN = - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/; + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; const MAINFRAME_VIDEO_URL_PREFIX = "https://mainframe.app/v/"; export type TranscriptSummary = @@ -22,48 +22,90 @@ export type TranscriptSummary = alreadyShared: boolean; }; -type CursorTranscriptRow = - | { event: "assistant_message" } - | { event: "tool_call"; timestamp: unknown } - | { event: "tool_result" } - | { event: "user_message"; timestamp: unknown }; - -export function summarizeTranscriptFile(path: string): TranscriptSummary { - try { - const stat = lstatSync(path); - if (!stat.isFile() || stat.size > MAX_TRANSCRIPT_BYTES) { - return { kind: "unreadable" }; - } - - return summarizeTranscript(readFileSync(path, "utf8")); - } catch { +export type ParsedTranscript = { + sawUser: boolean; + lastUserTimeMs: number | null; + workHappened: boolean; + alreadyShared: boolean; +}; + +// A host adds support by supplying one of these: it turns transcript text into +// a `ParsedTranscript`, or fails closed with "unreadable". The shared file +// reading, size guarding, and summary shaping below are host-agnostic. +export type TranscriptRowParser = (text: string) => ParsedTranscript | "unreadable"; + +export function summarizeTranscriptFile( + path: string, + parseRows: TranscriptRowParser, +): TranscriptSummary { + const text = readTranscriptText(path); + if (text === null) { return { kind: "unreadable" }; } + + return summarizeTranscript(text, parseRows); } -export function summarizeTranscript(text: string): TranscriptSummary { - const summary = summarizeCursorRows(text); - if (summary.kind === "unreadable") { - return summary; +export function summarizeTranscript( + text: string, + parseRows: TranscriptRowParser, +): TranscriptSummary { + const parsed = parseRows(text); + if (parsed === "unreadable") { + return { kind: "unreadable" }; } - if (!summary.sawUser) { + if (!parsed.sawUser) { return { kind: "no-user" }; } - if (summary.lastUserTimeMs === null) { + if (parsed.lastUserTimeMs === null) { return { kind: "missing-user-time" }; } return { kind: "ready", - lastUserTimeMs: summary.lastUserTimeMs, - workHappened: summary.workHappened, - alreadyShared: summary.alreadyShared, + lastUserTimeMs: parsed.lastUserTimeMs, + workHappened: parsed.workHappened, + alreadyShared: parsed.alreadyShared, }; } -function parseTimestampMs(value: unknown): number | null { +function readTranscriptText(path: string): string | null { + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.size > MAX_TRANSCRIPT_BYTES) { + return null; + } + + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +export function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +// Advance the user-message time cursor while enforcing non-decreasing order. +// Returns the parsed time (which may be null when absent/ambiguous), or +// "unreadable" when a user timestamp moves backwards: transcripts are +// append-only, so a regression means the input can't be trusted and the hook +// must fail closed. Shared so every host applies the same ordering rule. +export function nextUserTimeMs( + rawTimestamp: unknown, + previousUserTimeMs: number | null, +): number | null | "unreadable" { + const userTimeMs = parseTimestampMs(rawTimestamp); + if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { + return "unreadable"; + } + + return userTimeMs; +} + +export function parseTimestampMs(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return normalizeEpochMs(value); } @@ -91,110 +133,20 @@ function parseTimestampMs(value: unknown): number | null { return null; } -function summarizeCursorRows(text: string): - | { - kind: "parsed"; - sawUser: boolean; - lastUserTimeMs: number | null; - workHappened: boolean; - alreadyShared: boolean; - } - | { kind: "unreadable" } { - let sawUser = false; - let lastUserTimeMs: number | null = null; - let workHappened = false; - let alreadyShared = false; - let previousUserTimeMs: number | null = null; - - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed === "") { - continue; - } - - try { - const parsed: unknown = JSON.parse(trimmed); - if (!isJsonRecord(parsed)) { - return { kind: "unreadable" }; - } - - const row = parseCursorTranscriptRow(parsed); - if (row === null) { - return { kind: "unreadable" }; - } - - if (row.event === "user_message") { - const userTimeMs = parseTimestampMs(row.timestamp); - if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { - return { kind: "unreadable" }; - } - - sawUser = true; - lastUserTimeMs = userTimeMs; - if (userTimeMs !== null) { - previousUserTimeMs = userTimeMs; - } - workHappened = false; - alreadyShared = false; - continue; - } - - if (sawUser) { - const workTimeMs = readToolWorkTimeMs(row, lastUserTimeMs); - if (workTimeMs === "unreadable") { - return { kind: "unreadable" }; - } - - workHappened = workHappened || workTimeMs !== null; - alreadyShared = alreadyShared || hasMainframeVideoUrl(parsed); - } - } catch { - return { kind: "unreadable" }; - } - } - - return { kind: "parsed", sawUser, lastUserTimeMs, workHappened, alreadyShared }; -} - -function parseCursorTranscriptRow(record: JsonRecord): CursorTranscriptRow | null { - if (record.event === "user_message" && isNonEmptyString(record.text)) { - return { event: record.event, timestamp: record.timestamp }; - } - if (record.event === "tool_call" && typeof record.name === "string") { - return { event: record.event, timestamp: record.timestamp }; - } - if (record.event === "assistant_message") { - return { event: record.event }; - } - if (record.event === "tool_result") { - return { event: record.event }; - } - - return null; -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim() !== ""; -} - -function readToolWorkTimeMs( - row: CursorTranscriptRow, - lastUserTimeMs: number | null, -): number | null | "unreadable" { - if (row.event !== "tool_call") { - return null; +export function hasMainframeVideoUrl(value: unknown): boolean { + if (typeof value === "string") { + return value.includes(MAINFRAME_VIDEO_URL_PREFIX); } - const toolTimeMs = parseTimestampMs(row.timestamp); - if (toolTimeMs === null) { - return "unreadable"; + if (Array.isArray(value)) { + return value.some((entry) => hasMainframeVideoUrl(entry)); } - if (lastUserTimeMs !== null && toolTimeMs < lastUserTimeMs) { - return "unreadable"; + if (isJsonRecord(value)) { + return Object.values(value).some((entry) => hasMainframeVideoUrl(entry)); } - return toolTimeMs; + return false; } function normalizeEpochMs(value: number): number | null { @@ -208,19 +160,3 @@ function normalizeEpochMs(value: number): number | null { return null; } - -function hasMainframeVideoUrl(value: unknown): boolean { - if (typeof value === "string") { - return value.includes(MAINFRAME_VIDEO_URL_PREFIX); - } - - if (Array.isArray(value)) { - return value.some((entry) => hasMainframeVideoUrl(entry)); - } - - if (isJsonRecord(value)) { - return Object.values(value).some((entry) => hasMainframeVideoUrl(entry)); - } - - return false; -} diff --git a/hooks/cursor/stop-evaluator.ts b/hooks/cursor/stop-evaluator.ts index 3b05c6d..08d9758 100644 --- a/hooks/cursor/stop-evaluator.ts +++ b/hooks/cursor/stop-evaluator.ts @@ -1,7 +1,6 @@ -import { readFileSync } from "node:fs"; - -import { evaluateStopPolicy } from "../core/stop-policy.js"; +import { decideStop } from "../core/stop-policy.js"; import { parseJsonRecord } from "../core/json.js"; +import { summarizeCursorTranscriptFile } from "./transcript.js"; export type CursorStopEvaluationInput = { stdin: string; @@ -16,10 +15,8 @@ export function evaluateCursorStopHook(input: CursorStopEvaluationInput): Cursor return {}; } - const decision = evaluateStopPolicy({ - transcriptPath: hookInput.transcriptPath, - stopTimeMs: input.nowMs ?? Date.now(), - }); + const summary = summarizeCursorTranscriptFile(hookInput.transcriptPath); + const decision = decideStop(summary, input.nowMs ?? Date.now()); if (decision.kind === "skip") { return {}; } @@ -27,12 +24,6 @@ export function evaluateCursorStopHook(input: CursorStopEvaluationInput): Cursor return { followup_message: decision.message }; } -export function runCursorStopHookCli(): void { - const stdin = readFileSync(0, "utf8"); - const output = evaluateCursorStopHook({ stdin }); - process.stdout.write(`${JSON.stringify(output)}\n`); -} - function parseCursorStopInput(stdin: string): { transcriptPath: string; loopCount: number } | null { const input = parseJsonRecord(stdin); if (input === null || input.hook_event_name !== "stop" || input.status !== "completed") { diff --git a/hooks/cursor/stop.ts b/hooks/cursor/stop.ts index e21be3c..c66993b 100644 --- a/hooks/cursor/stop.ts +++ b/hooks/cursor/stop.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node -import { runCursorStopHookCli } from "./stop-evaluator.js"; +import { runStopHookCli } from "../core/run-stop-hook.js"; +import { evaluateCursorStopHook } from "./stop-evaluator.js"; -runCursorStopHookCli(); +runStopHookCli(evaluateCursorStopHook); diff --git a/hooks/core/transcript.test.ts b/hooks/cursor/transcript.test.ts similarity index 90% rename from hooks/core/transcript.test.ts rename to hooks/cursor/transcript.test.ts index 3c9eb19..6a515a3 100644 --- a/hooks/core/transcript.test.ts +++ b/hooks/cursor/transcript.test.ts @@ -4,11 +4,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { summarizeTranscript, summarizeTranscriptFile } from "./transcript.js"; +import { summarizeCursorTranscript, summarizeCursorTranscriptFile } from "./transcript.js"; -describe("summarizeTranscript", () => { +describe("summarizeCursorTranscript", () => { it("summarizes Cursor tool work after the last user message", () => { - const summary = summarizeTranscript( + const summary = summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T12:00:00.000Z", @@ -38,7 +38,7 @@ describe("summarizeTranscript", () => { }); it("detects an existing Mainframe share from Cursor tool output", () => { - const summary = summarizeTranscript( + const summary = summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -62,7 +62,7 @@ describe("summarizeTranscript", () => { }); it("detects an existing Mainframe share from MCP-style text output", () => { - const summary = summarizeTranscript( + const summary = summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -93,7 +93,7 @@ describe("summarizeTranscript", () => { }); it("detects a Mainframe video URL without relying on the tool name", () => { - const summary = summarizeTranscript( + const summary = summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -117,7 +117,7 @@ describe("summarizeTranscript", () => { }); it("detects Mainframe video URLs in assistant messages after work", () => { - const summary = summarizeTranscript( + const summary = summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -146,7 +146,7 @@ describe("summarizeTranscript", () => { }); it("detects Mainframe video URLs in tool result rows", () => { - const summary = summarizeTranscript( + const summary = summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -180,7 +180,7 @@ describe("summarizeTranscript", () => { }); it("does not treat a video URL in the latest user message as an existing share", () => { - const summary = summarizeTranscript( + const summary = summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -204,7 +204,7 @@ describe("summarizeTranscript", () => { }); it("accepts numeric epoch seconds and milliseconds", () => { - const secondsSummary = summarizeTranscript( + const secondsSummary = summarizeCursorTranscript( cursorTranscript([ { timestamp: 1_746_710_400, @@ -213,7 +213,7 @@ describe("summarizeTranscript", () => { }, ]), ); - const millisecondsSummary = summarizeTranscript( + const millisecondsSummary = summarizeCursorTranscript( cursorTranscript([ { timestamp: 1_746_710_400_000, @@ -236,7 +236,7 @@ describe("summarizeTranscript", () => { it("rejects ambiguous timestamp strings", () => { for (const timestamp of ["2026", "05/08/2026", "2026-05-08"]) { expect( - summarizeTranscript( + summarizeCursorTranscript( cursorTranscript([ { timestamp, @@ -257,7 +257,7 @@ describe("summarizeTranscript", () => { it("treats out-of-order user timestamps as unreadable", () => { expect( - summarizeTranscript( + summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T15:00:00.000Z", @@ -282,7 +282,7 @@ describe("summarizeTranscript", () => { it("preserves valid user timestamp order across malformed user timestamps", () => { expect( - summarizeTranscript( + summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T15:00:00.000Z", @@ -306,7 +306,7 @@ describe("summarizeTranscript", () => { it("reports missing user time when the latest user timestamp is malformed", () => { expect( - summarizeTranscript( + summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -325,7 +325,7 @@ describe("summarizeTranscript", () => { it("treats post-user tool calls without timestamps as unreadable", () => { expect( - summarizeTranscript( + summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -344,7 +344,7 @@ describe("summarizeTranscript", () => { it("treats post-user tool calls with malformed timestamps as unreadable", () => { expect( - summarizeTranscript( + summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -364,7 +364,7 @@ describe("summarizeTranscript", () => { it("treats post-user tool calls with pre-user timestamps as unreadable", () => { expect( - summarizeTranscript( + summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -383,7 +383,7 @@ describe("summarizeTranscript", () => { }); it("summarizes full transcripts instead of only a byte tail", () => { - const summary = summarizeTranscript( + const summary = summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -413,7 +413,7 @@ describe("summarizeTranscript", () => { it("treats non-Cursor transcript shapes as unreadable", () => { expect( - summarizeTranscript( + summarizeCursorTranscript( [ JSON.stringify({ timestamp: "2026-05-08T13:00:00.000Z", @@ -431,7 +431,7 @@ describe("summarizeTranscript", () => { it("treats user message rows without text as unreadable", () => { expect( - summarizeTranscript( + summarizeCursorTranscript( JSON.stringify({ timestamp: "2026-05-08T13:00:00.000Z", event: "user_message", @@ -442,7 +442,7 @@ describe("summarizeTranscript", () => { it("treats unknown non-empty rows as unreadable", () => { expect( - summarizeTranscript( + summarizeCursorTranscript( cursorTranscript([ { timestamp: "2026-05-08T13:00:00.000Z", @@ -461,7 +461,7 @@ describe("summarizeTranscript", () => { it("treats corrupt non-empty JSONL as unreadable", () => { expect( - summarizeTranscript( + summarizeCursorTranscript( [ JSON.stringify({ timestamp: "2026-05-08T13:00:00.000Z", @@ -475,21 +475,21 @@ describe("summarizeTranscript", () => { }); it("treats non-object JSONL rows as unreadable", () => { - expect(summarizeTranscript(JSON.stringify(["not", "a", "cursor", "row"]))).toEqual({ + expect(summarizeCursorTranscript(JSON.stringify(["not", "a", "cursor", "row"]))).toEqual({ kind: "unreadable", }); }); it("uses a discriminated state when no real user is present", () => { expect( - summarizeTranscript(JSON.stringify({ event: "assistant_message", text: "done" })), + summarizeCursorTranscript(JSON.stringify({ event: "assistant_message", text: "done" })), ).toEqual({ kind: "no-user", }); }); it("uses a discriminated state when the transcript file cannot be read", () => { - expect(summarizeTranscriptFile("/tmp/mainframe-missing-transcript.jsonl")).toEqual({ + expect(summarizeCursorTranscriptFile("/tmp/mainframe-missing-transcript.jsonl")).toEqual({ kind: "unreadable", }); }); @@ -499,7 +499,7 @@ describe("summarizeTranscript", () => { const path = join(directory, "oversized.jsonl"); writeFileSync(path, "x".repeat(5 * 1024 * 1024 + 1)); - expect(summarizeTranscriptFile(path)).toEqual({ kind: "unreadable" }); + expect(summarizeCursorTranscriptFile(path)).toEqual({ kind: "unreadable" }); }); it("treats symlink transcript paths as unreadable", () => { @@ -518,7 +518,7 @@ describe("summarizeTranscript", () => { ); symlinkSync(sourcePath, symlinkPath); - expect(summarizeTranscriptFile(symlinkPath)).toEqual({ kind: "unreadable" }); + expect(summarizeCursorTranscriptFile(symlinkPath)).toEqual({ kind: "unreadable" }); }); }); diff --git a/hooks/cursor/transcript.ts b/hooks/cursor/transcript.ts new file mode 100644 index 0000000..7754918 --- /dev/null +++ b/hooks/cursor/transcript.ts @@ -0,0 +1,110 @@ +import { + hasMainframeVideoUrl, + isNonEmptyString, + nextUserTimeMs, + type ParsedTranscript, + parseTimestampMs, + summarizeTranscript, + summarizeTranscriptFile, + type TranscriptSummary, +} from "../core/transcript.js"; +import { type JsonRecord, parseJsonlRecords } from "../core/json.js"; + +type CursorTranscriptRow = + | { event: "assistant_message" } + | { event: "tool_call"; timestamp: unknown } + | { event: "tool_result" } + | { event: "user_message"; timestamp: unknown }; + +export function summarizeCursorTranscriptFile(path: string): TranscriptSummary { + return summarizeTranscriptFile(path, parseCursorRows); +} + +export function summarizeCursorTranscript(text: string): TranscriptSummary { + return summarizeTranscript(text, parseCursorRows); +} + +function parseCursorRows(text: string): ParsedTranscript | "unreadable" { + const records = parseJsonlRecords(text); + if (records === "unreadable") { + return "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 row = parseCursorTranscriptRow(record); + if (row === null) { + return "unreadable"; + } + + if (row.event === "user_message") { + const userTimeMs = nextUserTimeMs(row.timestamp, previousUserTimeMs); + if (userTimeMs === "unreadable") { + return "unreadable"; + } + + sawUser = true; + lastUserTimeMs = userTimeMs; + if (userTimeMs !== null) { + previousUserTimeMs = userTimeMs; + } + workHappened = false; + alreadyShared = false; + continue; + } + + if (sawUser) { + const workTimeMs = readToolWorkTimeMs(row, lastUserTimeMs); + if (workTimeMs === "unreadable") { + return "unreadable"; + } + + workHappened = workHappened || workTimeMs !== null; + alreadyShared = alreadyShared || hasMainframeVideoUrl(record); + } + } + + return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; +} + +function parseCursorTranscriptRow(record: JsonRecord): CursorTranscriptRow | null { + if (record.event === "user_message" && isNonEmptyString(record.text)) { + return { event: record.event, timestamp: record.timestamp }; + } + if (record.event === "tool_call" && typeof record.name === "string") { + return { event: record.event, timestamp: record.timestamp }; + } + if (record.event === "assistant_message") { + return { event: record.event }; + } + if (record.event === "tool_result") { + return { event: record.event }; + } + + return null; +} + +function readToolWorkTimeMs( + row: CursorTranscriptRow, + lastUserTimeMs: number | null, +): number | null | "unreadable" { + if (row.event !== "tool_call") { + return null; + } + + const toolTimeMs = parseTimestampMs(row.timestamp); + if (toolTimeMs === null) { + return "unreadable"; + } + + if (lastUserTimeMs !== null && toolTimeMs < lastUserTimeMs) { + return "unreadable"; + } + + return toolTimeMs; +} diff --git a/package.json b/package.json index 8f8b608..c8b29ae 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@mainframe/plugins", "version": "0.1.0", "private": true, - "description": "Mainframe Cursor plugin manifest, skill, MCP wiring, and stop hook.", + "description": "Mainframe Cursor and Codex plugin manifests, skill, MCP wiring, and stop hooks.", "keywords": [ "agent-skills", "hooks", @@ -17,15 +17,19 @@ "url": "https://github.com/mainframecomputer/mainframe-plugins.git" }, "bin": { + "mainframe-hook-codex": "./dist/hooks/codex/stop.js", "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js" }, "files": [ + ".agents", + ".codex-plugin", ".cursor-plugin", ".mcp.json", "LICENSE", "README.md", "assets/logo.png", "dist", + "hooks/codex/hooks.json", "hooks/cursor/hooks.json", "skills" ], @@ -34,9 +38,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", + "generate": "bun tooling/generate.ts && oxfmt --write package.json .cursor-plugin .codex-plugin .agents", "check:generated": "bun tooling/generated-drift-check.ts", - "archive:release": "bun run generate && bun run build && git diff --exit-code -- package.json .cursor-plugin 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 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 965d764..95e9bf3 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -8,9 +8,12 @@ const JsonRecordSchema = z.record(z.string(), z.unknown()); const MetadataSchema = z.object({ name: z.string().min(1), + displayName: z.string().min(1), packageName: z.string().min(1), version: z.string().regex(/^\d+\.\d+\.\d+$/), description: z.string().min(1), + longDescription: z.string().min(1), + category: z.string().min(1), author: z.object({ name: z.string().min(1), email: z.string().email(), @@ -24,11 +27,18 @@ const MetadataSchema = z.object({ skillDirectory: z.string().min(1), }); +const CURSOR_HOOKS = "./hooks/cursor/hooks.json"; +const CODEX_HOOKS = "./hooks/codex/hooks.json"; + const metadata = MetadataSchema.parse({ name: "mainframe", + displayName: "Mainframe", packageName: "@mainframe/plugins", 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.", + 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.", + category: "Productivity", author: { name: "Mainframe", email: "support@mainframe.app", @@ -51,22 +61,25 @@ const sharedManifest = { repository: metadata.repository, license: metadata.license, keywords: metadata.keywords, - logo: metadata.logo, - skills: metadata.skillDirectory, - mcpServers: metadata.mcpServers, }; function main(): void { writeJson(".cursor-plugin/plugin.json", { ...sharedManifest, - hooks: "./hooks/cursor/hooks.json", + logo: metadata.logo, + skills: metadata.skillDirectory, + mcpServers: metadata.mcpServers, + hooks: CURSOR_HOOKS, }); + writeJson(".cursor-plugin/marketplace.json", cursorMarketplace()); + + writeJson(".codex-plugin/plugin.json", codexManifest()); + writeJson(".agents/plugins/marketplace.json", codexMarketplace()); - writeJson(".cursor-plugin/marketplace.json", marketplace()); updatePackageJson(); } -function marketplace() { +function cursorMarketplace() { return { name: metadata.name, owner: metadata.author, @@ -83,11 +96,56 @@ function marketplace() { }; } +function codexManifest() { + return { + ...sharedManifest, + skills: metadata.skillDirectory, + mcpServers: metadata.mcpServers, + hooks: CODEX_HOOKS, + interface: { + displayName: metadata.displayName, + shortDescription: metadata.description, + longDescription: metadata.longDescription, + developerName: metadata.author.name, + category: metadata.category, + logo: `./${metadata.logo}`, + }, + }; +} + +// Codex resolves a marketplace's local plugin sources from a subdirectory, so we +// point the entry at the repository root through a Git URL source. That keeps the +// Codex plugin rooted alongside the Cursor plugin and sharing the same skill, +// MCP wiring, and hook runtime instead of duplicating them into a subfolder. +function codexMarketplace() { + return { + name: metadata.name, + interface: { + displayName: metadata.displayName, + }, + plugins: [ + { + name: metadata.name, + source: { + source: "url", + url: metadata.repository, + }, + policy: { + installation: "AVAILABLE", + authentication: "ON_INSTALL", + }, + category: metadata.category, + }, + ], + }; +} + 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 plugin manifest, skill, MCP wiring, and stop hook."; + packageJson.description = + "Mainframe Cursor and Codex plugin manifests, skill, MCP wiring, and stop hooks."; packageJson.private = true; packageJson.license = metadata.license; packageJson.homepage = metadata.homepage; @@ -98,6 +156,7 @@ function updatePackageJson(): void { packageJson.keywords = metadata.keywords; packageJson.bin = { "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js", + "mainframe-hook-codex": "./dist/hooks/codex/stop.js", }; packageJson.files = PACKAGE_FILES; diff --git a/tooling/generated-drift-check.ts b/tooling/generated-drift-check.ts index 9908e1f..cf49e61 100644 --- a/tooling/generated-drift-check.ts +++ b/tooling/generated-drift-check.ts @@ -4,6 +4,8 @@ import { spawnSync } from "node:child_process"; const generatedPaths = [ ".cursor-plugin/plugin.json", ".cursor-plugin/marketplace.json", + ".codex-plugin/plugin.json", + ".agents/plugins/marketplace.json", "package.json", ]; diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index f723f90..0db7069 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -12,49 +12,77 @@ const AuthorSchema = z const RepositorySchema = z.literal("https://github.com/mainframecomputer/mainframe-plugins.git"); +const DescriptionSchema = z.literal("Create and share short video updates from agent work."); + +const LongDescriptionSchema = z.literal( + "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.", +); + +const KeywordsSchema = z.tuple([ + z.literal("mainframe"), + z.literal("agent-skills"), + z.literal("mcp"), + z.literal("hooks"), + z.literal("video"), +]); + const SharedManifestSchema = z.object({ + name: z.literal("mainframe"), version: z.literal("0.1.0"), - description: z.literal("Create and share short Mainframe video updates from coding-agent work."), + description: DescriptionSchema, author: AuthorSchema, homepage: z.literal("https://mainframe.app"), repository: RepositorySchema, license: z.literal("UNLICENSED"), - keywords: z.tuple([ - z.literal("mainframe"), - z.literal("agent-skills"), - z.literal("mcp"), - z.literal("hooks"), - z.literal("video"), - ]), - logo: z.literal("assets/logo.png"), -}); - -const ManifestSchema = SharedManifestSchema.extend({ - name: z.literal("mainframe"), + keywords: KeywordsSchema, skills: z.literal("./skills"), mcpServers: z.literal("./.mcp.json"), +}); + +const CursorManifestSchema = SharedManifestSchema.extend({ + logo: z.literal("assets/logo.png"), hooks: z.literal("./hooks/cursor/hooks.json"), }).strict(); -const manifestPaths = [".cursor-plugin/plugin.json"]; +const CodexManifestSchema = SharedManifestSchema.extend({ + hooks: z.literal("./hooks/codex/hooks.json"), + interface: z + .object({ + displayName: z.literal("Mainframe"), + shortDescription: DescriptionSchema, + longDescription: LongDescriptionSchema, + developerName: z.literal("Mainframe"), + category: z.literal("Productivity"), + logo: z.literal("./assets/logo.png"), + }) + .strict(), +}).strict(); describe("generated plugin manifests", () => { - it.each(manifestPaths)("%s matches the Cursor plugin schema subset", (path) => { - const manifest = ManifestSchema.parse(JSON.parse(readFileSync(path, "utf8"))); + it(".cursor-plugin/plugin.json matches the Cursor plugin schema", () => { + const manifest = CursorManifestSchema.parse( + JSON.parse(readFileSync(".cursor-plugin/plugin.json", "utf8")), + ); expect(manifest.license).toBe("UNLICENSED"); }); - it("marketplace metadata matches the Cursor marketplace schema subset", () => { + it(".codex-plugin/plugin.json matches the Codex plugin schema", () => { + const manifest = CodexManifestSchema.parse( + JSON.parse(readFileSync(".codex-plugin/plugin.json", "utf8")), + ); + + expect(manifest.hooks).toBe("./hooks/codex/hooks.json"); + }); + + it("Cursor marketplace metadata matches the Cursor marketplace schema", () => { const marketplaceSchema = z .object({ name: z.literal("mainframe"), owner: AuthorSchema, metadata: z .object({ - description: z.literal( - "Create and share short Mainframe video updates from coding-agent work.", - ), + description: DescriptionSchema, }) .strict(), plugins: z.tuple([ @@ -62,9 +90,7 @@ describe("generated plugin manifests", () => { .object({ name: z.literal("mainframe"), source: z.literal("."), - description: z.literal( - "Create and share short Mainframe video updates from coding-agent work.", - ), + description: DescriptionSchema, }) .strict(), ]), @@ -73,4 +99,39 @@ describe("generated plugin manifests", () => { marketplaceSchema.parse(JSON.parse(readFileSync(".cursor-plugin/marketplace.json", "utf8"))); }); + + it("Codex marketplace metadata matches the Codex marketplace schema", () => { + const marketplaceSchema = z + .object({ + name: z.literal("mainframe"), + interface: z + .object({ + displayName: z.literal("Mainframe"), + }) + .strict(), + plugins: z.tuple([ + z + .object({ + name: z.literal("mainframe"), + source: z + .object({ + source: z.literal("url"), + url: RepositorySchema, + }) + .strict(), + policy: z + .object({ + installation: z.literal("AVAILABLE"), + authentication: z.literal("ON_INSTALL"), + }) + .strict(), + category: z.literal("Productivity"), + }) + .strict(), + ]), + }) + .strict(); + + marketplaceSchema.parse(JSON.parse(readFileSync(".agents/plugins/marketplace.json", "utf8"))); + }); }); diff --git a/tooling/mark-executable.ts b/tooling/mark-executable.ts index 3062c78..06c9e7e 100644 --- a/tooling/mark-executable.ts +++ b/tooling/mark-executable.ts @@ -1,6 +1,6 @@ import { chmodSync } from "node:fs"; -const files = ["dist/hooks/cursor/stop.js"]; +const files = ["dist/hooks/cursor/stop.js", "dist/hooks/codex/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 680c203..befe928 100644 --- a/tooling/package-surface.test.ts +++ b/tooling/package-surface.test.ts @@ -13,7 +13,7 @@ const PackageSchema = z }) .passthrough(); -const HooksSchema = z +const CursorHooksSchema = z .object({ hooks: z.object({ stop: z.tuple([ @@ -27,14 +27,34 @@ const HooksSchema = z }) .passthrough(); +const CodexHooksSchema = z + .object({ + hooks: z.object({ + Stop: z.tuple([ + z + .object({ + hooks: z.tuple([ + z + .object({ + command: z.string(), + }) + .passthrough(), + ]), + }) + .passthrough(), + ]), + }), + }) + .passthrough(); + describe("package runtime surface", () => { - it("ships only the Cursor plugin package surface", () => { + it("ships only the allowlisted plugin package surface", () => { const packageJson = PackageSchema.parse(readJson("package.json")); expect(packageJson.files).toEqual(PACKAGE_FILES); }); - it("ships only the expected Cursor plugin files", () => { + it("ships only the expected plugin files", () => { const packageJson = PackageSchema.parse(readJson("package.json")); const shippedFiles = packageJson.files.flatMap(readPackageFiles); @@ -44,9 +64,9 @@ describe("package runtime surface", () => { it("ships the executable Cursor hook runtime referenced by hooks.json and package bin", () => { const packageJson = PackageSchema.parse(readJson("package.json")); - const hooks = HooksSchema.parse(readJson("hooks/cursor/hooks.json")); + const hooks = CursorHooksSchema.parse(readJson("hooks/cursor/hooks.json")); - const hookTarget = readCursorPluginRootNodeTarget(hooks.hooks.stop[0].command); + const hookTarget = readPluginRootNodeTarget(hooks.hooks.stop[0].command, "CURSOR_PLUGIN_ROOT"); const binTargets = Object.values(packageJson.bin).map((target) => target.replace(/^\.\//, "")); expect(hookTarget).toBe("dist/hooks/cursor/stop.js"); @@ -55,6 +75,22 @@ describe("package runtime surface", () => { expect(statSync(hookTarget).mode & 0o111).not.toBe(0); }); + 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 hookTarget = readPluginRootNodeTarget( + hooks.hooks.Stop[0].hooks[0].command, + "PLUGIN_ROOT", + ); + const binTargets = Object.values(packageJson.bin).map((target) => target.replace(/^\.\//, "")); + + expect(hookTarget).toBe("dist/hooks/codex/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")); @@ -68,8 +104,8 @@ function readJson(path: string): unknown { return JSON.parse(readFileSync(path, "utf8")); } -function readCursorPluginRootNodeTarget(command: string): string { - const match = /^node "\$CURSOR_PLUGIN_ROOT\/(.+)"$/.exec(command); +function readPluginRootNodeTarget(command: string, rootEnv: string): string { + const match = new RegExp(`^node "\\$${rootEnv}/(.+)"$`).exec(command); if (match === null) { throw new Error(`Unexpected hook command: ${command}`); } diff --git a/tooling/package-surface.ts b/tooling/package-surface.ts index 35d4266..da3e6ea 100644 --- a/tooling/package-surface.ts +++ b/tooling/package-surface.ts @@ -2,29 +2,40 @@ import { lstatSync, readdirSync } from "node:fs"; import { join } from "node:path"; export const PACKAGE_FILES = [ + ".agents", + ".codex-plugin", ".cursor-plugin", ".mcp.json", "LICENSE", "README.md", "assets/logo.png", "dist", + "hooks/codex/hooks.json", "hooks/cursor/hooks.json", "skills", ]; export const SHIPPED_FILES = [ + ".agents/plugins/marketplace.json", + ".codex-plugin/plugin.json", ".cursor-plugin/marketplace.json", ".cursor-plugin/plugin.json", ".mcp.json", "LICENSE", "README.md", "assets/logo.png", + "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-policy.js", "dist/hooks/core/transcript.js", "dist/hooks/cursor/stop-evaluator.js", "dist/hooks/cursor/stop.js", + "dist/hooks/cursor/transcript.js", + "hooks/codex/hooks.json", "hooks/cursor/hooks.json", "skills/share-video/SKILL.md", ]; @@ -38,19 +49,11 @@ export function readPackageFiles(path: string): string[] { return readdirSync(path).flatMap((entry) => readPackageFiles(join(path, entry))); } -export function assertCursorOnlyPackageSurface(packageFiles: readonly string[]): void { - assertSameItems( - "Package files differ from the Cursor-only allowlist", - packageFiles, - PACKAGE_FILES, - ); +export function assertPluginPackageSurface(packageFiles: readonly string[]): void { + assertSameItems("Package files differ from the plugin allowlist", packageFiles, PACKAGE_FILES); const shippedFiles = packageFiles.flatMap(readPackageFiles); - assertSameItems( - "Shipped files differ from the Cursor-only allowlist", - shippedFiles, - SHIPPED_FILES, - ); + assertSameItems("Shipped files differ from the plugin allowlist", shippedFiles, SHIPPED_FILES); } function assertSameItems( diff --git a/tooling/release-archive.ts b/tooling/release-archive.ts index eb637f0..d6e6b36 100644 --- a/tooling/release-archive.ts +++ b/tooling/release-archive.ts @@ -12,7 +12,7 @@ import { spawnSync } from "node:child_process"; import { basename, dirname, join, resolve } from "node:path"; import { z } from "zod"; -import { assertCursorOnlyPackageSurface } from "./package-surface.js"; +import { assertPluginPackageSurface } from "./package-surface.js"; const PackageSchema = z.object({ name: z.string().min(1), @@ -27,10 +27,19 @@ const PluginManifestSchema = z.object({ skills: z.string().min(1), }); +const CodexManifestSchema = z.object({ + hooks: z.string().min(1), + mcpServers: z.string().min(1), + skills: z.string().min(1), +}); + const packageJson = PackageSchema.parse(JSON.parse(readFileSync("package.json", "utf8"))); const pluginManifest = PluginManifestSchema.parse( JSON.parse(readFileSync(".cursor-plugin/plugin.json", "utf8")), ); +const codexManifest = CodexManifestSchema.parse( + JSON.parse(readFileSync(".codex-plugin/plugin.json", "utf8")), +); const archiveName = `${packageJson.name.replace(/^@/, "").replace("/", "-")}-${packageJson.version}.tgz`; const releaseDir = resolve("release"); const archivePath = join(releaseDir, archiveName); @@ -47,9 +56,12 @@ const manifestPaths = [ pluginManifest.logo, pluginManifest.mcpServers, pluginManifest.skills, + codexManifest.hooks, + codexManifest.mcpServers, + codexManifest.skills, ].map((path) => path.replace(/^\.\//, "")); -assertCursorOnlyPackageSurface(packageJson.files); +assertPluginPackageSurface(packageJson.files); for (const path of manifestPaths) { if (!existsSync(path)) {