From e923c6bcd18d02fbf357500318727eddf271ac61 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 18:57:53 +0000 Subject: [PATCH 01/14] Add Codex plugin sharing a host-agnostic stop-hook core Codex now installs the Mainframe plugin with the same share-video skill, hosted MCP wiring, and AFK stop hook as Cursor. The Codex Stop hook returns `{ decision: "block", reason }` to nudge an away session toward a video summary, guarded by `stop_hook_active` to avoid continuation loops. Refactor the stop-hook runtime so hosts share one core: - `hooks/core/transcript.ts` holds host-agnostic primitives (timestamp and Mainframe-URL parsing, file reading, summary shaping). - Cursor transcript parsing moves to `hooks/cursor/transcript.ts`; Codex rollout parsing lives in `hooks/codex/transcript.ts`, ignoring unrelated event types while failing closed on corruption or a missing `session_meta`. - `stop-policy` exposes `decideStop(summary, stopTimeMs)` shared by both hosts. `tooling/generate.ts` emits `.codex-plugin/plugin.json` and the `.agents/plugins/marketplace.json` catalog (a Git-URL source rooted at the repo so both plugins share `skills/`, `./.mcp.json`, and `dist/`). The package surface, drift check, and release archive now cover both hosts. --- .agents/plugins/marketplace.json | 20 ++ .codex-plugin/plugin.json | 24 +++ dist/hooks/codex/stop-evaluator.js | 32 +++ dist/hooks/codex/stop.js | 3 + dist/hooks/codex/transcript.js | 82 ++++++++ dist/hooks/core/stop-policy.js | 6 +- dist/hooks/core/transcript.js | 129 +++--------- dist/hooks/cursor/stop-evaluator.js | 9 +- dist/hooks/cursor/transcript.js | 93 +++++++++ hooks/codex/fixtures/stop.json | 11 + hooks/codex/fixtures/transcript.jsonl | 4 + hooks/codex/hooks.json | 15 ++ hooks/codex/stop-evaluator.ts | 52 +++++ hooks/codex/stop.test.ts | 115 +++++++++++ hooks/codex/stop.ts | 4 + hooks/codex/transcript.test.ts | 234 ++++++++++++++++++++++ hooks/codex/transcript.ts | 114 +++++++++++ hooks/core/stop-policy.ts | 14 +- hooks/core/transcript.ts | 170 +++------------- hooks/cursor/stop-evaluator.ts | 9 +- hooks/{core => cursor}/transcript.test.ts | 56 +++--- hooks/cursor/transcript.ts | 128 ++++++++++++ package.json | 10 +- tooling/generate.ts | 73 ++++++- tooling/generated-drift-check.ts | 2 + tooling/manifest.test.ts | 109 +++++++--- tooling/mark-executable.ts | 2 +- tooling/package-surface.test.ts | 50 ++++- tooling/package-surface.ts | 24 ++- tooling/release-archive.ts | 16 +- 30 files changed, 1261 insertions(+), 349 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 .codex-plugin/plugin.json create mode 100644 dist/hooks/codex/stop-evaluator.js create mode 100755 dist/hooks/codex/stop.js create mode 100644 dist/hooks/codex/transcript.js create mode 100644 dist/hooks/cursor/transcript.js create mode 100644 hooks/codex/fixtures/stop.json create mode 100644 hooks/codex/fixtures/transcript.jsonl create mode 100644 hooks/codex/hooks.json create mode 100644 hooks/codex/stop-evaluator.ts create mode 100644 hooks/codex/stop.test.ts create mode 100644 hooks/codex/stop.ts create mode 100644 hooks/codex/transcript.test.ts create mode 100644 hooks/codex/transcript.ts rename hooks/{core => cursor}/transcript.test.ts (90%) create mode 100644 hooks/cursor/transcript.ts 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..b163bcb --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "mainframe", + "version": "0.1.0", + "description": "Create and share short Mainframe video updates from coding-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 Mainframe video updates from coding-agent work.", + "longDescription": "Mainframe adds a share-video skill, hosted MCP server wiring, and a stop hook that suggests a short video update after meaningful agent work when the user has been away.", + "developerName": "Mainframe", + "category": "Productivity", + "logo": "./assets/logo.png" + } +} diff --git a/dist/hooks/codex/stop-evaluator.js b/dist/hooks/codex/stop-evaluator.js new file mode 100644 index 0000000..30ec330 --- /dev/null +++ b/dist/hooks/codex/stop-evaluator.js @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +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 }; +} +export function runCodexStopHookCli() { + const stdin = readFileSync(0, "utf8"); + const output = evaluateCodexStopHook({ stdin }); + process.stdout.write(`${JSON.stringify(output)}\n`); +} +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..882042b --- /dev/null +++ b/dist/hooks/codex/stop.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node +import { runCodexStopHookCli } from "./stop-evaluator.js"; +runCodexStopHookCli(); diff --git a/dist/hooks/codex/transcript.js b/dist/hooks/codex/transcript.js new file mode 100644 index 0000000..7d259d1 --- /dev/null +++ b/dist/hooks/codex/transcript.js @@ -0,0 +1,82 @@ +import { hasMainframeVideoUrl, isNonEmptyString, parseTimestampMs, readTranscriptText, summaryFromParsed, } from "../core/transcript.js"; +import { isJsonRecord } from "../core/json.js"; +export function summarizeCodexTranscriptFile(path) { + const text = readTranscriptText(path); + if (text === null) { + return { kind: "unreadable" }; + } + return summarizeCodexTranscript(text); +} +export function summarizeCodexTranscript(text) { + const parsed = parseCodexRows(text); + if (parsed === "unreadable") { + return { kind: "unreadable" }; + } + return summaryFromParsed(parsed); +} +// 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. Structural +// corruption (non-JSON or non-object rows) and the absence of a `session_meta` +// row fail closed so the hook never fires on an untrusted transcript. +function parseCodexRows(text) { + let sawSessionMeta = false; + let sawUser = false; + let lastUserTimeMs = null; + let workHappened = false; + let alreadyShared = false; + 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"; + } + const row = classifyCodexRow(parsed); + if (row.kind === "session-meta") { + sawSessionMeta = true; + continue; + } + if (row.kind === "user") { + sawUser = true; + lastUserTimeMs = parseTimestampMs(parsed.timestamp); + workHappened = false; + alreadyShared = false; + continue; + } + if (sawUser) { + workHappened = workHappened || row.kind === "work"; + alreadyShared = alreadyShared || hasMainframeVideoUrl(parsed); + } + } + if (!sawSessionMeta) { + return "unreadable"; + } + return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; +} +function classifyCodexRow(record) { + if (record.type === "session_meta") { + return { kind: "session-meta" }; + } + const payload = record.payload; + if (!isJsonRecord(payload)) { + return { kind: "other" }; + } + if (record.type === "event_msg" && + payload.type === "user_message" && + isNonEmptyString(payload.message)) { + return { kind: "user" }; + } + if (record.type === "response_item" && payload.type === "function_call") { + return { kind: "work" }; + } + return { kind: "other" }; +} 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..576f121 100644 --- a/dist/hooks/core/transcript.js +++ b/dist/hooks/core/transcript.js @@ -1,43 +1,42 @@ import { lstatSync, readFileSync } from "node:fs"; import { isJsonRecord } from "./json.js"; -const MAX_TRANSCRIPT_BYTES = 5 * 1024 * 1024; +export const MAX_TRANSCRIPT_BYTES = 5 * 1024 * 1024; 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) { +export function readTranscriptText(path) { try { const stat = lstatSync(path); if (!stat.isFile() || stat.size > MAX_TRANSCRIPT_BYTES) { - return { kind: "unreadable" }; + return null; } - return summarizeTranscript(readFileSync(path, "utf8")); + return readFileSync(path, "utf8"); } catch { - return { kind: "unreadable" }; + return null; } } -export function summarizeTranscript(text) { - const summary = summarizeCursorRows(text); - if (summary.kind === "unreadable") { - return summary; - } - if (!summary.sawUser) { +export function summaryFromParsed(parsed) { + 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) { +export function isNonEmptyString(value) { + return typeof value === "string" && value.trim() !== ""; +} +export function parseTimestampMs(value) { if (typeof value === "number" && Number.isFinite(value)) { return normalizeEpochMs(value); } @@ -59,85 +58,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 +79,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..20e6114 100644 --- a/dist/hooks/cursor/stop-evaluator.js +++ b/dist/hooks/cursor/stop-evaluator.js @@ -1,15 +1,14 @@ 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 {}; } diff --git a/dist/hooks/cursor/transcript.js b/dist/hooks/cursor/transcript.js new file mode 100644 index 0000000..a41793d --- /dev/null +++ b/dist/hooks/cursor/transcript.js @@ -0,0 +1,93 @@ +import { hasMainframeVideoUrl, isNonEmptyString, parseTimestampMs, readTranscriptText, summaryFromParsed, } from "../core/transcript.js"; +import { isJsonRecord } from "../core/json.js"; +export function summarizeCursorTranscriptFile(path) { + const text = readTranscriptText(path); + if (text === null) { + return { kind: "unreadable" }; + } + return summarizeCursorTranscript(text); +} +export function summarizeCursorTranscript(text) { + const parsed = parseCursorRows(text); + if (parsed === "unreadable") { + return { kind: "unreadable" }; + } + return summaryFromParsed(parsed); +} +function parseCursorRows(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 "unreadable"; + } + const row = parseCursorTranscriptRow(parsed); + if (row === null) { + return "unreadable"; + } + if (row.event === "user_message") { + const userTimeMs = parseTimestampMs(row.timestamp); + if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { + 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(parsed); + } + } + catch { + return "unreadable"; + } + } + 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..1a81112 --- /dev/null +++ b/hooks/codex/stop-evaluator.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; + +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 }; +} + +export function runCodexStopHookCli(): void { + const stdin = readFileSync(0, "utf8"); + const output = evaluateCodexStopHook({ stdin }); + process.stdout.write(`${JSON.stringify(output)}\n`); +} + +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..5b1c3e2 --- /dev/null +++ b/hooks/codex/stop.test.ts @@ -0,0 +1,115 @@ +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 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..a70b017 --- /dev/null +++ b/hooks/codex/stop.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runCodexStopHookCli } from "./stop-evaluator.js"; + +runCodexStopHookCli(); diff --git a/hooks/codex/transcript.test.ts b/hooks/codex/transcript.test.ts new file mode 100644 index 0000000..27fae73 --- /dev/null +++ b/hooks/codex/transcript.test.ts @@ -0,0 +1,234 @@ +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("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("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("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..8c99746 --- /dev/null +++ b/hooks/codex/transcript.ts @@ -0,0 +1,114 @@ +import { + hasMainframeVideoUrl, + isNonEmptyString, + type ParsedTranscript, + parseTimestampMs, + readTranscriptText, + summaryFromParsed, + type TranscriptSummary, +} from "../core/transcript.js"; +import { isJsonRecord, type JsonRecord } from "../core/json.js"; + +type CodexRowKind = + | { kind: "session-meta" } + | { kind: "user" } + | { kind: "work" } + | { kind: "other" }; + +export function summarizeCodexTranscriptFile(path: string): TranscriptSummary { + const text = readTranscriptText(path); + if (text === null) { + return { kind: "unreadable" }; + } + + return summarizeCodexTranscript(text); +} + +export function summarizeCodexTranscript(text: string): TranscriptSummary { + const parsed = parseCodexRows(text); + if (parsed === "unreadable") { + return { kind: "unreadable" }; + } + + return summaryFromParsed(parsed); +} + +// 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. Structural +// corruption (non-JSON or non-object rows) and the absence of a `session_meta` +// row fail closed so the hook never fires on an untrusted transcript. +function parseCodexRows(text: string): ParsedTranscript | "unreadable" { + let sawSessionMeta = false; + let sawUser = false; + let lastUserTimeMs: number | null = null; + let workHappened = false; + let alreadyShared = false; + + 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"; + } + + const row = classifyCodexRow(parsed); + if (row.kind === "session-meta") { + sawSessionMeta = true; + continue; + } + + if (row.kind === "user") { + sawUser = true; + lastUserTimeMs = parseTimestampMs(parsed.timestamp); + workHappened = false; + alreadyShared = false; + continue; + } + + if (sawUser) { + workHappened = workHappened || row.kind === "work"; + alreadyShared = alreadyShared || hasMainframeVideoUrl(parsed); + } + } + + if (!sawSessionMeta) { + return "unreadable"; + } + + return { sawUser, lastUserTimeMs, workHappened, alreadyShared }; +} + +function classifyCodexRow(record: JsonRecord): CodexRowKind { + if (record.type === "session_meta") { + return { kind: "session-meta" }; + } + + const payload = record.payload; + if (!isJsonRecord(payload)) { + return { kind: "other" }; + } + + if ( + record.type === "event_msg" && + payload.type === "user_message" && + isNonEmptyString(payload.message) + ) { + return { kind: "user" }; + } + + if (record.type === "response_item" && payload.type === "function_call") { + return { kind: "work" }; + } + + return { kind: "other" }; +} 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..69d84c9 100644 --- a/hooks/core/transcript.ts +++ b/hooks/core/transcript.ts @@ -1,14 +1,14 @@ 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; +export const MAX_TRANSCRIPT_BYTES = 5 * 1024 * 1024; 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})$/; + /^\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,48 @@ export type TranscriptSummary = alreadyShared: boolean; }; -type CursorTranscriptRow = - | { event: "assistant_message" } - | { event: "tool_call"; timestamp: unknown } - | { event: "tool_result" } - | { event: "user_message"; timestamp: unknown }; +export type ParsedTranscript = { + sawUser: boolean; + lastUserTimeMs: number | null; + workHappened: boolean; + alreadyShared: boolean; +}; -export function summarizeTranscriptFile(path: string): TranscriptSummary { +export function readTranscriptText(path: string): string | null { try { const stat = lstatSync(path); if (!stat.isFile() || stat.size > MAX_TRANSCRIPT_BYTES) { - return { kind: "unreadable" }; + return null; } - return summarizeTranscript(readFileSync(path, "utf8")); + return readFileSync(path, "utf8"); } catch { - return { kind: "unreadable" }; + return null; } } -export function summarizeTranscript(text: string): TranscriptSummary { - const summary = summarizeCursorRows(text); - if (summary.kind === "unreadable") { - return summary; - } - - if (!summary.sawUser) { +export function summaryFromParsed(parsed: ParsedTranscript): TranscriptSummary { + 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 { +export function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +export function parseTimestampMs(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return normalizeEpochMs(value); } @@ -91,110 +91,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 +118,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..965cf0c 100644 --- a/hooks/cursor/stop-evaluator.ts +++ b/hooks/cursor/stop-evaluator.ts @@ -1,7 +1,8 @@ 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 +17,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 {}; } 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..7493cad --- /dev/null +++ b/hooks/cursor/transcript.ts @@ -0,0 +1,128 @@ +import { + hasMainframeVideoUrl, + isNonEmptyString, + type ParsedTranscript, + parseTimestampMs, + readTranscriptText, + summaryFromParsed, + type TranscriptSummary, +} from "../core/transcript.js"; +import { isJsonRecord, type JsonRecord } 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 { + const text = readTranscriptText(path); + if (text === null) { + return { kind: "unreadable" }; + } + + return summarizeCursorTranscript(text); +} + +export function summarizeCursorTranscript(text: string): TranscriptSummary { + const parsed = parseCursorRows(text); + if (parsed === "unreadable") { + return { kind: "unreadable" }; + } + + return summaryFromParsed(parsed); +} + +function parseCursorRows(text: string): ParsedTranscript | "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 "unreadable"; + } + + const row = parseCursorTranscriptRow(parsed); + if (row === null) { + return "unreadable"; + } + + if (row.event === "user_message") { + const userTimeMs = parseTimestampMs(row.timestamp); + if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { + 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(parsed); + } + } catch { + return "unreadable"; + } + } + + 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..b50a963 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.", + longDescription: + "Mainframe adds a share-video skill, hosted MCP server wiring, and a stop hook that suggests a short video update after meaningful agent work when the user has been away.", + 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..d0f0d5e 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -12,49 +12,79 @@ const AuthorSchema = z const RepositorySchema = z.literal("https://github.com/mainframecomputer/mainframe-plugins.git"); +const DescriptionSchema = z.literal( + "Create and share short Mainframe video updates from coding-agent work.", +); + +const LongDescriptionSchema = z.literal( + "Mainframe adds a share-video skill, hosted MCP server wiring, and a stop hook that suggests a short video update after meaningful agent work when the user has been away.", +); + +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 +92,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 +101,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..b834c0f 100644 --- a/tooling/package-surface.ts +++ b/tooling/package-surface.ts @@ -2,29 +2,39 @@ 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/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 +48,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)) { From 36aae6d44bcb3eeed8fecd278bc9cebacdafd0ac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 18:58:23 +0000 Subject: [PATCH 02/14] Document the Cursor and Codex plugins Describe installing the Codex plugin via `codex plugin marketplace add` and note the shared share-video skill, MCP wiring, and stop hook. Update the agent notes to cover both supported hosts and the generated metadata locations. --- AGENTS.md | 18 ++++++++++-------- README.md | 23 +++++++++++++++++++---- 2 files changed, 29 insertions(+), 12 deletions(-) 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..406f1b7 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,23 @@ 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. The plugin bundles the `share-video` +skill, Mainframe MCP wiring, and a stop hook that suggests a short video update after meaningful +agent work when the user has been away. + +### 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 bundles the same `share-video` skill, Mainframe MCP wiring, and stop hook as the +Cursor plugin. Codex asks you to review and trust the bundled hook before it runs. ## Included skill From 0f722a52a0cc9dadc82274b1d25d24cbb44d4741 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 19:14:54 +0000 Subject: [PATCH 03/14] Share the transcript summarization flow across hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-quality pass from the thermo-nuclear review. The file/text read, size-guard, and summary-shaping skeleton was copy-pasted in both hooks/cursor/transcript.ts and hooks/codex/transcript.ts, differing only in the row parser — duplication that a third host would copy again. Lift that skeleton into core `summarizeTranscript(text, parseRows)` and `summarizeTranscriptFile(path, parseRows)` parameterized by a named `TranscriptRowParser`. Each host now contributes only its parser behind a thin named binding, making "a host is a row parser" the explicit extension point. `readTranscriptText`, `MAX_TRANSCRIPT_BYTES`, and the summary shaping become private to core. Also simplify Codex `classifyCodexRow` to a plain string union instead of payload-free `{ kind }` objects. Behavior is unchanged. --- dist/hooks/codex/transcript.js | 32 ++++++++------------- dist/hooks/core/transcript.js | 35 +++++++++++++++-------- dist/hooks/cursor/transcript.js | 14 ++-------- hooks/codex/transcript.ts | 42 ++++++++++------------------ hooks/core/transcript.ts | 49 +++++++++++++++++++++++++-------- hooks/cursor/transcript.ts | 18 +++--------- 6 files changed, 93 insertions(+), 97 deletions(-) diff --git a/dist/hooks/codex/transcript.js b/dist/hooks/codex/transcript.js index 7d259d1..327b6c2 100644 --- a/dist/hooks/codex/transcript.js +++ b/dist/hooks/codex/transcript.js @@ -1,18 +1,10 @@ -import { hasMainframeVideoUrl, isNonEmptyString, parseTimestampMs, readTranscriptText, summaryFromParsed, } from "../core/transcript.js"; +import { hasMainframeVideoUrl, isNonEmptyString, parseTimestampMs, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; import { isJsonRecord } from "../core/json.js"; export function summarizeCodexTranscriptFile(path) { - const text = readTranscriptText(path); - if (text === null) { - return { kind: "unreadable" }; - } - return summarizeCodexTranscript(text); + return summarizeTranscriptFile(path, parseCodexRows); } export function summarizeCodexTranscript(text) { - const parsed = parseCodexRows(text); - if (parsed === "unreadable") { - return { kind: "unreadable" }; - } - return summaryFromParsed(parsed); + 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 @@ -40,12 +32,12 @@ function parseCodexRows(text) { if (!isJsonRecord(parsed)) { return "unreadable"; } - const row = classifyCodexRow(parsed); - if (row.kind === "session-meta") { + const kind = classifyCodexRow(parsed); + if (kind === "session-meta") { sawSessionMeta = true; continue; } - if (row.kind === "user") { + if (kind === "user") { sawUser = true; lastUserTimeMs = parseTimestampMs(parsed.timestamp); workHappened = false; @@ -53,7 +45,7 @@ function parseCodexRows(text) { continue; } if (sawUser) { - workHappened = workHappened || row.kind === "work"; + workHappened = workHappened || kind === "work"; alreadyShared = alreadyShared || hasMainframeVideoUrl(parsed); } } @@ -64,19 +56,19 @@ function parseCodexRows(text) { } function classifyCodexRow(record) { if (record.type === "session_meta") { - return { kind: "session-meta" }; + return "session-meta"; } const payload = record.payload; if (!isJsonRecord(payload)) { - return { kind: "other" }; + return "other"; } if (record.type === "event_msg" && payload.type === "user_message" && isNonEmptyString(payload.message)) { - return { kind: "user" }; + return "user"; } if (record.type === "response_item" && payload.type === "function_call") { - return { kind: "work" }; + return "work"; } - return { kind: "other" }; + return "other"; } diff --git a/dist/hooks/core/transcript.js b/dist/hooks/core/transcript.js index 576f121..c7d1687 100644 --- a/dist/hooks/core/transcript.js +++ b/dist/hooks/core/transcript.js @@ -1,25 +1,24 @@ import { lstatSync, readFileSync } from "node:fs"; import { isJsonRecord } from "./json.js"; -export const MAX_TRANSCRIPT_BYTES = 5 * 1024 * 1024; +const MAX_TRANSCRIPT_BYTES = 5 * 1024 * 1024; 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,9})?(?:Z|[+-]\d{2}:\d{2})$/; const MAINFRAME_VIDEO_URL_PREFIX = "https://mainframe.app/v/"; -export 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 summarizeTranscriptFile(path, parseRows) { + const text = readTranscriptText(path); + if (text === null) { + return { kind: "unreadable" }; } + return summarizeTranscript(text, parseRows); } -export function summaryFromParsed(parsed) { +export function summarizeTranscript(text, parseRows) { + const parsed = parseRows(text); + if (parsed === "unreadable") { + return { kind: "unreadable" }; + } if (!parsed.sawUser) { return { kind: "no-user" }; } @@ -33,6 +32,18 @@ export function summaryFromParsed(parsed) { alreadyShared: parsed.alreadyShared, }; } +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() !== ""; } diff --git a/dist/hooks/cursor/transcript.js b/dist/hooks/cursor/transcript.js index a41793d..a29fcdd 100644 --- a/dist/hooks/cursor/transcript.js +++ b/dist/hooks/cursor/transcript.js @@ -1,18 +1,10 @@ -import { hasMainframeVideoUrl, isNonEmptyString, parseTimestampMs, readTranscriptText, summaryFromParsed, } from "../core/transcript.js"; +import { hasMainframeVideoUrl, isNonEmptyString, parseTimestampMs, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; import { isJsonRecord } from "../core/json.js"; export function summarizeCursorTranscriptFile(path) { - const text = readTranscriptText(path); - if (text === null) { - return { kind: "unreadable" }; - } - return summarizeCursorTranscript(text); + return summarizeTranscriptFile(path, parseCursorRows); } export function summarizeCursorTranscript(text) { - const parsed = parseCursorRows(text); - if (parsed === "unreadable") { - return { kind: "unreadable" }; - } - return summaryFromParsed(parsed); + return summarizeTranscript(text, parseCursorRows); } function parseCursorRows(text) { let sawUser = false; diff --git a/hooks/codex/transcript.ts b/hooks/codex/transcript.ts index 8c99746..de86f5c 100644 --- a/hooks/codex/transcript.ts +++ b/hooks/codex/transcript.ts @@ -3,34 +3,20 @@ import { isNonEmptyString, type ParsedTranscript, parseTimestampMs, - readTranscriptText, - summaryFromParsed, + summarizeTranscript, + summarizeTranscriptFile, type TranscriptSummary, } from "../core/transcript.js"; import { isJsonRecord, type JsonRecord } from "../core/json.js"; -type CodexRowKind = - | { kind: "session-meta" } - | { kind: "user" } - | { kind: "work" } - | { kind: "other" }; +type CodexRowKind = "session-meta" | "user" | "work" | "other"; export function summarizeCodexTranscriptFile(path: string): TranscriptSummary { - const text = readTranscriptText(path); - if (text === null) { - return { kind: "unreadable" }; - } - - return summarizeCodexTranscript(text); + return summarizeTranscriptFile(path, parseCodexRows); } export function summarizeCodexTranscript(text: string): TranscriptSummary { - const parsed = parseCodexRows(text); - if (parsed === "unreadable") { - return { kind: "unreadable" }; - } - - return summaryFromParsed(parsed); + return summarizeTranscript(text, parseCodexRows); } // Codex rollout files are append-only JSONL where every line is @@ -61,13 +47,13 @@ function parseCodexRows(text: string): ParsedTranscript | "unreadable" { return "unreadable"; } - const row = classifyCodexRow(parsed); - if (row.kind === "session-meta") { + const kind = classifyCodexRow(parsed); + if (kind === "session-meta") { sawSessionMeta = true; continue; } - if (row.kind === "user") { + if (kind === "user") { sawUser = true; lastUserTimeMs = parseTimestampMs(parsed.timestamp); workHappened = false; @@ -76,7 +62,7 @@ function parseCodexRows(text: string): ParsedTranscript | "unreadable" { } if (sawUser) { - workHappened = workHappened || row.kind === "work"; + workHappened = workHappened || kind === "work"; alreadyShared = alreadyShared || hasMainframeVideoUrl(parsed); } } @@ -90,12 +76,12 @@ function parseCodexRows(text: string): ParsedTranscript | "unreadable" { function classifyCodexRow(record: JsonRecord): CodexRowKind { if (record.type === "session_meta") { - return { kind: "session-meta" }; + return "session-meta"; } const payload = record.payload; if (!isJsonRecord(payload)) { - return { kind: "other" }; + return "other"; } if ( @@ -103,12 +89,12 @@ function classifyCodexRow(record: JsonRecord): CodexRowKind { payload.type === "user_message" && isNonEmptyString(payload.message) ) { - return { kind: "user" }; + return "user"; } if (record.type === "response_item" && payload.type === "function_call") { - return { kind: "work" }; + return "work"; } - return { kind: "other" }; + return "other"; } diff --git a/hooks/core/transcript.ts b/hooks/core/transcript.ts index 69d84c9..be3a0e9 100644 --- a/hooks/core/transcript.ts +++ b/hooks/core/transcript.ts @@ -2,7 +2,7 @@ import { lstatSync, readFileSync } from "node:fs"; import { isJsonRecord } from "./json.js"; -export const MAX_TRANSCRIPT_BYTES = 5 * 1024 * 1024; +const MAX_TRANSCRIPT_BYTES = 5 * 1024 * 1024; const MIN_EPOCH_SECONDS = 946_684_800; const MAX_EPOCH_SECONDS = 4_102_444_800; const MIN_EPOCH_MS = MIN_EPOCH_SECONDS * 1000; @@ -29,20 +29,32 @@ export type ParsedTranscript = { alreadyShared: boolean; }; -export 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; +// 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 summaryFromParsed(parsed: ParsedTranscript): TranscriptSummary { +export function summarizeTranscript( + text: string, + parseRows: TranscriptRowParser, +): TranscriptSummary { + const parsed = parseRows(text); + if (parsed === "unreadable") { + return { kind: "unreadable" }; + } + if (!parsed.sawUser) { return { kind: "no-user" }; } @@ -59,6 +71,19 @@ export function summaryFromParsed(parsed: ParsedTranscript): TranscriptSummary { }; } +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() !== ""; } diff --git a/hooks/cursor/transcript.ts b/hooks/cursor/transcript.ts index 7493cad..d9326f4 100644 --- a/hooks/cursor/transcript.ts +++ b/hooks/cursor/transcript.ts @@ -3,8 +3,8 @@ import { isNonEmptyString, type ParsedTranscript, parseTimestampMs, - readTranscriptText, - summaryFromParsed, + summarizeTranscript, + summarizeTranscriptFile, type TranscriptSummary, } from "../core/transcript.js"; import { isJsonRecord, type JsonRecord } from "../core/json.js"; @@ -16,21 +16,11 @@ type CursorTranscriptRow = | { event: "user_message"; timestamp: unknown }; export function summarizeCursorTranscriptFile(path: string): TranscriptSummary { - const text = readTranscriptText(path); - if (text === null) { - return { kind: "unreadable" }; - } - - return summarizeCursorTranscript(text); + return summarizeTranscriptFile(path, parseCursorRows); } export function summarizeCursorTranscript(text: string): TranscriptSummary { - const parsed = parseCursorRows(text); - if (parsed === "unreadable") { - return { kind: "unreadable" }; - } - - return summaryFromParsed(parsed); + return summarizeTranscript(text, parseCursorRows); } function parseCursorRows(text: string): ParsedTranscript | "unreadable" { From 85782a8f66d5dd39e64295c0c2842df7603b59f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 19:37:43 +0000 Subject: [PATCH 04/14] Centralize untrusted-JSONL parsing and stop-hook I/O Acts on the second code-quality review (no blockers; DRY sharpening + test gaps). - Extract the JSONL "parse or fail closed" scaffold (split, trim, `JSON.parse`, object-check) into core `parseJsonlRecords`, so the untrusted-transcript -> trusted-rows guarantee lives in one chokepoint for every host. This also narrows Cursor's previously over-broad try/catch to the parse step only. - Add core `runStopHookCli(evaluate)` and make both host stop-evaluators pure (no stdin/stdout I/O), separating orchestration from policy. - Close two Codex test gaps: work state resets per user turn, and a stop-level "no work since last user message" negative case. Behavior unchanged; 68 tests pass and both compiled hooks were smoke-tested. --- dist/hooks/codex/stop-evaluator.js | 6 --- dist/hooks/codex/stop.js | 5 ++- dist/hooks/codex/transcript.js | 28 +++++-------- dist/hooks/core/json.js | 24 +++++++++++ dist/hooks/core/run-stop-hook.js | 8 ++++ dist/hooks/cursor/stop-evaluator.js | 6 --- dist/hooks/cursor/stop.js | 5 ++- dist/hooks/cursor/transcript.js | 61 ++++++++++++---------------- hooks/codex/stop-evaluator.ts | 8 ---- hooks/codex/stop.test.ts | 32 +++++++++++++++ hooks/codex/stop.ts | 5 ++- hooks/codex/transcript.test.ts | 18 +++++++++ hooks/codex/transcript.ts | 30 +++++--------- hooks/core/json.ts | 27 +++++++++++++ hooks/core/run-stop-hook.ts | 9 +++++ hooks/cursor/stop-evaluator.ts | 8 ---- hooks/cursor/stop.ts | 5 ++- hooks/cursor/transcript.ts | 63 +++++++++++++---------------- tooling/package-surface.ts | 1 + 19 files changed, 203 insertions(+), 146 deletions(-) create mode 100644 dist/hooks/core/run-stop-hook.js create mode 100644 hooks/core/run-stop-hook.ts diff --git a/dist/hooks/codex/stop-evaluator.js b/dist/hooks/codex/stop-evaluator.js index 30ec330..2776b7e 100644 --- a/dist/hooks/codex/stop-evaluator.js +++ b/dist/hooks/codex/stop-evaluator.js @@ -1,4 +1,3 @@ -import { readFileSync } from "node:fs"; import { decideStop } from "../core/stop-policy.js"; import { parseJsonRecord } from "../core/json.js"; import { summarizeCodexTranscriptFile } from "./transcript.js"; @@ -14,11 +13,6 @@ export function evaluateCodexStopHook(input) { } return { decision: "block", reason: decision.message }; } -export function runCodexStopHookCli() { - const stdin = readFileSync(0, "utf8"); - const output = evaluateCodexStopHook({ stdin }); - process.stdout.write(`${JSON.stringify(output)}\n`); -} function parseCodexStopInput(stdin) { const input = parseJsonRecord(stdin); if (input === null || input.hook_event_name !== "Stop") { diff --git a/dist/hooks/codex/stop.js b/dist/hooks/codex/stop.js index 882042b..4ac336f 100755 --- a/dist/hooks/codex/stop.js +++ b/dist/hooks/codex/stop.js @@ -1,3 +1,4 @@ #!/usr/bin/env node -import { runCodexStopHookCli } from "./stop-evaluator.js"; -runCodexStopHookCli(); +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 index 327b6c2..70a261f 100644 --- a/dist/hooks/codex/transcript.js +++ b/dist/hooks/codex/transcript.js @@ -1,5 +1,5 @@ import { hasMainframeVideoUrl, isNonEmptyString, parseTimestampMs, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; -import { isJsonRecord } from "../core/json.js"; +import { isJsonRecord, parseJsonlRecords } from "../core/json.js"; export function summarizeCodexTranscriptFile(path) { return summarizeTranscriptFile(path, parseCodexRows); } @@ -12,41 +12,31 @@ export function summarizeCodexTranscript(text) { // corruption (non-JSON or non-object rows) and the absence of a `session_meta` // row fail closed so the hook never fires on an untrusted transcript. 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; - 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"; - } - const kind = classifyCodexRow(parsed); + for (const record of records) { + const kind = classifyCodexRow(record); if (kind === "session-meta") { sawSessionMeta = true; continue; } if (kind === "user") { sawUser = true; - lastUserTimeMs = parseTimestampMs(parsed.timestamp); + lastUserTimeMs = parseTimestampMs(record.timestamp); workHappened = false; alreadyShared = false; continue; } if (sawUser) { workHappened = workHappened || kind === "work"; - alreadyShared = alreadyShared || hasMainframeVideoUrl(parsed); + alreadyShared = alreadyShared || hasMainframeVideoUrl(record); } } if (!sawSessionMeta) { 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/cursor/stop-evaluator.js b/dist/hooks/cursor/stop-evaluator.js index 20e6114..c7b0f19 100644 --- a/dist/hooks/cursor/stop-evaluator.js +++ b/dist/hooks/cursor/stop-evaluator.js @@ -1,4 +1,3 @@ -import { readFileSync } from "node:fs"; import { decideStop } from "../core/stop-policy.js"; import { parseJsonRecord } from "../core/json.js"; import { summarizeCursorTranscriptFile } from "./transcript.js"; @@ -14,11 +13,6 @@ export function evaluateCursorStopHook(input) { } 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 index a29fcdd..c1cec81 100644 --- a/dist/hooks/cursor/transcript.js +++ b/dist/hooks/cursor/transcript.js @@ -1,5 +1,5 @@ import { hasMainframeVideoUrl, isNonEmptyString, parseTimestampMs, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; -import { isJsonRecord } from "../core/json.js"; +import { parseJsonlRecords } from "../core/json.js"; export function summarizeCursorTranscriptFile(path) { return summarizeTranscriptFile(path, parseCursorRows); } @@ -7,50 +7,41 @@ 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 line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed === "") { - continue; + for (const record of records) { + const row = parseCursorTranscriptRow(record); + if (row === null) { + return "unreadable"; } - try { - const parsed = JSON.parse(trimmed); - if (!isJsonRecord(parsed)) { - return "unreadable"; - } - const row = parseCursorTranscriptRow(parsed); - if (row === null) { + if (row.event === "user_message") { + const userTimeMs = parseTimestampMs(row.timestamp); + if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { return "unreadable"; } - if (row.event === "user_message") { - const userTimeMs = parseTimestampMs(row.timestamp); - if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { - 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(parsed); + sawUser = true; + lastUserTimeMs = userTimeMs; + if (userTimeMs !== null) { + previousUserTimeMs = userTimeMs; } + workHappened = false; + alreadyShared = false; + continue; } - catch { - return "unreadable"; + 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 }; diff --git a/hooks/codex/stop-evaluator.ts b/hooks/codex/stop-evaluator.ts index 1a81112..527370b 100644 --- a/hooks/codex/stop-evaluator.ts +++ b/hooks/codex/stop-evaluator.ts @@ -1,5 +1,3 @@ -import { readFileSync } from "node:fs"; - import { decideStop } from "../core/stop-policy.js"; import { parseJsonRecord } from "../core/json.js"; import { summarizeCodexTranscriptFile } from "./transcript.js"; @@ -29,12 +27,6 @@ export function evaluateCodexStopHook(input: CodexStopEvaluationInput): CodexSto return { decision: "block", reason: decision.message }; } -export function runCodexStopHookCli(): void { - const stdin = readFileSync(0, "utf8"); - const output = evaluateCodexStopHook({ stdin }); - process.stdout.write(`${JSON.stringify(output)}\n`); -} - function parseCodexStopInput( stdin: string, ): { transcriptPath: string; stopHookActive: boolean } | null { diff --git a/hooks/codex/stop.test.ts b/hooks/codex/stop.test.ts index 5b1c3e2..cda4267 100644 --- a/hooks/codex/stop.test.ts +++ b/hooks/codex/stop.test.ts @@ -65,6 +65,38 @@ describe("Codex stop hook", () => { } }); + 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"); diff --git a/hooks/codex/stop.ts b/hooks/codex/stop.ts index a70b017..488a1b9 100644 --- a/hooks/codex/stop.ts +++ b/hooks/codex/stop.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node -import { runCodexStopHookCli } from "./stop-evaluator.js"; +import { runStopHookCli } from "../core/run-stop-hook.js"; +import { evaluateCodexStopHook } from "./stop-evaluator.js"; -runCodexStopHookCli(); +runStopHookCli(evaluateCodexStopHook); diff --git a/hooks/codex/transcript.test.ts b/hooks/codex/transcript.test.ts index 27fae73..a2dc047 100644 --- a/hooks/codex/transcript.test.ts +++ b/hooks/codex/transcript.test.ts @@ -27,6 +27,24 @@ describe("summarizeCodexTranscript", () => { }); }); + 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("ignores unrelated rollout event types", () => { const summary = summarizeCodexTranscript( codexRollout([ diff --git a/hooks/codex/transcript.ts b/hooks/codex/transcript.ts index de86f5c..54a7587 100644 --- a/hooks/codex/transcript.ts +++ b/hooks/codex/transcript.ts @@ -7,7 +7,7 @@ import { summarizeTranscriptFile, type TranscriptSummary, } from "../core/transcript.js"; -import { isJsonRecord, type JsonRecord } from "../core/json.js"; +import { isJsonRecord, type JsonRecord, parseJsonlRecords } from "../core/json.js"; type CodexRowKind = "session-meta" | "user" | "work" | "other"; @@ -25,29 +25,19 @@ export function summarizeCodexTranscript(text: string): TranscriptSummary { // corruption (non-JSON or non-object rows) and the absence of a `session_meta` // row fail closed so the hook never fires on an untrusted transcript. 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; - 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"; - } - - const kind = classifyCodexRow(parsed); + for (const record of records) { + const kind = classifyCodexRow(record); if (kind === "session-meta") { sawSessionMeta = true; continue; @@ -55,7 +45,7 @@ function parseCodexRows(text: string): ParsedTranscript | "unreadable" { if (kind === "user") { sawUser = true; - lastUserTimeMs = parseTimestampMs(parsed.timestamp); + lastUserTimeMs = parseTimestampMs(record.timestamp); workHappened = false; alreadyShared = false; continue; @@ -63,7 +53,7 @@ function parseCodexRows(text: string): ParsedTranscript | "unreadable" { if (sawUser) { workHappened = workHappened || kind === "work"; - alreadyShared = alreadyShared || hasMainframeVideoUrl(parsed); + alreadyShared = alreadyShared || hasMainframeVideoUrl(record); } } 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/cursor/stop-evaluator.ts b/hooks/cursor/stop-evaluator.ts index 965cf0c..08d9758 100644 --- a/hooks/cursor/stop-evaluator.ts +++ b/hooks/cursor/stop-evaluator.ts @@ -1,5 +1,3 @@ -import { readFileSync } from "node:fs"; - import { decideStop } from "../core/stop-policy.js"; import { parseJsonRecord } from "../core/json.js"; import { summarizeCursorTranscriptFile } from "./transcript.js"; @@ -26,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/cursor/transcript.ts b/hooks/cursor/transcript.ts index d9326f4..82294f4 100644 --- a/hooks/cursor/transcript.ts +++ b/hooks/cursor/transcript.ts @@ -7,7 +7,7 @@ import { summarizeTranscriptFile, type TranscriptSummary, } from "../core/transcript.js"; -import { isJsonRecord, type JsonRecord } from "../core/json.js"; +import { type JsonRecord, parseJsonlRecords } from "../core/json.js"; type CursorTranscriptRow = | { event: "assistant_message" } @@ -24,56 +24,47 @@ export function summarizeCursorTranscript(text: string): TranscriptSummary { } 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 line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed === "") { - continue; + for (const record of records) { + const row = parseCursorTranscriptRow(record); + if (row === null) { + return "unreadable"; } - try { - const parsed: unknown = JSON.parse(trimmed); - if (!isJsonRecord(parsed)) { + if (row.event === "user_message") { + const userTimeMs = parseTimestampMs(row.timestamp); + if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { return "unreadable"; } - const row = parseCursorTranscriptRow(parsed); - if (row === null) { - return "unreadable"; + sawUser = true; + lastUserTimeMs = userTimeMs; + if (userTimeMs !== null) { + previousUserTimeMs = userTimeMs; } + workHappened = false; + alreadyShared = false; + continue; + } - if (row.event === "user_message") { - const userTimeMs = parseTimestampMs(row.timestamp); - if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { - 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"; } - if (sawUser) { - const workTimeMs = readToolWorkTimeMs(row, lastUserTimeMs); - if (workTimeMs === "unreadable") { - return "unreadable"; - } - - workHappened = workHappened || workTimeMs !== null; - alreadyShared = alreadyShared || hasMainframeVideoUrl(parsed); - } - } catch { - return "unreadable"; + workHappened = workHappened || workTimeMs !== null; + alreadyShared = alreadyShared || hasMainframeVideoUrl(record); } } diff --git a/tooling/package-surface.ts b/tooling/package-surface.ts index b834c0f..da3e6ea 100644 --- a/tooling/package-surface.ts +++ b/tooling/package-surface.ts @@ -29,6 +29,7 @@ export const SHIPPED_FILES = [ "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", From 8678b8f8fd3c736f4e337203083d6e2e40c42b33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 19:56:33 +0000 Subject: [PATCH 05/14] Harden Codex transcript fail-closed coverage Follow-up to a code-quality review (APPROVE; no blockers). Add two Codex parser tests that lock guarantees the review flagged as untested: - a synthetic `response_item` user row injected after real work does not steal the turn (last user time and workHappened stay anchored to the real `event_msg/user_message`) - a latest-user timestamp outside the supported epoch range fails closed to `missing-user-time` (so the hook never fires on a garbage timestamp) Tests only; no behavior change. 70 tests pass. --- hooks/codex/transcript.test.ts | 38 ++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/hooks/codex/transcript.test.ts b/hooks/codex/transcript.test.ts index a2dc047..70d275b 100644 --- a/hooks/codex/transcript.test.ts +++ b/hooks/codex/transcript.test.ts @@ -182,6 +182,34 @@ describe("summarizeCodexTranscript", () => { 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([ @@ -194,6 +222,16 @@ describe("summarizeCodexTranscript", () => { 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")])), From 0add66683ae82eb88f8f28c264c846c7d1888c7c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 20:17:27 +0000 Subject: [PATCH 06/14] Fix Codex stop-hook work detection and user-time ordering Addresses two Bugbot findings on the Codex transcript parser: - Work detection only recognized `function_call`, so sessions whose work ran through other Responses-API tool calls (`local_shell_call`, `custom_tool_call`, `web_search_call`, ...) reported no work and never triggered the share suggestion. Match the whole `*_call` family instead. - User rows overwrote `lastUserTimeMs` without an ordering check, so a backwards timestamp could inflate the away duration and fire the hook spuriously. Fail closed on non-decreasing user timestamps, matching the Cursor parser and AGENTS.md's fail-closed rule. Adds tests for both. 72 tests pass. --- dist/hooks/codex/transcript.js | 27 ++++++++++++++++++++++----- hooks/codex/transcript.test.ts | 31 +++++++++++++++++++++++++++++++ hooks/codex/transcript.ts | 29 ++++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/dist/hooks/codex/transcript.js b/dist/hooks/codex/transcript.js index 70a261f..62e6b78 100644 --- a/dist/hooks/codex/transcript.js +++ b/dist/hooks/codex/transcript.js @@ -8,9 +8,10 @@ export function summarizeCodexTranscript(text) { } // 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. Structural -// corruption (non-JSON or non-object rows) and the absence of a `session_meta` -// row fail closed so the hook never fires on an untrusted transcript. +// 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") { @@ -21,6 +22,7 @@ function parseCodexRows(text) { 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") { @@ -28,8 +30,15 @@ function parseCodexRows(text) { continue; } if (kind === "user") { + const userTimeMs = parseTimestampMs(record.timestamp); + if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { + return "unreadable"; + } sawUser = true; - lastUserTimeMs = parseTimestampMs(record.timestamp); + lastUserTimeMs = userTimeMs; + if (userTimeMs !== null) { + previousUserTimeMs = userTimeMs; + } workHappened = false; alreadyShared = false; continue; @@ -57,8 +66,16 @@ function classifyCodexRow(record) { isNonEmptyString(payload.message)) { return "user"; } - if (record.type === "response_item" && payload.type === "function_call") { + 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/hooks/codex/transcript.test.ts b/hooks/codex/transcript.test.ts index 70d275b..7c0461e 100644 --- a/hooks/codex/transcript.test.ts +++ b/hooks/codex/transcript.test.ts @@ -45,6 +45,37 @@ describe("summarizeCodexTranscript", () => { }); }); + 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([ diff --git a/hooks/codex/transcript.ts b/hooks/codex/transcript.ts index 54a7587..828a4d8 100644 --- a/hooks/codex/transcript.ts +++ b/hooks/codex/transcript.ts @@ -21,9 +21,10 @@ export function summarizeCodexTranscript(text: string): TranscriptSummary { // 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. Structural -// corruption (non-JSON or non-object rows) and the absence of a `session_meta` -// row fail closed so the hook never fires on an untrusted transcript. +// 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") { @@ -35,6 +36,7 @@ function parseCodexRows(text: string): ParsedTranscript | "unreadable" { 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); @@ -44,8 +46,16 @@ function parseCodexRows(text: string): ParsedTranscript | "unreadable" { } if (kind === "user") { + const userTimeMs = parseTimestampMs(record.timestamp); + if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { + return "unreadable"; + } + sawUser = true; - lastUserTimeMs = parseTimestampMs(record.timestamp); + lastUserTimeMs = userTimeMs; + if (userTimeMs !== null) { + previousUserTimeMs = userTimeMs; + } workHappened = false; alreadyShared = false; continue; @@ -82,9 +92,18 @@ function classifyCodexRow(record: JsonRecord): CodexRowKind { return "user"; } - if (record.type === "response_item" && payload.type === "function_call") { + 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"); +} From 34f631f666342f877d65922366f89f6576963d43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 20:22:58 +0000 Subject: [PATCH 07/14] Single-source the user-timestamp ordering rule Self-review of the prior Bugbot fixes: the monotonic user-timestamp guard added to the Codex parser was a verbatim copy of the Cursor parser's block, so the same correctness-critical rule lived in two places. Extract it to core `nextUserTimeMs(rawTimestamp, previousUserTimeMs)` and call it from both parsers, leaving only the trivial per-host state assignments inline. One definition of "fail closed when a user timestamp moves backwards" for every host. Behavior unchanged; 72 tests pass. --- dist/hooks/codex/transcript.js | 6 +++--- dist/hooks/core/transcript.js | 12 ++++++++++++ dist/hooks/cursor/transcript.js | 6 +++--- hooks/codex/transcript.ts | 6 +++--- hooks/core/transcript.ts | 17 +++++++++++++++++ hooks/cursor/transcript.ts | 5 +++-- 6 files changed, 41 insertions(+), 11 deletions(-) diff --git a/dist/hooks/codex/transcript.js b/dist/hooks/codex/transcript.js index 62e6b78..ff9efc6 100644 --- a/dist/hooks/codex/transcript.js +++ b/dist/hooks/codex/transcript.js @@ -1,4 +1,4 @@ -import { hasMainframeVideoUrl, isNonEmptyString, parseTimestampMs, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; +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); @@ -30,8 +30,8 @@ function parseCodexRows(text) { continue; } if (kind === "user") { - const userTimeMs = parseTimestampMs(record.timestamp); - if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { + const userTimeMs = nextUserTimeMs(record.timestamp, previousUserTimeMs); + if (userTimeMs === "unreadable") { return "unreadable"; } sawUser = true; diff --git a/dist/hooks/core/transcript.js b/dist/hooks/core/transcript.js index c7d1687..305a4f8 100644 --- a/dist/hooks/core/transcript.js +++ b/dist/hooks/core/transcript.js @@ -47,6 +47,18 @@ function readTranscriptText(path) { 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); diff --git a/dist/hooks/cursor/transcript.js b/dist/hooks/cursor/transcript.js index c1cec81..2982327 100644 --- a/dist/hooks/cursor/transcript.js +++ b/dist/hooks/cursor/transcript.js @@ -1,4 +1,4 @@ -import { hasMainframeVideoUrl, isNonEmptyString, parseTimestampMs, summarizeTranscript, summarizeTranscriptFile, } from "../core/transcript.js"; +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); @@ -22,8 +22,8 @@ function parseCursorRows(text) { return "unreadable"; } if (row.event === "user_message") { - const userTimeMs = parseTimestampMs(row.timestamp); - if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { + const userTimeMs = nextUserTimeMs(row.timestamp, previousUserTimeMs); + if (userTimeMs === "unreadable") { return "unreadable"; } sawUser = true; diff --git a/hooks/codex/transcript.ts b/hooks/codex/transcript.ts index 828a4d8..7ad78de 100644 --- a/hooks/codex/transcript.ts +++ b/hooks/codex/transcript.ts @@ -1,8 +1,8 @@ import { hasMainframeVideoUrl, isNonEmptyString, + nextUserTimeMs, type ParsedTranscript, - parseTimestampMs, summarizeTranscript, summarizeTranscriptFile, type TranscriptSummary, @@ -46,8 +46,8 @@ function parseCodexRows(text: string): ParsedTranscript | "unreadable" { } if (kind === "user") { - const userTimeMs = parseTimestampMs(record.timestamp); - if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { + const userTimeMs = nextUserTimeMs(record.timestamp, previousUserTimeMs); + if (userTimeMs === "unreadable") { return "unreadable"; } diff --git a/hooks/core/transcript.ts b/hooks/core/transcript.ts index be3a0e9..2951d91 100644 --- a/hooks/core/transcript.ts +++ b/hooks/core/transcript.ts @@ -88,6 +88,23 @@ 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); diff --git a/hooks/cursor/transcript.ts b/hooks/cursor/transcript.ts index 82294f4..7754918 100644 --- a/hooks/cursor/transcript.ts +++ b/hooks/cursor/transcript.ts @@ -1,6 +1,7 @@ import { hasMainframeVideoUrl, isNonEmptyString, + nextUserTimeMs, type ParsedTranscript, parseTimestampMs, summarizeTranscript, @@ -42,8 +43,8 @@ function parseCursorRows(text: string): ParsedTranscript | "unreadable" { } if (row.event === "user_message") { - const userTimeMs = parseTimestampMs(row.timestamp); - if (previousUserTimeMs !== null && userTimeMs !== null && userTimeMs < previousUserTimeMs) { + const userTimeMs = nextUserTimeMs(row.timestamp, previousUserTimeMs); + if (userTimeMs === "unreadable") { return "unreadable"; } From 054a30ed5bd375f4f5b2869a1b6c1ab363e11cc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 20:38:11 +0000 Subject: [PATCH 08/14] Say "agent work" instead of "coding-agent work" Update the shared plugin description copy and regenerate the Cursor/Codex manifests and marketplace metadata. Tests assert the new literal. --- .codex-plugin/plugin.json | 4 ++-- .cursor-plugin/marketplace.json | 4 ++-- .cursor-plugin/plugin.json | 2 +- tooling/generate.ts | 2 +- tooling/manifest.test.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index b163bcb..9212c76 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-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 Mainframe video updates from agent work.", "author": { "name": "Mainframe", "email": "support@mainframe.app" @@ -15,7 +15,7 @@ "hooks": "./hooks/codex/hooks.json", "interface": { "displayName": "Mainframe", - "shortDescription": "Create and share short Mainframe video updates from coding-agent work.", + "shortDescription": "Create and share short Mainframe video updates from agent work.", "longDescription": "Mainframe adds a share-video skill, hosted MCP server wiring, and a stop hook that suggests a short video update after meaningful agent work when the user has been away.", "developerName": "Mainframe", "category": "Productivity", diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 370a4c8..e1d75b5 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 Mainframe 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 Mainframe video updates from agent work." } ] } diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 96bd6e5..1f22cde 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 Mainframe video updates from agent work.", "author": { "name": "Mainframe", "email": "support@mainframe.app" diff --git a/tooling/generate.ts b/tooling/generate.ts index b50a963..2dc8c6b 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -35,7 +35,7 @@ const metadata = MetadataSchema.parse({ 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 Mainframe video updates from agent work.", longDescription: "Mainframe adds a share-video skill, hosted MCP server wiring, and a stop hook that suggests a short video update after meaningful agent work when the user has been away.", category: "Productivity", diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index d0f0d5e..5d6b912 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -13,7 +13,7 @@ const AuthorSchema = z const RepositorySchema = z.literal("https://github.com/mainframecomputer/mainframe-plugins.git"); const DescriptionSchema = z.literal( - "Create and share short Mainframe video updates from coding-agent work.", + "Create and share short Mainframe video updates from agent work.", ); const LongDescriptionSchema = z.literal( From e98505e196e44d1b35868965b89539750fc381d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 20:55:08 +0000 Subject: [PATCH 09/14] Keep the README user-facing Drop implementation details (stop hook, MCP wiring, hook trust) from the Cursor and Codex install sections; describe what the plugin gives users. --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 406f1b7..8ec140f 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,8 @@ npx skills add mainframecomputer/mainframe-plugins --list ### Cursor -Install the Mainframe plugin from the Cursor marketplace. The plugin bundles the `share-video` -skill, Mainframe MCP wiring, and a stop hook that suggests a short video update after meaningful -agent work when the user has been away. +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 @@ -32,8 +31,7 @@ browser in the Codex CLI: codex plugin marketplace add mainframecomputer/mainframe-plugins ``` -The Codex plugin bundles the same `share-video` skill, Mainframe MCP wiring, and stop hook as the -Cursor plugin. Codex asks you to review and trust the bundled hook before it runs. +The Codex plugin gives Codex the same `share-video` skill and Mainframe tools as the Cursor plugin. ## Included skill From 59b24480cd027224575771ee11f17ca51c495439 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 21:07:39 +0000 Subject: [PATCH 10/14] Reframe plugin description around the problem Short description: drop the "Mainframe" brand name (it doesn't tell a new reader what the plugin does) -> "Create and share short video updates from agent work." Long description: replace the feature/implementation blurb with the value: agents now outproduce what anyone can review, so the agent sends you a short video of what it did, in your own voice and branding, shareable with your team. Regenerated manifests/marketplace; tests assert the new literals. --- .codex-plugin/plugin.json | 6 +++--- .cursor-plugin/marketplace.json | 4 ++-- .cursor-plugin/plugin.json | 2 +- tooling/generate.ts | 4 ++-- tooling/manifest.test.ts | 6 ++---- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 9212c76..21f3be6 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mainframe", "version": "0.1.0", - "description": "Create and share short Mainframe video updates from agent work.", + "description": "Create and share short video updates from agent work.", "author": { "name": "Mainframe", "email": "support@mainframe.app" @@ -15,8 +15,8 @@ "hooks": "./hooks/codex/hooks.json", "interface": { "displayName": "Mainframe", - "shortDescription": "Create and share short Mainframe video updates from agent work.", - "longDescription": "Mainframe adds a share-video skill, hosted MCP server wiring, and a stop hook that suggests a short video update after meaningful agent work when the user has been away.", + "shortDescription": "Create and share short video updates from agent work.", + "longDescription": "Agents now produce more work than anyone can review, so keeping up has become the bottleneck. When your agent finishes a task, it sends you a short video of what it did, recorded in your own voice and company branding and shareable with your team.", "developerName": "Mainframe", "category": "Productivity", "logo": "./assets/logo.png" diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index e1d75b5..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 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 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 1f22cde..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 agent work.", + "description": "Create and share short video updates from agent work.", "author": { "name": "Mainframe", "email": "support@mainframe.app" diff --git a/tooling/generate.ts b/tooling/generate.ts index 2dc8c6b..72caca6 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -35,9 +35,9 @@ const metadata = MetadataSchema.parse({ displayName: "Mainframe", packageName: "@mainframe/plugins", version: "0.1.0", - description: "Create and share short Mainframe video updates from agent work.", + description: "Create and share short video updates from agent work.", longDescription: - "Mainframe adds a share-video skill, hosted MCP server wiring, and a stop hook that suggests a short video update after meaningful agent work when the user has been away.", + "Agents now produce more work than anyone can review, so keeping up has become the bottleneck. When your agent finishes a task, it sends you a short video of what it did, recorded in your own voice and company branding and shareable with your team.", category: "Productivity", author: { name: "Mainframe", diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index 5d6b912..d5a4b30 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -12,12 +12,10 @@ const AuthorSchema = z const RepositorySchema = z.literal("https://github.com/mainframecomputer/mainframe-plugins.git"); -const DescriptionSchema = z.literal( - "Create and share short Mainframe video updates from agent work.", -); +const DescriptionSchema = z.literal("Create and share short video updates from agent work."); const LongDescriptionSchema = z.literal( - "Mainframe adds a share-video skill, hosted MCP server wiring, and a stop hook that suggests a short video update after meaningful agent work when the user has been away.", + "Agents now produce more work than anyone can review, so keeping up has become the bottleneck. When your agent finishes a task, it sends you a short video of what it did, recorded in your own voice and company branding and shareable with your team.", ); const KeywordsSchema = z.tuple([ From 38c2388bc98ac036d2d0445241eb444cbc4f7929 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 21:09:53 +0000 Subject: [PATCH 11/14] Make the long description action/benefit-led Lead with what you get out of it ("have your agents record short videos so you can stay up to date") rather than dwelling on the problem. --- .codex-plugin/plugin.json | 2 +- tooling/generate.ts | 2 +- tooling/manifest.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 21f3be6..2bc2467 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -16,7 +16,7 @@ "interface": { "displayName": "Mainframe", "shortDescription": "Create and share short video updates from agent work.", - "longDescription": "Agents now produce more work than anyone can review, so keeping up has become the bottleneck. When your agent finishes a task, it sends you a short video of what it did, recorded in your own voice and company branding and shareable with your team.", + "longDescription": "Have your agents record short videos of what they did so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", "developerName": "Mainframe", "category": "Productivity", "logo": "./assets/logo.png" diff --git a/tooling/generate.ts b/tooling/generate.ts index 72caca6..4e8d1eb 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -37,7 +37,7 @@ const metadata = MetadataSchema.parse({ version: "0.1.0", description: "Create and share short video updates from agent work.", longDescription: - "Agents now produce more work than anyone can review, so keeping up has become the bottleneck. When your agent finishes a task, it sends you a short video of what it did, recorded in your own voice and company branding and shareable with your team.", + "Have your agents record short videos of what they did so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", category: "Productivity", author: { name: "Mainframe", diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index d5a4b30..99129ef 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -15,7 +15,7 @@ const RepositorySchema = z.literal("https://github.com/mainframecomputer/mainfra const DescriptionSchema = z.literal("Create and share short video updates from agent work."); const LongDescriptionSchema = z.literal( - "Agents now produce more work than anyone can review, so keeping up has become the bottleneck. When your agent finishes a task, it sends you a short video of what it did, recorded in your own voice and company branding and shareable with your team.", + "Have your agents record short videos of what they did so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", ); const KeywordsSchema = z.tuple([ From df10ffc6560a3fc2d0078abcab31213cbb4da12b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 21:11:52 +0000 Subject: [PATCH 12/14] Smooth the long-description opener "Have your agents record..." read like an awkward imperative; use the plain declarative "Your agents record short videos of what they did, so you can..." --- .codex-plugin/plugin.json | 2 +- tooling/generate.ts | 2 +- tooling/manifest.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 2bc2467..eb88590 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -16,7 +16,7 @@ "interface": { "displayName": "Mainframe", "shortDescription": "Create and share short video updates from agent work.", - "longDescription": "Have your agents record short videos of what they did so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", + "longDescription": "Your agents record short videos of what they did, so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", "developerName": "Mainframe", "category": "Productivity", "logo": "./assets/logo.png" diff --git a/tooling/generate.ts b/tooling/generate.ts index 4e8d1eb..1e27469 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -37,7 +37,7 @@ const metadata = MetadataSchema.parse({ version: "0.1.0", description: "Create and share short video updates from agent work.", longDescription: - "Have your agents record short videos of what they did so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", + "Your agents record short videos of what they did, so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", category: "Productivity", author: { name: "Mainframe", diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index 99129ef..2a5e3ee 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -15,7 +15,7 @@ const RepositorySchema = z.literal("https://github.com/mainframecomputer/mainfra const DescriptionSchema = z.literal("Create and share short video updates from agent work."); const LongDescriptionSchema = z.literal( - "Have your agents record short videos of what they did so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", + "Your agents record short videos of what they did, so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", ); const KeywordsSchema = z.tuple([ From 088ee4a9f2a23a2451f8be0f7ee9a75abf974043 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 21:15:45 +0000 Subject: [PATCH 13/14] Reword long description from a council riff Anchor the opener to the delivery moment ("When your agent finishes a task, it sends you a short video of what it did...") and close on the benefit, replacing the flatter "Your agents record..." opener. --- .codex-plugin/plugin.json | 2 +- tooling/generate.ts | 2 +- tooling/manifest.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index eb88590..e6eb820 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -16,7 +16,7 @@ "interface": { "displayName": "Mainframe", "shortDescription": "Create and share short video updates from agent work.", - "longDescription": "Your agents record short videos of what they did, so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", + "longDescription": "When your agent finishes a task, it sends you a short video of what it did, in your own voice and company branding. Stay up to date at a glance, and share the recap with your team.", "developerName": "Mainframe", "category": "Productivity", "logo": "./assets/logo.png" diff --git a/tooling/generate.ts b/tooling/generate.ts index 1e27469..5175351 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -37,7 +37,7 @@ const metadata = MetadataSchema.parse({ version: "0.1.0", description: "Create and share short video updates from agent work.", longDescription: - "Your agents record short videos of what they did, so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", + "When your agent finishes a task, it sends you a short video of what it did, in your own voice and company branding. Stay up to date at a glance, and share the recap with your team.", category: "Productivity", author: { name: "Mainframe", diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index 2a5e3ee..01e9954 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -15,7 +15,7 @@ const RepositorySchema = z.literal("https://github.com/mainframecomputer/mainfra const DescriptionSchema = z.literal("Create and share short video updates from agent work."); const LongDescriptionSchema = z.literal( - "Your agents record short videos of what they did, so you can stay up to date on their work at a glance. Each recap uses your own voice and company branding, and is shareable with your team.", + "When your agent finishes a task, it sends you a short video of what it did, in your own voice and company branding. Stay up to date at a glance, and share the recap with your team.", ); const KeywordsSchema = z.tuple([ From 1d03162b976c94de2b98a0a7b06a0a58881d5e37 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 21:16:49 +0000 Subject: [PATCH 14/14] Use the council's "watch instead of read" long description Lead with the payoff ("watch what your agents did instead of reading through all of it") and keep the voice/branding/glance/share specifics. --- .codex-plugin/plugin.json | 2 +- tooling/generate.ts | 2 +- tooling/manifest.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index e6eb820..040b345 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -16,7 +16,7 @@ "interface": { "displayName": "Mainframe", "shortDescription": "Create and share short video updates from agent work.", - "longDescription": "When your agent finishes a task, it sends you a short video of what it did, in your own voice and company branding. Stay up to date at a glance, and share the recap with your team.", + "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/tooling/generate.ts b/tooling/generate.ts index 5175351..95e9bf3 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -37,7 +37,7 @@ const metadata = MetadataSchema.parse({ version: "0.1.0", description: "Create and share short video updates from agent work.", longDescription: - "When your agent finishes a task, it sends you a short video of what it did, in your own voice and company branding. Stay up to date at a glance, and share the recap with your team.", + "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", diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index 01e9954..0db7069 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -15,7 +15,7 @@ const RepositorySchema = z.literal("https://github.com/mainframecomputer/mainfra const DescriptionSchema = z.literal("Create and share short video updates from agent work."); const LongDescriptionSchema = z.literal( - "When your agent finishes a task, it sends you a short video of what it did, in your own voice and company branding. Stay up to date at a glance, and share the recap with your team.", + "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([