From 597cc36f4fcf75877aa0b9dc912104d915c468c1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 21:24:56 +0000 Subject: [PATCH 01/10] Add ClawHub publishing for the share-video skill Reuse ClawHub's official skill-publish reusable workflow (pinned to v0.19.0) for a dry-run on PRs and a gated workflow_dispatch publish, declare the skill's ClawHub homepage metadata, and document the install/publish path in the README. No parallel ClawHub manifest is hand-maintained: the registry consumes the canonical skills/share-video folder directly. --- .github/workflows/clawhub-publish.yml | 35 +++++++++++++++++++++++++++ README.md | 15 ++++++++++++ skills/share-video/SKILL.md | 3 +++ 3 files changed, 53 insertions(+) create mode 100644 .github/workflows/clawhub-publish.yml diff --git a/.github/workflows/clawhub-publish.yml b/.github/workflows/clawhub-publish.yml new file mode 100644 index 0000000..86968ae --- /dev/null +++ b/.github/workflows/clawhub-publish.yml @@ -0,0 +1,35 @@ +name: ClawHub publish + +on: + pull_request: + workflow_dispatch: + inputs: + owner: + description: ClawHub owner or publisher handle. Leave empty to publish as the authenticated user. + type: string + required: false + default: "" + +permissions: {} + +jobs: + dry-run: + if: github.event_name == 'pull_request' + permissions: + contents: read + id-token: write + uses: openclaw/clawhub/.github/workflows/skill-publish.yml@v0.19.0 + with: + dry_run: true + + publish: + if: github.event_name == 'workflow_dispatch' + permissions: + contents: read + id-token: write + uses: openclaw/clawhub/.github/workflows/skill-publish.yml@v0.19.0 + with: + owner: ${{ inputs.owner }} + dry_run: false + secrets: + clawhub_token: ${{ secrets.CLAWHUB_TOKEN }} diff --git a/README.md b/README.md index c99e9ac..972bcd0 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,21 @@ claude The Claude Code plugin gives Claude the same `share-video` skill and Mainframe tools as the Cursor and Codex plugins. +### ClawHub (OpenClaw) + +[ClawHub](https://clawhub.ai) is the public skill registry for OpenClaw. The same canonical +`share-video` skill is published there, so any OpenClaw agent can install it with the `clawhub` CLI: + +```sh +clawhub install share-video +``` + +Publishing is automated by [`.github/workflows/clawhub-publish.yml`](.github/workflows/clawhub-publish.yml), +which reuses ClawHub's official `skill-publish` reusable workflow instead of duplicating publish +logic. Pull requests run a dry-run preview, and a manual `workflow_dispatch` run performs the real +publish. A real publish needs a `CLAWHUB_TOKEN` repository secret and an `owner` handle; publishing +to ClawHub releases the skill under `MIT-0`. + ## Included skill - `share-video` — share a short video that explains what the agent did, useful for demos, diff --git a/skills/share-video/SKILL.md b/skills/share-video/SKILL.md index 3d2ccdc..6c9bcdc 100644 --- a/skills/share-video/SKILL.md +++ b/skills/share-video/SKILL.md @@ -7,6 +7,9 @@ description: | for trivial answers, active back-and-forth, unfinished work, or sensitive data. author: Mainframe +metadata: + openclaw: + homepage: https://mainframe.app --- # Share video From 62626349e1f7014be9ba69c795c23ff4c4463b4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 21:37:54 +0000 Subject: [PATCH 02/10] Refine ClawHub skill publishing after model-council review - Clarify in the README that ClawHub is a publish target for the existing canonical skill, not a new supported host. - Document that a ClawHub install ships skill instructions only; the video tools come from the hosted Mainframe MCP server, which users wire up separately. - Guard the PR dry-run job to same-repo pull requests so fork PRs (which lack id-token: write for the reusable workflow's OIDC step) do not fail CI. --- .github/workflows/clawhub-publish.yml | 2 +- README.md | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/clawhub-publish.yml b/.github/workflows/clawhub-publish.yml index 86968ae..50c7a7f 100644 --- a/.github/workflows/clawhub-publish.yml +++ b/.github/workflows/clawhub-publish.yml @@ -14,7 +14,7 @@ permissions: {} jobs: dry-run: - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository permissions: contents: read id-token: write diff --git a/README.md b/README.md index 972bcd0..8d5f07c 100644 --- a/README.md +++ b/README.md @@ -49,13 +49,20 @@ and Codex plugins. ### ClawHub (OpenClaw) -[ClawHub](https://clawhub.ai) is the public skill registry for OpenClaw. The same canonical -`share-video` skill is published there, so any OpenClaw agent can install it with the `clawhub` CLI: +[ClawHub](https://clawhub.ai) is the public skill registry for OpenClaw. It is a publishing target +for the existing canonical `share-video` skill, not a new supported host: the same skill folder the +Cursor, Codex, and Claude Code plugins ship is published there, so any OpenClaw agent can install it +with the `clawhub` CLI: ```sh clawhub install share-video ``` +A ClawHub install delivers the skill instructions only. The `generate_video`, `upload_video`, and +`get_video` tools come from the hosted Mainframe MCP server (`https://mcp.mainframe.app/mcp`, which +authenticates on install), so OpenClaw users wire that server up separately for the skill's tools to +work. + Publishing is automated by [`.github/workflows/clawhub-publish.yml`](.github/workflows/clawhub-publish.yml), which reuses ClawHub's official `skill-publish` reusable workflow instead of duplicating publish logic. Pull requests run a dry-run preview, and a manual `workflow_dispatch` run performs the real From d6ea1dfa6faddaab4f0bfeb81e9ec6a3a0d3faca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 22:02:25 +0000 Subject: [PATCH 03/10] Add OpenClaw plugin with a before_agent_finalize stop hook Make OpenClaw a first-class host alongside Cursor, Codex, and Claude Code, generated from the single tooling/generate.ts source: - hooks/openclaw/runtime.ts + register.ts implement a native OpenClaw plugin entry that maps the shared hooks/core AFK gate onto the documented before_agent_finalize lifecycle hook (agent_turn_prepare marks turn start, after_tool_call records work, finalize asks for one revise pass that leaves a Mainframe video). It reuses evaluateAfkGate and hasMainframeVideoUrl and fails closed, and models the host contract with local types instead of depending on the 80MB openclaw SDK, matching the other hosts. - generate.ts emits openclaw.plugin.json and the package.json openclaw block (extensions + compat.pluginApi + build.openclawVersion) that ClawHub package publishing requires; drift check, package surface, release archive, and the manifest test cover the new surface. - clawhub-publish.yml also dry-runs and publishes the plugin package via ClawHub's reusable package-publish workflow. - README documents the openclaw plugins install flow and the openclaw.json MCP + allowConversationAccess config; AGENTS.md adds OpenClaw as a supported host. Validated package metadata with a real clawhub package publish --dry-run. --- .github/workflows/clawhub-publish.yml | 27 +++++- AGENTS.md | 13 +-- README.md | 55 +++++++++---- dist/hooks/openclaw/register.js | 11 +++ dist/hooks/openclaw/runtime.js | 38 +++++++++ hooks/openclaw/register.test.ts | 113 ++++++++++++++++++++++++++ hooks/openclaw/register.ts | 24 ++++++ hooks/openclaw/runtime.ts | 93 +++++++++++++++++++++ openclaw.plugin.json | 29 +++++++ package.json | 18 +++- tooling/generate.ts | 38 +++++++++ tooling/generated-drift-check.ts | 1 + tooling/manifest.test.ts | 26 ++++++ tooling/package-surface.ts | 4 + tooling/release-archive.ts | 16 ++++ 15 files changed, 479 insertions(+), 27 deletions(-) create mode 100644 dist/hooks/openclaw/register.js create mode 100644 dist/hooks/openclaw/runtime.js create mode 100644 hooks/openclaw/register.test.ts create mode 100644 hooks/openclaw/register.ts create mode 100644 hooks/openclaw/runtime.ts create mode 100644 openclaw.plugin.json diff --git a/.github/workflows/clawhub-publish.yml b/.github/workflows/clawhub-publish.yml index 50c7a7f..fab4784 100644 --- a/.github/workflows/clawhub-publish.yml +++ b/.github/workflows/clawhub-publish.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: owner: - description: ClawHub owner or publisher handle. Leave empty to publish as the authenticated user. + description: ClawHub owner or publisher handle for the skill and package. Leave empty to publish as the authenticated user. type: string required: false default: "" @@ -13,7 +13,7 @@ on: permissions: {} jobs: - dry-run: + skill-dry-run: if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository permissions: contents: read @@ -22,7 +22,16 @@ jobs: with: dry_run: true - publish: + package-dry-run: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: read + id-token: write + uses: openclaw/clawhub/.github/workflows/package-publish.yml@v0.19.0 + with: + dry_run: true + + skill-publish: if: github.event_name == 'workflow_dispatch' permissions: contents: read @@ -33,3 +42,15 @@ jobs: dry_run: false secrets: clawhub_token: ${{ secrets.CLAWHUB_TOKEN }} + + package-publish: + if: github.event_name == 'workflow_dispatch' + permissions: + contents: read + id-token: write + uses: openclaw/clawhub/.github/workflows/package-publish.yml@v0.19.0 + with: + owner: ${{ inputs.owner }} + dry_run: false + secrets: + clawhub_token: ${{ secrets.CLAWHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index e1ba6cb..62e593e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,18 +1,19 @@ # Agent notes -This repository packages the Mainframe Cursor, Codex, and Claude Code plugins. Keep it focused on -the Cursor, Codex, and Claude Code manifests, hosted MCP wiring, the `share-video` skill, and the -stop hooks. +This repository packages the Mainframe Cursor, Codex, Claude Code, and OpenClaw plugins. Keep it +focused on the Cursor, Codex, Claude Code, and OpenClaw manifests, hosted MCP wiring, the +`share-video` skill, and the stop hooks. ## Repository boundaries - User-visible copy should say "Mainframe", not legacy product names. - Do not add secrets, customer data, private URLs, or private business context. -- Cursor, Codex, and Claude Code are the supported hosts. All three plugins share the repo root, +- Cursor, Codex, Claude Code, and OpenClaw are the supported hosts. All plugins share the repo root, the `share-video` skill, the `./.mcp.json` wiring, and the `hooks/core` runtime. Codex and Claude Code share the same Stop hook contract, so they both use `hooks/core/stop-hook.ts`; only the - transcript parser differs per host. Do not add other host surfaces unless the product task - explicitly asks for them. + transcript parser differs per host. OpenClaw is a native plugin host instead: it loads + `hooks/openclaw/register.ts` and maps the shared AFK gate onto the `before_agent_finalize` + lifecycle hook. Do not add other host surfaces unless the product task explicitly asks for them. - Run `bun run verify` before considering changes ready. - Generated Cursor, Codex, and Claude Code manifest and marketplace files come from `tooling/generate.ts`; edit the config there, then run `bun run generate`. diff --git a/README.md b/README.md index 8d5f07c..123e491 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Mainframe plugins Mainframe is the video sharing platform for agents. This repository ships the `share-video` skill -and Mainframe plugins for Cursor, Codex, and Claude Code. +and Mainframe plugins for Cursor, Codex, Claude Code, and OpenClaw. ## Install @@ -47,27 +47,52 @@ claude The Claude Code plugin gives Claude the same `share-video` skill and Mainframe tools as the Cursor and Codex plugins. -### ClawHub (OpenClaw) +### OpenClaw -[ClawHub](https://clawhub.ai) is the public skill registry for OpenClaw. It is a publishing target -for the existing canonical `share-video` skill, not a new supported host: the same skill folder the -Cursor, Codex, and Claude Code plugins ship is published there, so any OpenClaw agent can install it -with the `clawhub` CLI: +Install the Mainframe plugin from [ClawHub](https://clawhub.ai), the public skill and plugin +registry for OpenClaw, with the OpenClaw plugin manager: ```sh -clawhub install share-video +openclaw plugins install clawhub:mainframe +``` + +The plugin gives OpenClaw the `share-video` skill, the hosted Mainframe MCP server, and a native +`before_agent_finalize` hook that suggests a short video after a long, unattended run — the same +conservative AFK behavior as the other hosts' stop hooks, reusing the shared `hooks/core` runtime. +Because native OpenClaw plugins cannot register an MCP server for you and conversation hooks need +explicit access, add this to your `openclaw.json`: + +```json +{ + "mcp": { + "servers": { + "mainframe": { "type": "http", "url": "https://mcp.mainframe.app/mcp" } + } + }, + "plugins": { + "entries": { + "mainframe": { "hooks": { "allowConversationAccess": true } } + } + } +} ``` -A ClawHub install delivers the skill instructions only. The `generate_video`, `upload_video`, and -`get_video` tools come from the hosted Mainframe MCP server (`https://mcp.mainframe.app/mcp`, which -authenticates on install), so OpenClaw users wire that server up separately for the skill's tools to -work. +#### Publishing to ClawHub + +The canonical `share-video` skill is also published to ClawHub on its own, so any agent can install +just the skill (its `generate_video`, `upload_video`, and `get_video` tools come from the hosted +Mainframe MCP server, wired up separately): + +```sh +clawhub install share-video +``` Publishing is automated by [`.github/workflows/clawhub-publish.yml`](.github/workflows/clawhub-publish.yml), -which reuses ClawHub's official `skill-publish` reusable workflow instead of duplicating publish -logic. Pull requests run a dry-run preview, and a manual `workflow_dispatch` run performs the real -publish. A real publish needs a `CLAWHUB_TOKEN` repository secret and an `owner` handle; publishing -to ClawHub releases the skill under `MIT-0`. +which reuses ClawHub's official `skill-publish` and `package-publish` reusable workflows instead of +duplicating publish logic. Pull requests run dry-run previews, and a manual `workflow_dispatch` run +publishes both the skill and the plugin package. A real publish needs a `CLAWHUB_TOKEN` repository +secret and an `owner` handle (the package scope `@mainframe` must match that owner); publishing the +skill to ClawHub releases it under `MIT-0`. ## Included skill diff --git a/dist/hooks/openclaw/register.js b/dist/hooks/openclaw/register.js new file mode 100644 index 0000000..c68987a --- /dev/null +++ b/dist/hooks/openclaw/register.js @@ -0,0 +1,11 @@ +import { registerMainframeHooks } from "./runtime.js"; +const plugin = { + id: "mainframe", + name: "Mainframe", + description: "Create and share short video updates from agent work.", + register(api) { + registerMainframeHooks(api); + }, +}; +export default plugin; +export { registerMainframeHooks }; diff --git a/dist/hooks/openclaw/runtime.js b/dist/hooks/openclaw/runtime.js new file mode 100644 index 0000000..d465cd4 --- /dev/null +++ b/dist/hooks/openclaw/runtime.js @@ -0,0 +1,38 @@ +import { evaluateAfkGate } from "../core/afk-gate.js"; +import { hasMainframeVideoUrl } from "../core/transcript.js"; +export function createMainframeFinalizeTracker(options = {}) { + const nowMs = options.nowMs ?? (() => Date.now()); + let state = { kind: "idle" }; + return { + onTurnPrepare() { + state = { kind: "tracking", turnStartMs: nowMs(), workHappened: false }; + }, + onToolCall() { + if (state.kind === "tracking") { + state = { kind: "tracking", turnStartMs: state.turnStartMs, workHappened: true }; + } + }, + onFinalize(event) { + if (event.stopHookActive || state.kind === "idle") { + return undefined; + } + const gate = evaluateAfkGate({ + stopTimeMs: nowMs(), + lastUserTimeMs: state.turnStartMs, + workHappened: state.workHappened, + alreadyShared: alreadySharedFromEvent(event), + }); + return gate.fire ? { action: "revise", reason: gate.reason } : undefined; + }, + }; +} +export function registerMainframeHooks(api, options = {}) { + const tracker = createMainframeFinalizeTracker(options); + api.on("agent_turn_prepare", tracker.onTurnPrepare); + api.on("after_tool_call", tracker.onToolCall); + api.on("before_agent_finalize", tracker.onFinalize); + return tracker; +} +function alreadySharedFromEvent(event) { + return hasMainframeVideoUrl(event.lastAssistantMessage) || hasMainframeVideoUrl(event.messages); +} diff --git a/hooks/openclaw/register.test.ts b/hooks/openclaw/register.test.ts new file mode 100644 index 0000000..7c1d541 --- /dev/null +++ b/hooks/openclaw/register.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import plugin, { registerMainframeHooks } from "./register.js"; +import { + createMainframeFinalizeTracker, + type OpenClawFinalizeEvent, + type OpenClawPluginApi, +} from "./runtime.js"; + +const userTimeMs = Date.parse("2026-05-08T13:00:00.000Z"); +const awayTimeMs = Date.parse("2026-05-08T15:30:00.000Z"); +const sharedVideoUrl = "https://mainframe.app/v/37507089004e8f3700deb918a48b2556"; + +function trackerAt(times: readonly number[]): ReturnType { + let index = 0; + return createMainframeFinalizeTracker({ + nowMs: () => times[Math.min(index++, times.length - 1)] ?? awayTimeMs, + }); +} + +describe("OpenClaw before_agent_finalize hook", () => { + it("asks for a revise after tool work once the user is away past the threshold", () => { + const tracker = trackerAt([userTimeMs, awayTimeMs]); + tracker.onTurnPrepare(); + tracker.onToolCall(); + + const result = tracker.onFinalize({ stopHookActive: false }); + + expect(result?.action).toBe("revise"); + expect(result?.reason).toContain("2.5 hours"); + expect(result?.reason).toContain("share-video"); + }); + + it("does not fire before the fixed one-hour threshold", () => { + const tracker = trackerAt([userTimeMs, Date.parse("2026-05-08T13:30:00.000Z")]); + tracker.onTurnPrepare(); + tracker.onToolCall(); + + expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); + }); + + it("does not fire when no tool work happened since the turn began", () => { + const tracker = trackerAt([userTimeMs, awayTimeMs]); + tracker.onTurnPrepare(); + + expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); + }); + + it("does not fire when finalize arrives before any turn began", () => { + const tracker = trackerAt([awayTimeMs]); + + expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); + }); + + it("does not fire after a continuation already re-prompted the agent", () => { + const tracker = trackerAt([userTimeMs, awayTimeMs]); + tracker.onTurnPrepare(); + tracker.onToolCall(); + + expect(tracker.onFinalize({ stopHookActive: true })).toBeUndefined(); + }); + + it("does not fire after a Mainframe video URL already appears in the turn", () => { + for (const event of sharedEvents()) { + const tracker = trackerAt([userTimeMs, awayTimeMs]); + tracker.onTurnPrepare(); + tracker.onToolCall(); + + expect(tracker.onFinalize(event)).toBeUndefined(); + } + }); + + it("derives its suggestion from elapsed time only and never echoes turn content", () => { + const tracker = trackerAt([userTimeMs, awayTimeMs]); + tracker.onTurnPrepare(); + tracker.onToolCall(); + + const result = tracker.onFinalize({ + stopHookActive: false, + lastAssistantMessage: "SECRET_NEVER_LEAK", + messages: [{ text: "ANOTHER_SECRET" }], + }); + + expect(result?.reason).not.toContain("SECRET_NEVER_LEAK"); + expect(result?.reason).not.toContain("ANOTHER_SECRET"); + }); + + it("exports a default plugin entry that registers the OpenClaw hook names in order", () => { + expect(plugin).toMatchObject({ + id: "mainframe", + name: "Mainframe", + description: "Create and share short video updates from agent work.", + }); + expect(typeof plugin.register).toBe("function"); + + const names: string[] = []; + const api: OpenClawPluginApi = { + on(hookName: string): void { + names.push(hookName); + }, + }; + registerMainframeHooks(api); + + expect(names).toEqual(["agent_turn_prepare", "after_tool_call", "before_agent_finalize"]); + }); +}); + +function sharedEvents(): OpenClawFinalizeEvent[] { + return [ + { stopHookActive: false, lastAssistantMessage: `Shared: ${sharedVideoUrl}` }, + { stopHookActive: false, messages: [{ output: `Shared: ${sharedVideoUrl}` }] }, + ]; +} diff --git a/hooks/openclaw/register.ts b/hooks/openclaw/register.ts new file mode 100644 index 0000000..31f3475 --- /dev/null +++ b/hooks/openclaw/register.ts @@ -0,0 +1,24 @@ +import { type OpenClawPluginApi, registerMainframeHooks } from "./runtime.js"; + +// OpenClaw loads this default export and calls `register(api)` once. We model +// the plugin entry shape locally rather than importing the openclaw SDK just +// for its `definePluginEntry` identity helper, keeping this package free of a +// host dependency like the other hosts. +export type OpenClawPluginEntry = { + id: string; + name: string; + description: string; + register: (api: OpenClawPluginApi) => void; +}; + +const plugin: OpenClawPluginEntry = { + id: "mainframe", + name: "Mainframe", + description: "Create and share short video updates from agent work.", + register(api: OpenClawPluginApi): void { + registerMainframeHooks(api); + }, +}; + +export default plugin; +export { registerMainframeHooks }; diff --git a/hooks/openclaw/runtime.ts b/hooks/openclaw/runtime.ts new file mode 100644 index 0000000..fa46143 --- /dev/null +++ b/hooks/openclaw/runtime.ts @@ -0,0 +1,93 @@ +import { evaluateAfkGate } from "../core/afk-gate.js"; +import { hasMainframeVideoUrl } from "../core/transcript.js"; + +// Minimal local mirror of the OpenClaw plugin hook surface this plugin uses, +// per https://docs.openclaw.ai/plugins/hooks (openclaw 2026.6.1). Like the +// Cursor, Codex, and Claude Code hooks, this models the host contract locally +// instead of depending on a host SDK. +export type OpenClawFinalizeEvent = { + stopHookActive: boolean; + lastAssistantMessage?: string; + messages?: unknown[]; +}; + +export type OpenClawReviseResult = { action: "revise"; reason: string }; + +// OpenClaw exposes a final-answer review gate (`before_agent_finalize`) instead +// of the stdin/stdout Stop hook the other hosts use, and its event carries no +// per-message wall-clock time. Elapsed time is therefore measured across the +// turn: `agent_turn_prepare` marks the start, `after_tool_call` records that +// work happened, and `before_agent_finalize` reuses the shared AFK gate to ask +// for one more pass that leaves a Mainframe video. The tracker fails closed: if +// a turn never starts or no tool work is seen, it never suggests. +export type OpenClawPluginApi = { + on(hookName: "agent_turn_prepare", handler: () => void): void; + on(hookName: "after_tool_call", handler: () => void): void; + on( + hookName: "before_agent_finalize", + handler: (event: OpenClawFinalizeEvent) => OpenClawReviseResult | undefined, + ): void; +}; + +export type MainframeFinalizeTracker = { + onTurnPrepare: () => void; + onToolCall: () => void; + onFinalize: (event: OpenClawFinalizeEvent) => OpenClawReviseResult | undefined; +}; + +type TrackerState = + | { kind: "idle" } + | { kind: "tracking"; turnStartMs: number; workHappened: boolean }; + +export type MainframeTrackerOptions = { + nowMs?: () => number; +}; + +export function createMainframeFinalizeTracker( + options: MainframeTrackerOptions = {}, +): MainframeFinalizeTracker { + const nowMs = options.nowMs ?? (() => Date.now()); + let state: TrackerState = { kind: "idle" }; + + return { + onTurnPrepare(): void { + state = { kind: "tracking", turnStartMs: nowMs(), workHappened: false }; + }, + + onToolCall(): void { + if (state.kind === "tracking") { + state = { kind: "tracking", turnStartMs: state.turnStartMs, workHappened: true }; + } + }, + + onFinalize(event: OpenClawFinalizeEvent): OpenClawReviseResult | undefined { + if (event.stopHookActive || state.kind === "idle") { + return undefined; + } + + const gate = evaluateAfkGate({ + stopTimeMs: nowMs(), + lastUserTimeMs: state.turnStartMs, + workHappened: state.workHappened, + alreadyShared: alreadySharedFromEvent(event), + }); + + return gate.fire ? { action: "revise", reason: gate.reason } : undefined; + }, + }; +} + +export function registerMainframeHooks( + api: OpenClawPluginApi, + options: MainframeTrackerOptions = {}, +): MainframeFinalizeTracker { + const tracker = createMainframeFinalizeTracker(options); + api.on("agent_turn_prepare", tracker.onTurnPrepare); + api.on("after_tool_call", tracker.onToolCall); + api.on("before_agent_finalize", tracker.onFinalize); + return tracker; +} + +function alreadySharedFromEvent(event: OpenClawFinalizeEvent): boolean { + return hasMainframeVideoUrl(event.lastAssistantMessage) || hasMainframeVideoUrl(event.messages); +} diff --git a/openclaw.plugin.json b/openclaw.plugin.json new file mode 100644 index 0000000..060621b --- /dev/null +++ b/openclaw.plugin.json @@ -0,0 +1,29 @@ +{ + "name": "mainframe", + "version": "0.1.0", + "description": "Create and share short video updates from agent work.", + "author": { + "name": "Mainframe", + "email": "support@mainframe.app" + }, + "homepage": "https://mainframe.app", + "repository": "https://github.com/mainframecomputer/mainframe-plugins.git", + "license": "UNLICENSED", + "keywords": ["mainframe", "agent-skills", "mcp", "hooks", "video"], + "displayName": "Mainframe", + "longDescription": "Turn agent work into videos your team can keep up with. Agents can generate a short video or upload one they recorded themselves, narrated in your voice and styled with your brand. Every video becomes a link your team can watch.", + "category": "Productivity", + "logo": "assets/logo.png", + "skills": "./skills", + "mcpServers": "./.mcp.json", + "host": "openclaw", + "entrypoint": "./dist/hooks/openclaw/register.js", + "capabilities": ["skills", "mcp", "hooks"], + "callbacks": ["agent_turn_prepare", "after_tool_call", "before_agent_finalize"], + "compat": { + "pluginApi": ">=2026.6.1" + }, + "build": { + "openclawVersion": "2026.6.1" + } +} diff --git a/package.json b/package.json index 9e84996..99706cb 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ ".codex-plugin", ".cursor-plugin", ".mcp.json", + "openclaw.plugin.json", "LICENSE", "README.md", "assets/logo.png", @@ -41,9 +42,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "build": "rm -rf dist && tsc -p tsconfig.build.json && bun tooling/mark-executable.ts", "build:check": "bun run build && git diff --exit-code -- dist", - "generate": "bun tooling/generate.ts && oxfmt --write package.json .cursor-plugin .codex-plugin .agents .claude-plugin", + "generate": "bun tooling/generate.ts && oxfmt --write package.json .cursor-plugin .codex-plugin .agents .claude-plugin openclaw.plugin.json", "check:generated": "bun tooling/generated-drift-check.ts", - "archive:release": "bun run generate && bun run build && git diff --exit-code -- package.json .cursor-plugin .codex-plugin .agents .claude-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 .claude-plugin openclaw.plugin.json 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", @@ -72,5 +73,16 @@ ], "*.{json,jsonc,md,yaml,yml,css,html,svg}": "oxfmt --no-error-on-unmatched-pattern" }, - "packageManager": "bun@1.3.1" + "packageManager": "bun@1.3.1", + "openclaw": { + "extensions": [ + "./dist/hooks/openclaw/register.js" + ], + "compat": { + "pluginApi": ">=2026.6.1" + }, + "build": { + "openclawVersion": "2026.6.1" + } + } } diff --git a/tooling/generate.ts b/tooling/generate.ts index 5af20ba..055b218 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -31,6 +31,15 @@ const CURSOR_HOOKS = "./hooks/cursor/hooks.json"; const CODEX_HOOKS = "./hooks/codex/hooks.json"; const CLAUDE_HOOKS = "./hooks/claude/hooks.json"; +// OpenClaw loads an in-process plugin entry instead of a stdin/stdout Stop hook, +// so it points at the built register module and declares the lifecycle hooks the +// plugin subscribes to. The compat/build versions are what ClawHub package +// publishing requires; track the OpenClaw release this plugin is built against. +const OPENCLAW_ENTRYPOINT = "./dist/hooks/openclaw/register.js"; +const OPENCLAW_PLUGIN_API = ">=2026.6.1"; +const OPENCLAW_VERSION = "2026.6.1"; +const OPENCLAW_CALLBACKS = ["agent_turn_prepare", "after_tool_call", "before_agent_finalize"]; + const metadata = MetadataSchema.parse({ name: "mainframe", displayName: "Mainframe", @@ -81,6 +90,8 @@ function main(): void { writeJson(".claude-plugin/plugin.json", claudeManifest()); writeJson(".claude-plugin/marketplace.json", claudeMarketplace()); + writeJson("openclaw.plugin.json", openclawManifest()); + updatePackageJson(); } @@ -174,6 +185,28 @@ function claudeMarketplace() { }; } +// OpenClaw is a native plugin host: it reads this root manifest, installs the +// shared skill and MCP wiring, and runs the bundled lifecycle hook through the +// built entrypoint. It reuses the same metadata as the other hosts so name, +// version, and copy stay single-sourced. +function openclawManifest() { + return { + ...sharedManifest, + displayName: metadata.displayName, + longDescription: metadata.longDescription, + category: metadata.category, + logo: metadata.logo, + skills: metadata.skillDirectory, + mcpServers: metadata.mcpServers, + host: "openclaw", + entrypoint: OPENCLAW_ENTRYPOINT, + capabilities: ["skills", "mcp", "hooks"], + callbacks: OPENCLAW_CALLBACKS, + compat: { pluginApi: OPENCLAW_PLUGIN_API }, + build: { openclawVersion: OPENCLAW_VERSION }, + }; +} + function updatePackageJson(): void { const packageJson = JsonRecordSchema.parse(JSON.parse(readFileSync("package.json", "utf8"))); packageJson.name = metadata.packageName; @@ -188,6 +221,11 @@ function updatePackageJson(): void { url: metadata.repository, }; packageJson.keywords = metadata.keywords; + packageJson.openclaw = { + extensions: [OPENCLAW_ENTRYPOINT], + compat: { pluginApi: OPENCLAW_PLUGIN_API }, + build: { openclawVersion: OPENCLAW_VERSION }, + }; packageJson.bin = { "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js", "mainframe-hook-codex": "./dist/hooks/codex/stop.js", diff --git a/tooling/generated-drift-check.ts b/tooling/generated-drift-check.ts index b97d339..9bbc93c 100644 --- a/tooling/generated-drift-check.ts +++ b/tooling/generated-drift-check.ts @@ -8,6 +8,7 @@ const generatedPaths = [ ".agents/plugins/marketplace.json", ".claude-plugin/plugin.json", ".claude-plugin/marketplace.json", + "openclaw.plugin.json", "package.json", ]; diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index b5eccdf..b6f3695 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -64,6 +64,23 @@ const ClaudeManifestSchema = SharedManifestSchema.extend({ hooks: z.literal("./hooks/claude/hooks.json"), }).strict(); +const OpenClawManifestSchema = SharedManifestSchema.extend({ + displayName: z.literal("Mainframe"), + longDescription: LongDescriptionSchema, + category: z.literal("Productivity"), + logo: z.literal("assets/logo.png"), + host: z.literal("openclaw"), + entrypoint: z.literal("./dist/hooks/openclaw/register.js"), + capabilities: z.tuple([z.literal("skills"), z.literal("mcp"), z.literal("hooks")]), + callbacks: z.tuple([ + z.literal("agent_turn_prepare"), + z.literal("after_tool_call"), + z.literal("before_agent_finalize"), + ]), + compat: z.object({ pluginApi: z.literal(">=2026.6.1") }).strict(), + build: z.object({ openclawVersion: z.literal("2026.6.1") }).strict(), +}).strict(); + describe("generated plugin manifests", () => { it(".cursor-plugin/plugin.json matches the Cursor plugin schema", () => { const manifest = CursorManifestSchema.parse( @@ -89,6 +106,15 @@ describe("generated plugin manifests", () => { expect(manifest.hooks).toBe("./hooks/claude/hooks.json"); }); + it("openclaw.plugin.json matches the OpenClaw plugin schema", () => { + const manifest = OpenClawManifestSchema.parse( + JSON.parse(readFileSync("openclaw.plugin.json", "utf8")), + ); + + expect(manifest.host).toBe("openclaw"); + expect(manifest.callbacks).toContain("before_agent_finalize"); + }); + it("Cursor marketplace metadata matches the Cursor marketplace schema", () => { const marketplaceSchema = z .object({ diff --git a/tooling/package-surface.ts b/tooling/package-surface.ts index e18c3f2..5e3603b 100644 --- a/tooling/package-surface.ts +++ b/tooling/package-surface.ts @@ -7,6 +7,7 @@ export const PACKAGE_FILES = [ ".codex-plugin", ".cursor-plugin", ".mcp.json", + "openclaw.plugin.json", "LICENSE", "README.md", "assets/logo.png", @@ -25,6 +26,7 @@ export const SHIPPED_FILES = [ ".cursor-plugin/marketplace.json", ".cursor-plugin/plugin.json", ".mcp.json", + "openclaw.plugin.json", "LICENSE", "README.md", "assets/logo.png", @@ -43,6 +45,8 @@ export const SHIPPED_FILES = [ "dist/hooks/cursor/stop-evaluator.js", "dist/hooks/cursor/stop.js", "dist/hooks/cursor/transcript.js", + "dist/hooks/openclaw/register.js", + "dist/hooks/openclaw/runtime.js", "hooks/claude/hooks.json", "hooks/codex/hooks.json", "hooks/cursor/hooks.json", diff --git a/tooling/release-archive.ts b/tooling/release-archive.ts index 85d691b..e6f3bb4 100644 --- a/tooling/release-archive.ts +++ b/tooling/release-archive.ts @@ -35,6 +35,15 @@ const NestedHostManifestSchema = z.object({ skills: z.string().min(1), }); +// OpenClaw runs an in-process plugin entry, so it ships a built entrypoint +// alongside the shared MCP wiring, skill directory, and logo. +const OpenClawManifestSchema = z.object({ + entrypoint: z.string().min(1), + logo: 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")), @@ -45,6 +54,9 @@ const codexManifest = NestedHostManifestSchema.parse( const claudeManifest = NestedHostManifestSchema.parse( JSON.parse(readFileSync(".claude-plugin/plugin.json", "utf8")), ); +const openclawManifest = OpenClawManifestSchema.parse( + JSON.parse(readFileSync("openclaw.plugin.json", "utf8")), +); const archiveName = `${packageJson.name.replace(/^@/, "").replace("/", "-")}-${packageJson.version}.tgz`; const releaseDir = resolve("release"); const archivePath = join(releaseDir, archiveName); @@ -67,6 +79,10 @@ const manifestPaths = [ claudeManifest.hooks, claudeManifest.mcpServers, claudeManifest.skills, + openclawManifest.entrypoint, + openclawManifest.logo, + openclawManifest.mcpServers, + openclawManifest.skills, ].map((path) => path.replace(/^\.\//, "")); assertPluginPackageSurface(packageJson.files); From 24fa6d21773d1afc42040dba119b351c59da8959 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 22:19:35 +0000 Subject: [PATCH 04/10] Fix OpenClaw manifest to match the real plugin schema The generated openclaw.plugin.json was an ad-hoc mix of shared-host fields, ClawHub catalog copy, and invented keys. Against OpenClaw's documented manifest schema that is invalid: it omitted the required configSchema (which blocks config validation), typed skills as a string instead of string[], and declared an entrypoint plus host/capabilities/callbacks/compat/build fields that are not manifest fields (entrypoints and npm metadata belong in package.json's openclaw block, which already carries them). Collapse it to the real minimal contract (id, name, description, version, skills array, activation.onStartup, empty configSchema) and validate the entrypoint from package.json openclaw.extensions in the release guard. Behavior of the hook runtime is unchanged. --- openclaw.plugin.json | 33 +++++++++----------------------- tooling/generate.ts | 39 +++++++++++++++++--------------------- tooling/manifest.test.ts | 36 +++++++++++++++++------------------ tooling/release-archive.ts | 18 ++---------------- 4 files changed, 46 insertions(+), 80 deletions(-) diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 060621b..d9ade50 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,29 +1,14 @@ { - "name": "mainframe", - "version": "0.1.0", + "id": "mainframe", + "name": "Mainframe", "description": "Create and share short video updates from agent work.", - "author": { - "name": "Mainframe", - "email": "support@mainframe.app" - }, - "homepage": "https://mainframe.app", - "repository": "https://github.com/mainframecomputer/mainframe-plugins.git", - "license": "UNLICENSED", - "keywords": ["mainframe", "agent-skills", "mcp", "hooks", "video"], - "displayName": "Mainframe", - "longDescription": "Turn agent work into videos your team can keep up with. Agents can generate a short video or upload one they recorded themselves, narrated in your voice and styled with your brand. Every video becomes a link your team can watch.", - "category": "Productivity", - "logo": "assets/logo.png", - "skills": "./skills", - "mcpServers": "./.mcp.json", - "host": "openclaw", - "entrypoint": "./dist/hooks/openclaw/register.js", - "capabilities": ["skills", "mcp", "hooks"], - "callbacks": ["agent_turn_prepare", "after_tool_call", "before_agent_finalize"], - "compat": { - "pluginApi": ">=2026.6.1" + "version": "0.1.0", + "skills": ["./skills"], + "activation": { + "onStartup": true }, - "build": { - "openclawVersion": "2026.6.1" + "configSchema": { + "type": "object", + "additionalProperties": false } } diff --git a/tooling/generate.ts b/tooling/generate.ts index 055b218..b2d2d2f 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -31,14 +31,13 @@ const CURSOR_HOOKS = "./hooks/cursor/hooks.json"; const CODEX_HOOKS = "./hooks/codex/hooks.json"; const CLAUDE_HOOKS = "./hooks/claude/hooks.json"; -// OpenClaw loads an in-process plugin entry instead of a stdin/stdout Stop hook, -// so it points at the built register module and declares the lifecycle hooks the -// plugin subscribes to. The compat/build versions are what ClawHub package -// publishing requires; track the OpenClaw release this plugin is built against. +// OpenClaw reads code entrypoints and npm metadata from the package.json +// `openclaw` block (not the manifest), so the built register module is declared +// there. The compat/build versions are what ClawHub package publishing requires; +// track the OpenClaw release this plugin is built against. const OPENCLAW_ENTRYPOINT = "./dist/hooks/openclaw/register.js"; const OPENCLAW_PLUGIN_API = ">=2026.6.1"; const OPENCLAW_VERSION = "2026.6.1"; -const OPENCLAW_CALLBACKS = ["agent_turn_prepare", "after_tool_call", "before_agent_finalize"]; const metadata = MetadataSchema.parse({ name: "mainframe", @@ -185,25 +184,21 @@ function claudeMarketplace() { }; } -// OpenClaw is a native plugin host: it reads this root manifest, installs the -// shared skill and MCP wiring, and runs the bundled lifecycle hook through the -// built entrypoint. It reuses the same metadata as the other hosts so name, -// version, and copy stay single-sourced. +// The native OpenClaw manifest is intentionally minimal: it is the cold +// metadata OpenClaw reads before loading plugin code, so it carries only plugin +// identity, the skill directory to load, a startup activation hint for the +// lifecycle hook, and the required (empty) config schema. Entrypoints, MCP +// wiring, and catalog copy are not manifest fields — they live in package.json, +// the user's openclaw.json, and the bundle markers respectively. function openclawManifest() { return { - ...sharedManifest, - displayName: metadata.displayName, - longDescription: metadata.longDescription, - category: metadata.category, - logo: metadata.logo, - skills: metadata.skillDirectory, - mcpServers: metadata.mcpServers, - host: "openclaw", - entrypoint: OPENCLAW_ENTRYPOINT, - capabilities: ["skills", "mcp", "hooks"], - callbacks: OPENCLAW_CALLBACKS, - compat: { pluginApi: OPENCLAW_PLUGIN_API }, - build: { openclawVersion: OPENCLAW_VERSION }, + id: metadata.name, + name: metadata.displayName, + description: metadata.description, + version: metadata.version, + skills: [metadata.skillDirectory], + activation: { onStartup: true }, + configSchema: { type: "object", additionalProperties: false }, }; } diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index b6f3695..e8ad197 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -64,22 +64,22 @@ const ClaudeManifestSchema = SharedManifestSchema.extend({ hooks: z.literal("./hooks/claude/hooks.json"), }).strict(); -const OpenClawManifestSchema = SharedManifestSchema.extend({ - displayName: z.literal("Mainframe"), - longDescription: LongDescriptionSchema, - category: z.literal("Productivity"), - logo: z.literal("assets/logo.png"), - host: z.literal("openclaw"), - entrypoint: z.literal("./dist/hooks/openclaw/register.js"), - capabilities: z.tuple([z.literal("skills"), z.literal("mcp"), z.literal("hooks")]), - callbacks: z.tuple([ - z.literal("agent_turn_prepare"), - z.literal("after_tool_call"), - z.literal("before_agent_finalize"), - ]), - compat: z.object({ pluginApi: z.literal(">=2026.6.1") }).strict(), - build: z.object({ openclawVersion: z.literal("2026.6.1") }).strict(), -}).strict(); +// The OpenClaw manifest is its own minimal contract (id + required configSchema +// + cold metadata), not the shared host manifest: entrypoints and catalog copy +// are deliberately absent because OpenClaw reads those from package.json. +const OpenClawManifestSchema = z + .object({ + id: z.literal("mainframe"), + name: z.literal("Mainframe"), + description: DescriptionSchema, + version: z.literal("0.1.0"), + skills: z.tuple([z.literal("./skills")]), + activation: z.object({ onStartup: z.literal(true) }).strict(), + configSchema: z + .object({ type: z.literal("object"), additionalProperties: z.literal(false) }) + .strict(), + }) + .strict(); describe("generated plugin manifests", () => { it(".cursor-plugin/plugin.json matches the Cursor plugin schema", () => { @@ -111,8 +111,8 @@ describe("generated plugin manifests", () => { JSON.parse(readFileSync("openclaw.plugin.json", "utf8")), ); - expect(manifest.host).toBe("openclaw"); - expect(manifest.callbacks).toContain("before_agent_finalize"); + expect(manifest.id).toBe("mainframe"); + expect(manifest.configSchema.additionalProperties).toBe(false); }); it("Cursor marketplace metadata matches the Cursor marketplace schema", () => { diff --git a/tooling/release-archive.ts b/tooling/release-archive.ts index e6f3bb4..16ce3bf 100644 --- a/tooling/release-archive.ts +++ b/tooling/release-archive.ts @@ -18,6 +18,7 @@ const PackageSchema = z.object({ name: z.string().min(1), version: z.string().min(1), files: z.array(z.string().min(1)).min(1), + openclaw: z.object({ extensions: z.array(z.string().min(1)).min(1) }).passthrough(), }); const PluginManifestSchema = z.object({ @@ -35,15 +36,6 @@ const NestedHostManifestSchema = z.object({ skills: z.string().min(1), }); -// OpenClaw runs an in-process plugin entry, so it ships a built entrypoint -// alongside the shared MCP wiring, skill directory, and logo. -const OpenClawManifestSchema = z.object({ - entrypoint: z.string().min(1), - logo: 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")), @@ -54,9 +46,6 @@ const codexManifest = NestedHostManifestSchema.parse( const claudeManifest = NestedHostManifestSchema.parse( JSON.parse(readFileSync(".claude-plugin/plugin.json", "utf8")), ); -const openclawManifest = OpenClawManifestSchema.parse( - JSON.parse(readFileSync("openclaw.plugin.json", "utf8")), -); const archiveName = `${packageJson.name.replace(/^@/, "").replace("/", "-")}-${packageJson.version}.tgz`; const releaseDir = resolve("release"); const archivePath = join(releaseDir, archiveName); @@ -79,10 +68,7 @@ const manifestPaths = [ claudeManifest.hooks, claudeManifest.mcpServers, claudeManifest.skills, - openclawManifest.entrypoint, - openclawManifest.logo, - openclawManifest.mcpServers, - openclawManifest.skills, + ...packageJson.openclaw.extensions, ].map((path) => path.replace(/^\.\//, "")); assertPluginPackageSurface(packageJson.files); From 0ee92a88d1f2cbf91718bc938cedc6f53c57f410 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 22:37:29 +0000 Subject: [PATCH 05/10] Refine OpenClaw plugin after independent quality re-review - Declare the built entrypoint as openclaw.runtimeExtensions (built JS) instead of extensions (which is for source entrypoints); validate it in the release guard. Re-verified with clawhub package publish --dry-run. - Route the finalize decision through the shared decideStop policy seam like the other hosts, instead of calling evaluateAfkGate directly. - Scope the already-shared check to the current final answer (lastAssistantMessage) rather than scanning full session history, so a stale Mainframe link can no longer permanently mute later suggestions; drop the now-unused messages field. - Derive the plugin-api range from the tracked OpenClaw version, and refresh the generated package description and AGENTS.md to include OpenClaw. --- AGENTS.md | 8 ++++---- dist/hooks/openclaw/runtime.js | 23 ++++++++++++++--------- hooks/openclaw/register.test.ts | 32 +++++++++++--------------------- hooks/openclaw/runtime.ts | 33 ++++++++++++++++++--------------- package.json | 4 ++-- tooling/generate.ts | 15 ++++++++------- tooling/release-archive.ts | 4 ++-- 7 files changed, 59 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 62e593e..50684b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,12 +15,12 @@ focused on the Cursor, Codex, Claude Code, and OpenClaw manifests, hosted MCP wi `hooks/openclaw/register.ts` and maps the shared AFK gate onto the `before_agent_finalize` lifecycle hook. Do not add other host surfaces unless the product task explicitly asks for them. - Run `bun run verify` before considering changes ready. -- Generated Cursor, Codex, and Claude Code manifest and marketplace files come from +- Generated Cursor, Codex, Claude Code, and OpenClaw manifest and marketplace files come from `tooling/generate.ts`; edit the config there, then run `bun run generate`. - The canonical skill is `skills/share-video/SKILL.md`. -- Keep generated metadata in `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`, and - `.claude-plugin/`. Do not add package-local docs or extra top-level markdown unless the user - asks; improve `README.md` or this file instead. +- Keep generated metadata in `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`, + `.claude-plugin/`, and `openclaw.plugin.json`. 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/dist/hooks/openclaw/runtime.js b/dist/hooks/openclaw/runtime.js index d465cd4..e61ef56 100644 --- a/dist/hooks/openclaw/runtime.js +++ b/dist/hooks/openclaw/runtime.js @@ -1,4 +1,4 @@ -import { evaluateAfkGate } from "../core/afk-gate.js"; +import { decideStop } from "../core/stop-policy.js"; import { hasMainframeVideoUrl } from "../core/transcript.js"; export function createMainframeFinalizeTracker(options = {}) { const nowMs = options.nowMs ?? (() => Date.now()); @@ -16,13 +16,21 @@ export function createMainframeFinalizeTracker(options = {}) { if (event.stopHookActive || state.kind === "idle") { return undefined; } - const gate = evaluateAfkGate({ - stopTimeMs: nowMs(), + // The finalize event has no per-message timestamps, so the turn-scoped + // signals collected above stand in for a transcript summary and run + // through the same stop policy as the other hosts. Only the current final + // answer is checked for an existing share; older history is intentionally + // not scanned so a stale Mainframe link cannot mute later turns. + const summary = { + kind: "ready", lastUserTimeMs: state.turnStartMs, workHappened: state.workHappened, - alreadyShared: alreadySharedFromEvent(event), - }); - return gate.fire ? { action: "revise", reason: gate.reason } : undefined; + alreadyShared: hasMainframeVideoUrl(event.lastAssistantMessage), + }; + const decision = decideStop(summary, nowMs()); + return decision.kind === "suggest" + ? { action: "revise", reason: decision.message } + : undefined; }, }; } @@ -33,6 +41,3 @@ export function registerMainframeHooks(api, options = {}) { api.on("before_agent_finalize", tracker.onFinalize); return tracker; } -function alreadySharedFromEvent(event) { - return hasMainframeVideoUrl(event.lastAssistantMessage) || hasMainframeVideoUrl(event.messages); -} diff --git a/hooks/openclaw/register.test.ts b/hooks/openclaw/register.test.ts index 7c1d541..2ee62a6 100644 --- a/hooks/openclaw/register.test.ts +++ b/hooks/openclaw/register.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from "vitest"; import plugin, { registerMainframeHooks } from "./register.js"; -import { - createMainframeFinalizeTracker, - type OpenClawFinalizeEvent, - type OpenClawPluginApi, -} from "./runtime.js"; +import { createMainframeFinalizeTracker, type OpenClawPluginApi } from "./runtime.js"; const userTimeMs = Date.parse("2026-05-08T13:00:00.000Z"); const awayTimeMs = Date.parse("2026-05-08T15:30:00.000Z"); @@ -60,14 +56,17 @@ describe("OpenClaw before_agent_finalize hook", () => { expect(tracker.onFinalize({ stopHookActive: true })).toBeUndefined(); }); - it("does not fire after a Mainframe video URL already appears in the turn", () => { - for (const event of sharedEvents()) { - const tracker = trackerAt([userTimeMs, awayTimeMs]); - tracker.onTurnPrepare(); - tracker.onToolCall(); + it("does not fire when the current final answer already shared a Mainframe video", () => { + const tracker = trackerAt([userTimeMs, awayTimeMs]); + tracker.onTurnPrepare(); + tracker.onToolCall(); - expect(tracker.onFinalize(event)).toBeUndefined(); - } + expect( + tracker.onFinalize({ + stopHookActive: false, + lastAssistantMessage: `Shared: ${sharedVideoUrl}`, + }), + ).toBeUndefined(); }); it("derives its suggestion from elapsed time only and never echoes turn content", () => { @@ -78,11 +77,9 @@ describe("OpenClaw before_agent_finalize hook", () => { const result = tracker.onFinalize({ stopHookActive: false, lastAssistantMessage: "SECRET_NEVER_LEAK", - messages: [{ text: "ANOTHER_SECRET" }], }); expect(result?.reason).not.toContain("SECRET_NEVER_LEAK"); - expect(result?.reason).not.toContain("ANOTHER_SECRET"); }); it("exports a default plugin entry that registers the OpenClaw hook names in order", () => { @@ -104,10 +101,3 @@ describe("OpenClaw before_agent_finalize hook", () => { expect(names).toEqual(["agent_turn_prepare", "after_tool_call", "before_agent_finalize"]); }); }); - -function sharedEvents(): OpenClawFinalizeEvent[] { - return [ - { stopHookActive: false, lastAssistantMessage: `Shared: ${sharedVideoUrl}` }, - { stopHookActive: false, messages: [{ output: `Shared: ${sharedVideoUrl}` }] }, - ]; -} diff --git a/hooks/openclaw/runtime.ts b/hooks/openclaw/runtime.ts index fa46143..530fa3c 100644 --- a/hooks/openclaw/runtime.ts +++ b/hooks/openclaw/runtime.ts @@ -1,5 +1,5 @@ -import { evaluateAfkGate } from "../core/afk-gate.js"; -import { hasMainframeVideoUrl } from "../core/transcript.js"; +import { decideStop } from "../core/stop-policy.js"; +import { hasMainframeVideoUrl, type TranscriptSummary } from "../core/transcript.js"; // Minimal local mirror of the OpenClaw plugin hook surface this plugin uses, // per https://docs.openclaw.ai/plugins/hooks (openclaw 2026.6.1). Like the @@ -8,7 +8,6 @@ import { hasMainframeVideoUrl } from "../core/transcript.js"; export type OpenClawFinalizeEvent = { stopHookActive: boolean; lastAssistantMessage?: string; - messages?: unknown[]; }; export type OpenClawReviseResult = { action: "revise"; reason: string }; @@ -17,9 +16,9 @@ export type OpenClawReviseResult = { action: "revise"; reason: string }; // of the stdin/stdout Stop hook the other hosts use, and its event carries no // per-message wall-clock time. Elapsed time is therefore measured across the // turn: `agent_turn_prepare` marks the start, `after_tool_call` records that -// work happened, and `before_agent_finalize` reuses the shared AFK gate to ask -// for one more pass that leaves a Mainframe video. The tracker fails closed: if -// a turn never starts or no tool work is seen, it never suggests. +// work happened, and `before_agent_finalize` feeds the shared stop policy to +// ask for one more pass that leaves a Mainframe video. The tracker fails closed: +// if a turn never starts or no tool work is seen, it never suggests. export type OpenClawPluginApi = { on(hookName: "agent_turn_prepare", handler: () => void): void; on(hookName: "after_tool_call", handler: () => void): void; @@ -65,14 +64,22 @@ export function createMainframeFinalizeTracker( return undefined; } - const gate = evaluateAfkGate({ - stopTimeMs: nowMs(), + // The finalize event has no per-message timestamps, so the turn-scoped + // signals collected above stand in for a transcript summary and run + // through the same stop policy as the other hosts. Only the current final + // answer is checked for an existing share; older history is intentionally + // not scanned so a stale Mainframe link cannot mute later turns. + const summary: TranscriptSummary = { + kind: "ready", lastUserTimeMs: state.turnStartMs, workHappened: state.workHappened, - alreadyShared: alreadySharedFromEvent(event), - }); + alreadyShared: hasMainframeVideoUrl(event.lastAssistantMessage), + }; + const decision = decideStop(summary, nowMs()); - return gate.fire ? { action: "revise", reason: gate.reason } : undefined; + return decision.kind === "suggest" + ? { action: "revise", reason: decision.message } + : undefined; }, }; } @@ -87,7 +94,3 @@ export function registerMainframeHooks( api.on("before_agent_finalize", tracker.onFinalize); return tracker; } - -function alreadySharedFromEvent(event: OpenClawFinalizeEvent): boolean { - return hasMainframeVideoUrl(event.lastAssistantMessage) || hasMainframeVideoUrl(event.messages); -} diff --git a/package.json b/package.json index 99706cb..7c74de0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@mainframe/plugins", "version": "0.1.0", "private": true, - "description": "Mainframe Cursor, Codex, and Claude Code plugin manifests, skill, MCP wiring, and stop hooks.", + "description": "Mainframe Cursor, Codex, Claude Code, and OpenClaw plugin manifests, skill, MCP wiring, and stop hooks.", "keywords": [ "agent-skills", "hooks", @@ -75,7 +75,7 @@ }, "packageManager": "bun@1.3.1", "openclaw": { - "extensions": [ + "runtimeExtensions": [ "./dist/hooks/openclaw/register.js" ], "compat": { diff --git a/tooling/generate.ts b/tooling/generate.ts index b2d2d2f..7afb4fb 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -32,12 +32,13 @@ const CODEX_HOOKS = "./hooks/codex/hooks.json"; const CLAUDE_HOOKS = "./hooks/claude/hooks.json"; // OpenClaw reads code entrypoints and npm metadata from the package.json -// `openclaw` block (not the manifest), so the built register module is declared -// there. The compat/build versions are what ClawHub package publishing requires; -// track the OpenClaw release this plugin is built against. -const OPENCLAW_ENTRYPOINT = "./dist/hooks/openclaw/register.js"; -const OPENCLAW_PLUGIN_API = ">=2026.6.1"; +// `openclaw` block (not the manifest). We ship built JS, so the register module +// is declared as a `runtimeExtensions` entry. The compat/build versions are what +// ClawHub package publishing requires; track the OpenClaw release this plugin is +// built against. +const OPENCLAW_RUNTIME_ENTRYPOINT = "./dist/hooks/openclaw/register.js"; const OPENCLAW_VERSION = "2026.6.1"; +const OPENCLAW_PLUGIN_API = `>=${OPENCLAW_VERSION}`; const metadata = MetadataSchema.parse({ name: "mainframe", @@ -207,7 +208,7 @@ function updatePackageJson(): void { packageJson.name = metadata.packageName; packageJson.version = metadata.version; packageJson.description = - "Mainframe Cursor, Codex, and Claude Code plugin manifests, skill, MCP wiring, and stop hooks."; + "Mainframe Cursor, Codex, Claude Code, and OpenClaw plugin manifests, skill, MCP wiring, and stop hooks."; packageJson.private = true; packageJson.license = metadata.license; packageJson.homepage = metadata.homepage; @@ -217,7 +218,7 @@ function updatePackageJson(): void { }; packageJson.keywords = metadata.keywords; packageJson.openclaw = { - extensions: [OPENCLAW_ENTRYPOINT], + runtimeExtensions: [OPENCLAW_RUNTIME_ENTRYPOINT], compat: { pluginApi: OPENCLAW_PLUGIN_API }, build: { openclawVersion: OPENCLAW_VERSION }, }; diff --git a/tooling/release-archive.ts b/tooling/release-archive.ts index 16ce3bf..997684f 100644 --- a/tooling/release-archive.ts +++ b/tooling/release-archive.ts @@ -18,7 +18,7 @@ const PackageSchema = z.object({ name: z.string().min(1), version: z.string().min(1), files: z.array(z.string().min(1)).min(1), - openclaw: z.object({ extensions: z.array(z.string().min(1)).min(1) }).passthrough(), + openclaw: z.object({ runtimeExtensions: z.array(z.string().min(1)).min(1) }).passthrough(), }); const PluginManifestSchema = z.object({ @@ -68,7 +68,7 @@ const manifestPaths = [ claudeManifest.hooks, claudeManifest.mcpServers, claudeManifest.skills, - ...packageJson.openclaw.extensions, + ...packageJson.openclaw.runtimeExtensions, ].map((path) => path.replace(/^\.\//, "")); assertPluginPackageSurface(packageJson.files); From 688e32cc30851ac7df3254860f8ed9d63790b242 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 22:55:15 +0000 Subject: [PATCH 06/10] Harden OpenClaw finalize tracker after two independent re-reviews Two independent reviewers (Opus 4.8 and Codex) flagged the stateful tracker as the novel risk, and converged on a real fail-open defect plus a boundary gap: - onFinalize now consumes the turn back to idle before deciding, so it suggests at most once per armed turn. A re-finalize after a revise, or any later finalize the host emits without a fresh agent_turn_prepare, hits the idle guard instead of reusing stale turn-start/work signals (was: state never reset, so it could re-suggest in a revise loop or fire on a stale turn). - The finalize boundary now fails closed: stopHookActive is optional and the guard proceeds only on an explicit , so a missing/malformed value skips. lastAssistantMessage is typed unknown to match the host contract. - Added regression tests for the revise-loop backstop and per-turn re-arming, and a test pinning the package.json OpenClaw publishing contract. - Clarified the README so it no longer says the plugin registers the MCP server while also saying native plugins cannot. Confirmed against ClawHub source that publish reads compat/build from package.json and never reads extensions/runtimeExtensions, so runtimeExtensions (built JS) is correct and complete for our package. --- README.md | 10 ++++----- dist/hooks/openclaw/runtime.js | 25 ++++++++++++++++------ hooks/openclaw/register.test.ts | 22 +++++++++++++++++++ hooks/openclaw/runtime.ts | 38 ++++++++++++++++++++++++--------- tooling/manifest.test.ts | 18 ++++++++++++++++ 5 files changed, 91 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 123e491..aa617d2 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,11 @@ registry for OpenClaw, with the OpenClaw plugin manager: openclaw plugins install clawhub:mainframe ``` -The plugin gives OpenClaw the `share-video` skill, the hosted Mainframe MCP server, and a native -`before_agent_finalize` hook that suggests a short video after a long, unattended run — the same -conservative AFK behavior as the other hosts' stop hooks, reusing the shared `hooks/core` runtime. -Because native OpenClaw plugins cannot register an MCP server for you and conversation hooks need -explicit access, add this to your `openclaw.json`: +The plugin gives OpenClaw the `share-video` skill and a native `before_agent_finalize` hook that +suggests a short video after a long, unattended run — the same conservative AFK behavior as the +other hosts' stop hooks, reusing the shared `hooks/core` runtime. Native OpenClaw plugins cannot +register an MCP server for you, and conversation hooks need explicit access, so add the hosted +Mainframe MCP server and hook access to your `openclaw.json`: ```json { diff --git a/dist/hooks/openclaw/runtime.js b/dist/hooks/openclaw/runtime.js index e61ef56..5e82c10 100644 --- a/dist/hooks/openclaw/runtime.js +++ b/dist/hooks/openclaw/runtime.js @@ -13,18 +13,27 @@ export function createMainframeFinalizeTracker(options = {}) { } }, onFinalize(event) { - if (event.stopHookActive || state.kind === "idle") { + // Fail closed on the loop guard: only a turn the host reports as not + // already re-prompted may proceed, so a missing/ambiguous `stopHookActive` + // skips instead of risking a re-suggest. + if (event.stopHookActive !== false || state.kind === "idle") { return undefined; } + // Consume the turn back to idle before deciding. A turn is suggested at + // most once: a re-finalize after a revise, or any later finalize that the + // host emits without a fresh `agent_turn_prepare`, hits the idle guard + // instead of reusing stale turn-start/work signals. + const { turnStartMs, workHappened } = state; + state = { kind: "idle" }; // The finalize event has no per-message timestamps, so the turn-scoped - // signals collected above stand in for a transcript summary and run - // through the same stop policy as the other hosts. Only the current final - // answer is checked for an existing share; older history is intentionally - // not scanned so a stale Mainframe link cannot mute later turns. + // signals stand in for a transcript summary and run through the same stop + // policy as the other hosts. Only the current final answer is checked for + // an existing share; older history is intentionally not scanned so a stale + // Mainframe link cannot mute later turns. const summary = { kind: "ready", - lastUserTimeMs: state.turnStartMs, - workHappened: state.workHappened, + lastUserTimeMs: turnStartMs, + workHappened, alreadyShared: hasMainframeVideoUrl(event.lastAssistantMessage), }; const decision = decideStop(summary, nowMs()); @@ -36,6 +45,8 @@ export function createMainframeFinalizeTracker(options = {}) { } export function registerMainframeHooks(api, options = {}) { const tracker = createMainframeFinalizeTracker(options); + // The tracker methods close over local state, not `this`, so the host calling + // them detached is safe. Keep them `this`-free if this is ever refactored. api.on("agent_turn_prepare", tracker.onTurnPrepare); api.on("after_tool_call", tracker.onToolCall); api.on("before_agent_finalize", tracker.onFinalize); diff --git a/hooks/openclaw/register.test.ts b/hooks/openclaw/register.test.ts index 2ee62a6..2d0009d 100644 --- a/hooks/openclaw/register.test.ts +++ b/hooks/openclaw/register.test.ts @@ -56,6 +56,28 @@ describe("OpenClaw before_agent_finalize hook", () => { expect(tracker.onFinalize({ stopHookActive: true })).toBeUndefined(); }); + it("suggests at most once per turn, so a repeat finalize does not re-fire", () => { + const tracker = trackerAt([userTimeMs, awayTimeMs, awayTimeMs]); + tracker.onTurnPrepare(); + tracker.onToolCall(); + + expect(tracker.onFinalize({ stopHookActive: false })?.action).toBe("revise"); + expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); + }); + + it("re-arms per turn and does not carry stale work into the next turn", () => { + const laterTimeMs = Date.parse("2026-05-08T18:00:00.000Z"); + const tracker = trackerAt([userTimeMs, awayTimeMs, awayTimeMs, laterTimeMs]); + tracker.onTurnPrepare(); + tracker.onToolCall(); + expect(tracker.onFinalize({ stopHookActive: false })?.action).toBe("revise"); + + // A fresh turn with no tool work must not fire, even though wall-clock time + // since the previous turn exceeds the threshold. + tracker.onTurnPrepare(); + expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); + }); + it("does not fire when the current final answer already shared a Mainframe video", () => { const tracker = trackerAt([userTimeMs, awayTimeMs]); tracker.onTurnPrepare(); diff --git a/hooks/openclaw/runtime.ts b/hooks/openclaw/runtime.ts index 530fa3c..874f7f4 100644 --- a/hooks/openclaw/runtime.ts +++ b/hooks/openclaw/runtime.ts @@ -6,8 +6,12 @@ import { hasMainframeVideoUrl, type TranscriptSummary } from "../core/transcript // Cursor, Codex, and Claude Code hooks, this models the host contract locally // instead of depending on a host SDK. export type OpenClawFinalizeEvent = { - stopHookActive: boolean; - lastAssistantMessage?: string; + // Optional at this trust boundary so the guard can fail closed when the host + // omits or malforms it, rather than coercing a missing value to "not active". + stopHookActive?: boolean; + // The host contract allows structured assistant content; `hasMainframeVideoUrl` + // recurses over strings, arrays, and records, so keep this as `unknown`. + lastAssistantMessage?: unknown; }; export type OpenClawReviseResult = { action: "revise"; reason: string }; @@ -18,7 +22,9 @@ export type OpenClawReviseResult = { action: "revise"; reason: string }; // turn: `agent_turn_prepare` marks the start, `after_tool_call` records that // work happened, and `before_agent_finalize` feeds the shared stop policy to // ask for one more pass that leaves a Mainframe video. The tracker fails closed: -// if a turn never starts or no tool work is seen, it never suggests. +// it suggests at most once per armed turn (finalize consumes the turn back to +// idle), only proceeds when the host reports `stopHookActive === false`, and +// never suggests without a turn start and observed tool work. export type OpenClawPluginApi = { on(hookName: "agent_turn_prepare", handler: () => void): void; on(hookName: "after_tool_call", handler: () => void): void; @@ -60,19 +66,29 @@ export function createMainframeFinalizeTracker( }, onFinalize(event: OpenClawFinalizeEvent): OpenClawReviseResult | undefined { - if (event.stopHookActive || state.kind === "idle") { + // Fail closed on the loop guard: only a turn the host reports as not + // already re-prompted may proceed, so a missing/ambiguous `stopHookActive` + // skips instead of risking a re-suggest. + if (event.stopHookActive !== false || state.kind === "idle") { return undefined; } + // Consume the turn back to idle before deciding. A turn is suggested at + // most once: a re-finalize after a revise, or any later finalize that the + // host emits without a fresh `agent_turn_prepare`, hits the idle guard + // instead of reusing stale turn-start/work signals. + const { turnStartMs, workHappened } = state; + state = { kind: "idle" }; + // The finalize event has no per-message timestamps, so the turn-scoped - // signals collected above stand in for a transcript summary and run - // through the same stop policy as the other hosts. Only the current final - // answer is checked for an existing share; older history is intentionally - // not scanned so a stale Mainframe link cannot mute later turns. + // signals stand in for a transcript summary and run through the same stop + // policy as the other hosts. Only the current final answer is checked for + // an existing share; older history is intentionally not scanned so a stale + // Mainframe link cannot mute later turns. const summary: TranscriptSummary = { kind: "ready", - lastUserTimeMs: state.turnStartMs, - workHappened: state.workHappened, + lastUserTimeMs: turnStartMs, + workHappened, alreadyShared: hasMainframeVideoUrl(event.lastAssistantMessage), }; const decision = decideStop(summary, nowMs()); @@ -89,6 +105,8 @@ export function registerMainframeHooks( options: MainframeTrackerOptions = {}, ): MainframeFinalizeTracker { const tracker = createMainframeFinalizeTracker(options); + // The tracker methods close over local state, not `this`, so the host calling + // them detached is safe. Keep them `this`-free if this is ever refactored. api.on("agent_turn_prepare", tracker.onTurnPrepare); api.on("after_tool_call", tracker.onToolCall); api.on("before_agent_finalize", tracker.onFinalize); diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index e8ad197..fce4eef 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -115,6 +115,24 @@ describe("generated plugin manifests", () => { expect(manifest.configSchema.additionalProperties).toBe(false); }); + it("package.json declares the OpenClaw code-plugin publishing contract", () => { + const packageOpenClawSchema = z + .object({ + openclaw: z + .object({ + runtimeExtensions: z.tuple([z.literal("./dist/hooks/openclaw/register.js")]), + compat: z.object({ pluginApi: z.literal(">=2026.6.1") }).strict(), + build: z.object({ openclawVersion: z.literal("2026.6.1") }).strict(), + }) + .strict(), + }) + .passthrough(); + + const pkg = packageOpenClawSchema.parse(JSON.parse(readFileSync("package.json", "utf8"))); + + expect(pkg.openclaw.build.openclawVersion).toBe("2026.6.1"); + }); + it("Cursor marketplace metadata matches the Cursor marketplace schema", () => { const marketplaceSchema = z .object({ From 9dadc8ab67b2317e4bda8fe9b63b4da74c8162af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 23:11:08 +0000 Subject: [PATCH 07/10] Make OpenClaw tracker fail closed on every finalize and detect tool-result shares Two more independent reviewers split on severity; applying both findings makes the tracker's stated invariants hold unconditionally rather than relying on host behavior: - onFinalize now consumes the armed turn on EVERY call before the loop guard, so a skipped finalize (stopHookActive not strictly false, e.g. a host re-prompt or a missing field) can no longer leave stale turn signals that a later stopHookActive:false finalize fires from. 'Suggest at most once per armed turn' now holds in all paths. - after_tool_call now inspects the tool result for a Mainframe video URL and records it on the turn, matching the other hosts (which suppress when a share URL appears in tool output, not just the final answer). Fixes a duplicate share-video nudge when the agent shared via the MCP tool but did not repeat the URL in its final answer. - Added regression tests: missing stopHookActive fails closed, a skipped finalize spends the turn, and a tool-result share suppresses the nudge. Held (with rationale): runtimeExtensions-only stays (ClawHub source confirms publish never reads extensions/runtimeExtensions; it is the documented field for built installed packages), and no null-event guard (in-process SDK-typed call). --- dist/hooks/openclaw/runtime.js | 43 +++++++++++-------- hooks/openclaw/register.test.ts | 45 ++++++++++++++++---- hooks/openclaw/runtime.ts | 73 +++++++++++++++++++-------------- 3 files changed, 105 insertions(+), 56 deletions(-) diff --git a/dist/hooks/openclaw/runtime.js b/dist/hooks/openclaw/runtime.js index 5e82c10..e3167c7 100644 --- a/dist/hooks/openclaw/runtime.js +++ b/dist/hooks/openclaw/runtime.js @@ -5,36 +5,43 @@ export function createMainframeFinalizeTracker(options = {}) { let state = { kind: "idle" }; return { onTurnPrepare() { - state = { kind: "tracking", turnStartMs: nowMs(), workHappened: false }; + state = { kind: "tracking", turnStartMs: nowMs(), workHappened: false, alreadyShared: false }; }, - onToolCall() { + onToolCall(event) { if (state.kind === "tracking") { - state = { kind: "tracking", turnStartMs: state.turnStartMs, workHappened: true }; + state = { + kind: "tracking", + turnStartMs: state.turnStartMs, + workHappened: true, + alreadyShared: state.alreadyShared || hasMainframeVideoUrl(event.result), + }; } }, onFinalize(event) { - // Fail closed on the loop guard: only a turn the host reports as not - // already re-prompted may proceed, so a missing/ambiguous `stopHookActive` - // skips instead of risking a re-suggest. - if (event.stopHookActive !== false || state.kind === "idle") { + if (state.kind === "idle") { return undefined; } - // Consume the turn back to idle before deciding. A turn is suggested at - // most once: a re-finalize after a revise, or any later finalize that the - // host emits without a fresh `agent_turn_prepare`, hits the idle guard - // instead of reusing stale turn-start/work signals. - const { turnStartMs, workHappened } = state; + // Any finalize attempt spends the armed turn, so a re-finalize after a + // revise, or a later finalize the host emits without a fresh + // `agent_turn_prepare`, hits the idle guard instead of reusing stale + // turn-start/work signals. + const { turnStartMs, workHappened, alreadyShared } = state; state = { kind: "idle" }; - // The finalize event has no per-message timestamps, so the turn-scoped - // signals stand in for a transcript summary and run through the same stop - // policy as the other hosts. Only the current final answer is checked for - // an existing share; older history is intentionally not scanned so a stale - // Mainframe link cannot mute later turns. + // Fail closed on the loop guard: proceed only when the host explicitly + // reports the turn is not already being re-prompted. + if (event.stopHookActive !== false) { + return undefined; + } + // The turn-scoped signals stand in for a transcript summary and run + // through the same stop policy as the other hosts. A share counts when it + // appears in this turn's tool results or the current final answer; older + // history is intentionally not scanned so a stale link cannot mute later + // turns. const summary = { kind: "ready", lastUserTimeMs: turnStartMs, workHappened, - alreadyShared: hasMainframeVideoUrl(event.lastAssistantMessage), + alreadyShared: alreadyShared || hasMainframeVideoUrl(event.lastAssistantMessage), }; const decision = decideStop(summary, nowMs()); return decision.kind === "suggest" diff --git a/hooks/openclaw/register.test.ts b/hooks/openclaw/register.test.ts index 2d0009d..3e342cd 100644 --- a/hooks/openclaw/register.test.ts +++ b/hooks/openclaw/register.test.ts @@ -18,7 +18,7 @@ describe("OpenClaw before_agent_finalize hook", () => { it("asks for a revise after tool work once the user is away past the threshold", () => { const tracker = trackerAt([userTimeMs, awayTimeMs]); tracker.onTurnPrepare(); - tracker.onToolCall(); + tracker.onToolCall({}); const result = tracker.onFinalize({ stopHookActive: false }); @@ -30,7 +30,7 @@ describe("OpenClaw before_agent_finalize hook", () => { it("does not fire before the fixed one-hour threshold", () => { const tracker = trackerAt([userTimeMs, Date.parse("2026-05-08T13:30:00.000Z")]); tracker.onTurnPrepare(); - tracker.onToolCall(); + tracker.onToolCall({}); expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); }); @@ -51,15 +51,35 @@ describe("OpenClaw before_agent_finalize hook", () => { it("does not fire after a continuation already re-prompted the agent", () => { const tracker = trackerAt([userTimeMs, awayTimeMs]); tracker.onTurnPrepare(); - tracker.onToolCall(); + tracker.onToolCall({}); expect(tracker.onFinalize({ stopHookActive: true })).toBeUndefined(); }); + it("fails closed when the host omits stopHookActive", () => { + const tracker = trackerAt([userTimeMs, awayTimeMs]); + tracker.onTurnPrepare(); + tracker.onToolCall({}); + + expect(tracker.onFinalize({})).toBeUndefined(); + }); + + it("spends the armed turn on a skipped finalize, so a later finalize cannot fire from it", () => { + const tracker = trackerAt([userTimeMs, awayTimeMs, awayTimeMs]); + tracker.onTurnPrepare(); + tracker.onToolCall({}); + + // The host re-prompts (stopHookActive true), so this finalize is skipped... + expect(tracker.onFinalize({ stopHookActive: true })).toBeUndefined(); + // ...and the turn is now spent: a later finalize without a fresh turn must + // not revive the stale turn-start/work signals. + expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); + }); + it("suggests at most once per turn, so a repeat finalize does not re-fire", () => { const tracker = trackerAt([userTimeMs, awayTimeMs, awayTimeMs]); tracker.onTurnPrepare(); - tracker.onToolCall(); + tracker.onToolCall({}); expect(tracker.onFinalize({ stopHookActive: false })?.action).toBe("revise"); expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); @@ -69,7 +89,7 @@ describe("OpenClaw before_agent_finalize hook", () => { const laterTimeMs = Date.parse("2026-05-08T18:00:00.000Z"); const tracker = trackerAt([userTimeMs, awayTimeMs, awayTimeMs, laterTimeMs]); tracker.onTurnPrepare(); - tracker.onToolCall(); + tracker.onToolCall({}); expect(tracker.onFinalize({ stopHookActive: false })?.action).toBe("revise"); // A fresh turn with no tool work must not fire, even though wall-clock time @@ -81,7 +101,7 @@ describe("OpenClaw before_agent_finalize hook", () => { it("does not fire when the current final answer already shared a Mainframe video", () => { const tracker = trackerAt([userTimeMs, awayTimeMs]); tracker.onTurnPrepare(); - tracker.onToolCall(); + tracker.onToolCall({}); expect( tracker.onFinalize({ @@ -91,10 +111,21 @@ describe("OpenClaw before_agent_finalize hook", () => { ).toBeUndefined(); }); + it("does not fire when a Mainframe video appears in a tool result this turn", () => { + const tracker = trackerAt([userTimeMs, awayTimeMs]); + tracker.onTurnPrepare(); + tracker.onToolCall({ result: { watchUrl: sharedVideoUrl } }); + + // The final answer omits the link, but the share already happened via a tool. + expect( + tracker.onFinalize({ stopHookActive: false, lastAssistantMessage: "Shared the video." }), + ).toBeUndefined(); + }); + it("derives its suggestion from elapsed time only and never echoes turn content", () => { const tracker = trackerAt([userTimeMs, awayTimeMs]); tracker.onTurnPrepare(); - tracker.onToolCall(); + tracker.onToolCall({}); const result = tracker.onFinalize({ stopHookActive: false, diff --git a/hooks/openclaw/runtime.ts b/hooks/openclaw/runtime.ts index 874f7f4..f7e8c5c 100644 --- a/hooks/openclaw/runtime.ts +++ b/hooks/openclaw/runtime.ts @@ -4,30 +4,33 @@ import { hasMainframeVideoUrl, type TranscriptSummary } from "../core/transcript // Minimal local mirror of the OpenClaw plugin hook surface this plugin uses, // per https://docs.openclaw.ai/plugins/hooks (openclaw 2026.6.1). Like the // Cursor, Codex, and Claude Code hooks, this models the host contract locally -// instead of depending on a host SDK. +// instead of depending on a host SDK. `stopHookActive` is optional so the guard +// can fail closed when the host omits or malforms it, and the assistant/tool +// payloads are `unknown` because `hasMainframeVideoUrl` already recurses over +// strings, arrays, and records. export type OpenClawFinalizeEvent = { - // Optional at this trust boundary so the guard can fail closed when the host - // omits or malforms it, rather than coercing a missing value to "not active". stopHookActive?: boolean; - // The host contract allows structured assistant content; `hasMainframeVideoUrl` - // recurses over strings, arrays, and records, so keep this as `unknown`. lastAssistantMessage?: unknown; }; +export type OpenClawToolCallEvent = { + result?: unknown; +}; + export type OpenClawReviseResult = { action: "revise"; reason: string }; // OpenClaw exposes a final-answer review gate (`before_agent_finalize`) instead // of the stdin/stdout Stop hook the other hosts use, and its event carries no // per-message wall-clock time. Elapsed time is therefore measured across the // turn: `agent_turn_prepare` marks the start, `after_tool_call` records that -// work happened, and `before_agent_finalize` feeds the shared stop policy to -// ask for one more pass that leaves a Mainframe video. The tracker fails closed: -// it suggests at most once per armed turn (finalize consumes the turn back to -// idle), only proceeds when the host reports `stopHookActive === false`, and -// never suggests without a turn start and observed tool work. +// work happened and watches tool results for an existing Mainframe share, and +// `before_agent_finalize` feeds the turn-scoped signals into the shared stop +// policy. The tracker fails closed: any finalize spends the armed turn (so it +// suggests at most once), it proceeds only on an explicit `stopHookActive === +// false`, and it never suggests without a turn start and observed tool work. export type OpenClawPluginApi = { on(hookName: "agent_turn_prepare", handler: () => void): void; - on(hookName: "after_tool_call", handler: () => void): void; + on(hookName: "after_tool_call", handler: (event: OpenClawToolCallEvent) => void): void; on( hookName: "before_agent_finalize", handler: (event: OpenClawFinalizeEvent) => OpenClawReviseResult | undefined, @@ -36,13 +39,13 @@ export type OpenClawPluginApi = { export type MainframeFinalizeTracker = { onTurnPrepare: () => void; - onToolCall: () => void; + onToolCall: (event: OpenClawToolCallEvent) => void; onFinalize: (event: OpenClawFinalizeEvent) => OpenClawReviseResult | undefined; }; type TrackerState = | { kind: "idle" } - | { kind: "tracking"; turnStartMs: number; workHappened: boolean }; + | { kind: "tracking"; turnStartMs: number; workHappened: boolean; alreadyShared: boolean }; export type MainframeTrackerOptions = { nowMs?: () => number; @@ -56,40 +59,48 @@ export function createMainframeFinalizeTracker( return { onTurnPrepare(): void { - state = { kind: "tracking", turnStartMs: nowMs(), workHappened: false }; + state = { kind: "tracking", turnStartMs: nowMs(), workHappened: false, alreadyShared: false }; }, - onToolCall(): void { + onToolCall(event: OpenClawToolCallEvent): void { if (state.kind === "tracking") { - state = { kind: "tracking", turnStartMs: state.turnStartMs, workHappened: true }; + state = { + kind: "tracking", + turnStartMs: state.turnStartMs, + workHappened: true, + alreadyShared: state.alreadyShared || hasMainframeVideoUrl(event.result), + }; } }, onFinalize(event: OpenClawFinalizeEvent): OpenClawReviseResult | undefined { - // Fail closed on the loop guard: only a turn the host reports as not - // already re-prompted may proceed, so a missing/ambiguous `stopHookActive` - // skips instead of risking a re-suggest. - if (event.stopHookActive !== false || state.kind === "idle") { + if (state.kind === "idle") { return undefined; } - // Consume the turn back to idle before deciding. A turn is suggested at - // most once: a re-finalize after a revise, or any later finalize that the - // host emits without a fresh `agent_turn_prepare`, hits the idle guard - // instead of reusing stale turn-start/work signals. - const { turnStartMs, workHappened } = state; + // Any finalize attempt spends the armed turn, so a re-finalize after a + // revise, or a later finalize the host emits without a fresh + // `agent_turn_prepare`, hits the idle guard instead of reusing stale + // turn-start/work signals. + const { turnStartMs, workHappened, alreadyShared } = state; state = { kind: "idle" }; - // The finalize event has no per-message timestamps, so the turn-scoped - // signals stand in for a transcript summary and run through the same stop - // policy as the other hosts. Only the current final answer is checked for - // an existing share; older history is intentionally not scanned so a stale - // Mainframe link cannot mute later turns. + // Fail closed on the loop guard: proceed only when the host explicitly + // reports the turn is not already being re-prompted. + if (event.stopHookActive !== false) { + return undefined; + } + + // The turn-scoped signals stand in for a transcript summary and run + // through the same stop policy as the other hosts. A share counts when it + // appears in this turn's tool results or the current final answer; older + // history is intentionally not scanned so a stale link cannot mute later + // turns. const summary: TranscriptSummary = { kind: "ready", lastUserTimeMs: turnStartMs, workHappened, - alreadyShared: hasMainframeVideoUrl(event.lastAssistantMessage), + alreadyShared: alreadyShared || hasMainframeVideoUrl(event.lastAssistantMessage), }; const decision = decideStop(summary, nowMs()); From 9c27a9994273cc202810a4c64ca9db20016fa33d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 23:23:01 +0000 Subject: [PATCH 08/10] Tolerate a missing/malformed OpenClaw event as a no-op Final fail-closed gap from the independent re-reviews: if the host ever invokes a hook with a null/non-object event, the tracker dereferenced it and threw instead of failing closed. Make the event params optional and read fields via optional chaining so a missing event reads as undefined and skips (after the turn is consumed). Add a regression test that both hooks tolerate a missing event without throwing. Held with rationale (raised again, already resolved): runtimeExtensions-only is correct (ClawHub publish never reads extensions/runtimeExtensions; it is the documented field for built installed packages), and the alreadyShared tool-result check intentionally mirrors hooks/core/transcript.ts (suppression is the fail-closed direction and keeps parity with the other hosts). --- bun.lock | 1 + dist/hooks/openclaw/runtime.js | 9 +++++---- hooks/openclaw/register.test.ts | 8 ++++++++ hooks/openclaw/runtime.ts | 20 +++++++++++--------- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/bun.lock b/bun.lock index 8e1c213..7220acc 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@mainframe/plugins", diff --git a/dist/hooks/openclaw/runtime.js b/dist/hooks/openclaw/runtime.js index e3167c7..0d4a9cd 100644 --- a/dist/hooks/openclaw/runtime.js +++ b/dist/hooks/openclaw/runtime.js @@ -13,7 +13,7 @@ export function createMainframeFinalizeTracker(options = {}) { kind: "tracking", turnStartMs: state.turnStartMs, workHappened: true, - alreadyShared: state.alreadyShared || hasMainframeVideoUrl(event.result), + alreadyShared: state.alreadyShared || hasMainframeVideoUrl(event?.result), }; } }, @@ -28,8 +28,9 @@ export function createMainframeFinalizeTracker(options = {}) { const { turnStartMs, workHappened, alreadyShared } = state; state = { kind: "idle" }; // Fail closed on the loop guard: proceed only when the host explicitly - // reports the turn is not already being re-prompted. - if (event.stopHookActive !== false) { + // reports the turn is not already being re-prompted. A missing or + // malformed event reads as `undefined` here and skips. + if (event?.stopHookActive !== false) { return undefined; } // The turn-scoped signals stand in for a transcript summary and run @@ -41,7 +42,7 @@ export function createMainframeFinalizeTracker(options = {}) { kind: "ready", lastUserTimeMs: turnStartMs, workHappened, - alreadyShared: alreadyShared || hasMainframeVideoUrl(event.lastAssistantMessage), + alreadyShared: alreadyShared || hasMainframeVideoUrl(event?.lastAssistantMessage), }; const decision = decideStop(summary, nowMs()); return decision.kind === "suggest" diff --git a/hooks/openclaw/register.test.ts b/hooks/openclaw/register.test.ts index 3e342cd..799607a 100644 --- a/hooks/openclaw/register.test.ts +++ b/hooks/openclaw/register.test.ts @@ -64,6 +64,14 @@ describe("OpenClaw before_agent_finalize hook", () => { expect(tracker.onFinalize({})).toBeUndefined(); }); + it("tolerates a missing event on both hooks without throwing", () => { + const tracker = trackerAt([userTimeMs, awayTimeMs]); + tracker.onTurnPrepare(); + tracker.onToolCall(); + + expect(tracker.onFinalize()).toBeUndefined(); + }); + it("spends the armed turn on a skipped finalize, so a later finalize cannot fire from it", () => { const tracker = trackerAt([userTimeMs, awayTimeMs, awayTimeMs]); tracker.onTurnPrepare(); diff --git a/hooks/openclaw/runtime.ts b/hooks/openclaw/runtime.ts index f7e8c5c..429fd7a 100644 --- a/hooks/openclaw/runtime.ts +++ b/hooks/openclaw/runtime.ts @@ -27,7 +27,8 @@ export type OpenClawReviseResult = { action: "revise"; reason: string }; // `before_agent_finalize` feeds the turn-scoped signals into the shared stop // policy. The tracker fails closed: any finalize spends the armed turn (so it // suggests at most once), it proceeds only on an explicit `stopHookActive === -// false`, and it never suggests without a turn start and observed tool work. +// false`, it never suggests without a turn start and observed tool work, and a +// missing or malformed event is tolerated as a no-op rather than throwing. export type OpenClawPluginApi = { on(hookName: "agent_turn_prepare", handler: () => void): void; on(hookName: "after_tool_call", handler: (event: OpenClawToolCallEvent) => void): void; @@ -39,8 +40,8 @@ export type OpenClawPluginApi = { export type MainframeFinalizeTracker = { onTurnPrepare: () => void; - onToolCall: (event: OpenClawToolCallEvent) => void; - onFinalize: (event: OpenClawFinalizeEvent) => OpenClawReviseResult | undefined; + onToolCall: (event?: OpenClawToolCallEvent) => void; + onFinalize: (event?: OpenClawFinalizeEvent) => OpenClawReviseResult | undefined; }; type TrackerState = @@ -62,18 +63,18 @@ export function createMainframeFinalizeTracker( state = { kind: "tracking", turnStartMs: nowMs(), workHappened: false, alreadyShared: false }; }, - onToolCall(event: OpenClawToolCallEvent): void { + onToolCall(event?: OpenClawToolCallEvent): void { if (state.kind === "tracking") { state = { kind: "tracking", turnStartMs: state.turnStartMs, workHappened: true, - alreadyShared: state.alreadyShared || hasMainframeVideoUrl(event.result), + alreadyShared: state.alreadyShared || hasMainframeVideoUrl(event?.result), }; } }, - onFinalize(event: OpenClawFinalizeEvent): OpenClawReviseResult | undefined { + onFinalize(event?: OpenClawFinalizeEvent): OpenClawReviseResult | undefined { if (state.kind === "idle") { return undefined; } @@ -86,8 +87,9 @@ export function createMainframeFinalizeTracker( state = { kind: "idle" }; // Fail closed on the loop guard: proceed only when the host explicitly - // reports the turn is not already being re-prompted. - if (event.stopHookActive !== false) { + // reports the turn is not already being re-prompted. A missing or + // malformed event reads as `undefined` here and skips. + if (event?.stopHookActive !== false) { return undefined; } @@ -100,7 +102,7 @@ export function createMainframeFinalizeTracker( kind: "ready", lastUserTimeMs: turnStartMs, workHappened, - alreadyShared: alreadyShared || hasMainframeVideoUrl(event.lastAssistantMessage), + alreadyShared: alreadyShared || hasMainframeVideoUrl(event?.lastAssistantMessage), }; const decision = decideStop(summary, nowMs()); From 26992786dd8022e8d2bea551d727b2fbd25d816d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 16:44:11 +0000 Subject: [PATCH 09/10] Make OpenClaw a bundle host instead of a native plugin Per multi-model council review: the share-video skill is useless without the Mainframe MCP tools, and OpenClaw auto-wires both the skill and the hosted HTTP MCP from the existing .cursor/.codex/.claude bundle markers + .mcp.json with zero config. A native plugin (the prior approach) cannot auto-register a hosted HTTP MCP (registerMcpServer is stdio-only), so it forced users to hand-edit openclaw.json or the skill was dead on arrival. Remove the native OpenClaw surface and rely on bundle detection: - delete hooks/openclaw/* (runtime, register, tests), openclaw.plugin.json, and the dist/hooks/openclaw build output - revert tooling/generate.ts (no openclaw manifest or package.json openclaw block), the drift-check/package-surface/manifest-test/release-archive entries, and the package.json scripts/files/description - drop the package-publish jobs from clawhub-publish.yml (keep skill-publish; the bundle is not a ClawHub code-plugin) - README: OpenClaw installs as a bundle (auto skill + MCP, no stop hook); AGENTS: note OpenClaw reuses the bundle markers The conservative AFK stop hook remains on Cursor/Codex/Claude, where the Stop hook contract actually runs. --- .github/workflows/clawhub-publish.yml | 21 ---- AGENTS.md | 24 ++-- README.md | 48 +++----- dist/hooks/openclaw/register.js | 11 -- dist/hooks/openclaw/runtime.js | 62 ---------- hooks/openclaw/register.test.ts | 164 -------------------------- hooks/openclaw/register.ts | 24 ---- hooks/openclaw/runtime.ts | 127 -------------------- openclaw.plugin.json | 14 --- package.json | 20 +--- tooling/generate.ts | 36 +----- tooling/generated-drift-check.ts | 1 - tooling/manifest.test.ts | 44 ------- tooling/package-surface.ts | 4 - tooling/release-archive.ts | 2 - 15 files changed, 34 insertions(+), 568 deletions(-) delete mode 100644 dist/hooks/openclaw/register.js delete mode 100644 dist/hooks/openclaw/runtime.js delete mode 100644 hooks/openclaw/register.test.ts delete mode 100644 hooks/openclaw/register.ts delete mode 100644 hooks/openclaw/runtime.ts delete mode 100644 openclaw.plugin.json diff --git a/.github/workflows/clawhub-publish.yml b/.github/workflows/clawhub-publish.yml index fab4784..5f0470f 100644 --- a/.github/workflows/clawhub-publish.yml +++ b/.github/workflows/clawhub-publish.yml @@ -22,15 +22,6 @@ jobs: with: dry_run: true - package-dry-run: - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - permissions: - contents: read - id-token: write - uses: openclaw/clawhub/.github/workflows/package-publish.yml@v0.19.0 - with: - dry_run: true - skill-publish: if: github.event_name == 'workflow_dispatch' permissions: @@ -42,15 +33,3 @@ jobs: dry_run: false secrets: clawhub_token: ${{ secrets.CLAWHUB_TOKEN }} - - package-publish: - if: github.event_name == 'workflow_dispatch' - permissions: - contents: read - id-token: write - uses: openclaw/clawhub/.github/workflows/package-publish.yml@v0.19.0 - with: - owner: ${{ inputs.owner }} - dry_run: false - secrets: - clawhub_token: ${{ secrets.CLAWHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 50684b6..2ca2a1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,26 +1,28 @@ # Agent notes -This repository packages the Mainframe Cursor, Codex, Claude Code, and OpenClaw plugins. Keep it -focused on the Cursor, Codex, Claude Code, and OpenClaw manifests, hosted MCP wiring, the -`share-video` skill, and the stop hooks. +This repository packages the Mainframe Cursor, Codex, and Claude Code plugins. Keep it focused on +the Cursor, Codex, and Claude Code manifests, hosted MCP wiring, the `share-video` skill, and the +stop hooks. OpenClaw installs the same package as a bundle (reusing those host markers, the +`./.mcp.json` wiring, and `skills/`), so it needs no OpenClaw-specific manifest or code. ## Repository boundaries - User-visible copy should say "Mainframe", not legacy product names. - Do not add secrets, customer data, private URLs, or private business context. -- Cursor, Codex, Claude Code, and OpenClaw are the supported hosts. All plugins share the repo root, +- Cursor, Codex, and Claude Code are the supported hosts. All three plugins share the repo root, the `share-video` skill, the `./.mcp.json` wiring, and the `hooks/core` runtime. Codex and Claude Code share the same Stop hook contract, so they both use `hooks/core/stop-hook.ts`; only the - transcript parser differs per host. OpenClaw is a native plugin host instead: it loads - `hooks/openclaw/register.ts` and maps the shared AFK gate onto the `before_agent_finalize` - lifecycle hook. Do not add other host surfaces unless the product task explicitly asks for them. + transcript parser differs per host. OpenClaw reuses these same bundle markers, MCP wiring, and + skill (auto-detected as a bundle), so it gets the skill and MCP without a separate manifest; + bundles do not run the stop hook. Do not add other host surfaces unless the product task + explicitly asks for them. - Run `bun run verify` before considering changes ready. -- Generated Cursor, Codex, Claude Code, and OpenClaw manifest and marketplace files come from +- Generated Cursor, Codex, and Claude Code manifest and marketplace files come from `tooling/generate.ts`; edit the config there, then run `bun run generate`. - The canonical skill is `skills/share-video/SKILL.md`. -- Keep generated metadata in `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`, - `.claude-plugin/`, and `openclaw.plugin.json`. Do not add package-local docs or extra top-level - markdown unless the user asks; improve `README.md` or this file instead. +- Keep generated metadata in `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`, and + `.claude-plugin/`. Do not add package-local docs or extra top-level markdown unless the user + asks; improve `README.md` or this file instead. ## Start here diff --git a/README.md b/README.md index aa617d2..9c46082 100644 --- a/README.md +++ b/README.md @@ -49,50 +49,34 @@ and Codex plugins. ### OpenClaw -Install the Mainframe plugin from [ClawHub](https://clawhub.ai), the public skill and plugin -registry for OpenClaw, with the OpenClaw plugin manager: +OpenClaw consumes this package as a **bundle**: it reads the same `.cursor-plugin`, +`.codex-plugin`, and `.claude-plugin` markers, the `./.mcp.json` wiring, and the `skills/` +directory the other hosts already ship — no OpenClaw-specific manifest or code. Installing it +auto-loads the `share-video` skill and connects the hosted Mainframe MCP server (which provides the +`generate_video`, `upload_video`, and `get_video` tools), with no manual configuration: ```sh -openclaw plugins install clawhub:mainframe +openclaw plugins install mainframecomputer/mainframe-plugins ``` -The plugin gives OpenClaw the `share-video` skill and a native `before_agent_finalize` hook that -suggests a short video after a long, unattended run — the same conservative AFK behavior as the -other hosts' stop hooks, reusing the shared `hooks/core` runtime. Native OpenClaw plugins cannot -register an MCP server for you, and conversation hooks need explicit access, so add the hosted -Mainframe MCP server and hook access to your `openclaw.json`: - -```json -{ - "mcp": { - "servers": { - "mainframe": { "type": "http", "url": "https://mcp.mainframe.app/mcp" } - } - }, - "plugins": { - "entries": { - "mainframe": { "hooks": { "allowConversationAccess": true } } - } - } -} -``` +OpenClaw bundles do not run agent-lifecycle hooks, so the conservative AFK "leave a video" stop hook +is Cursor, Codex, and Claude Code only. On OpenClaw the `share-video` skill itself guides the agent +on when a short video is worthwhile. -#### Publishing to ClawHub +#### Publishing the skill to ClawHub -The canonical `share-video` skill is also published to ClawHub on its own, so any agent can install -just the skill (its `generate_video`, `upload_video`, and `get_video` tools come from the hosted -Mainframe MCP server, wired up separately): +The canonical `share-video` skill is also published to [ClawHub](https://clawhub.ai), the public +skill registry for OpenClaw, so any agent can install just the skill: ```sh clawhub install share-video ``` Publishing is automated by [`.github/workflows/clawhub-publish.yml`](.github/workflows/clawhub-publish.yml), -which reuses ClawHub's official `skill-publish` and `package-publish` reusable workflows instead of -duplicating publish logic. Pull requests run dry-run previews, and a manual `workflow_dispatch` run -publishes both the skill and the plugin package. A real publish needs a `CLAWHUB_TOKEN` repository -secret and an `owner` handle (the package scope `@mainframe` must match that owner); publishing the -skill to ClawHub releases it under `MIT-0`. +which reuses ClawHub's official `skill-publish` reusable workflow instead of duplicating publish +logic. Pull requests run a dry-run preview, and a manual `workflow_dispatch` run performs the real +publish. A real publish needs a `CLAWHUB_TOKEN` repository secret and an `owner` handle; publishing +the skill to ClawHub releases it under `MIT-0`. ## Included skill diff --git a/dist/hooks/openclaw/register.js b/dist/hooks/openclaw/register.js deleted file mode 100644 index c68987a..0000000 --- a/dist/hooks/openclaw/register.js +++ /dev/null @@ -1,11 +0,0 @@ -import { registerMainframeHooks } from "./runtime.js"; -const plugin = { - id: "mainframe", - name: "Mainframe", - description: "Create and share short video updates from agent work.", - register(api) { - registerMainframeHooks(api); - }, -}; -export default plugin; -export { registerMainframeHooks }; diff --git a/dist/hooks/openclaw/runtime.js b/dist/hooks/openclaw/runtime.js deleted file mode 100644 index 0d4a9cd..0000000 --- a/dist/hooks/openclaw/runtime.js +++ /dev/null @@ -1,62 +0,0 @@ -import { decideStop } from "../core/stop-policy.js"; -import { hasMainframeVideoUrl } from "../core/transcript.js"; -export function createMainframeFinalizeTracker(options = {}) { - const nowMs = options.nowMs ?? (() => Date.now()); - let state = { kind: "idle" }; - return { - onTurnPrepare() { - state = { kind: "tracking", turnStartMs: nowMs(), workHappened: false, alreadyShared: false }; - }, - onToolCall(event) { - if (state.kind === "tracking") { - state = { - kind: "tracking", - turnStartMs: state.turnStartMs, - workHappened: true, - alreadyShared: state.alreadyShared || hasMainframeVideoUrl(event?.result), - }; - } - }, - onFinalize(event) { - if (state.kind === "idle") { - return undefined; - } - // Any finalize attempt spends the armed turn, so a re-finalize after a - // revise, or a later finalize the host emits without a fresh - // `agent_turn_prepare`, hits the idle guard instead of reusing stale - // turn-start/work signals. - const { turnStartMs, workHappened, alreadyShared } = state; - state = { kind: "idle" }; - // Fail closed on the loop guard: proceed only when the host explicitly - // reports the turn is not already being re-prompted. A missing or - // malformed event reads as `undefined` here and skips. - if (event?.stopHookActive !== false) { - return undefined; - } - // The turn-scoped signals stand in for a transcript summary and run - // through the same stop policy as the other hosts. A share counts when it - // appears in this turn's tool results or the current final answer; older - // history is intentionally not scanned so a stale link cannot mute later - // turns. - const summary = { - kind: "ready", - lastUserTimeMs: turnStartMs, - workHappened, - alreadyShared: alreadyShared || hasMainframeVideoUrl(event?.lastAssistantMessage), - }; - const decision = decideStop(summary, nowMs()); - return decision.kind === "suggest" - ? { action: "revise", reason: decision.message } - : undefined; - }, - }; -} -export function registerMainframeHooks(api, options = {}) { - const tracker = createMainframeFinalizeTracker(options); - // The tracker methods close over local state, not `this`, so the host calling - // them detached is safe. Keep them `this`-free if this is ever refactored. - api.on("agent_turn_prepare", tracker.onTurnPrepare); - api.on("after_tool_call", tracker.onToolCall); - api.on("before_agent_finalize", tracker.onFinalize); - return tracker; -} diff --git a/hooks/openclaw/register.test.ts b/hooks/openclaw/register.test.ts deleted file mode 100644 index 799607a..0000000 --- a/hooks/openclaw/register.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import plugin, { registerMainframeHooks } from "./register.js"; -import { createMainframeFinalizeTracker, type OpenClawPluginApi } from "./runtime.js"; - -const userTimeMs = Date.parse("2026-05-08T13:00:00.000Z"); -const awayTimeMs = Date.parse("2026-05-08T15:30:00.000Z"); -const sharedVideoUrl = "https://mainframe.app/v/37507089004e8f3700deb918a48b2556"; - -function trackerAt(times: readonly number[]): ReturnType { - let index = 0; - return createMainframeFinalizeTracker({ - nowMs: () => times[Math.min(index++, times.length - 1)] ?? awayTimeMs, - }); -} - -describe("OpenClaw before_agent_finalize hook", () => { - it("asks for a revise after tool work once the user is away past the threshold", () => { - const tracker = trackerAt([userTimeMs, awayTimeMs]); - tracker.onTurnPrepare(); - tracker.onToolCall({}); - - const result = tracker.onFinalize({ stopHookActive: false }); - - expect(result?.action).toBe("revise"); - expect(result?.reason).toContain("2.5 hours"); - expect(result?.reason).toContain("share-video"); - }); - - it("does not fire before the fixed one-hour threshold", () => { - const tracker = trackerAt([userTimeMs, Date.parse("2026-05-08T13:30:00.000Z")]); - tracker.onTurnPrepare(); - tracker.onToolCall({}); - - expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); - }); - - it("does not fire when no tool work happened since the turn began", () => { - const tracker = trackerAt([userTimeMs, awayTimeMs]); - tracker.onTurnPrepare(); - - expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); - }); - - it("does not fire when finalize arrives before any turn began", () => { - const tracker = trackerAt([awayTimeMs]); - - expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); - }); - - it("does not fire after a continuation already re-prompted the agent", () => { - const tracker = trackerAt([userTimeMs, awayTimeMs]); - tracker.onTurnPrepare(); - tracker.onToolCall({}); - - expect(tracker.onFinalize({ stopHookActive: true })).toBeUndefined(); - }); - - it("fails closed when the host omits stopHookActive", () => { - const tracker = trackerAt([userTimeMs, awayTimeMs]); - tracker.onTurnPrepare(); - tracker.onToolCall({}); - - expect(tracker.onFinalize({})).toBeUndefined(); - }); - - it("tolerates a missing event on both hooks without throwing", () => { - const tracker = trackerAt([userTimeMs, awayTimeMs]); - tracker.onTurnPrepare(); - tracker.onToolCall(); - - expect(tracker.onFinalize()).toBeUndefined(); - }); - - it("spends the armed turn on a skipped finalize, so a later finalize cannot fire from it", () => { - const tracker = trackerAt([userTimeMs, awayTimeMs, awayTimeMs]); - tracker.onTurnPrepare(); - tracker.onToolCall({}); - - // The host re-prompts (stopHookActive true), so this finalize is skipped... - expect(tracker.onFinalize({ stopHookActive: true })).toBeUndefined(); - // ...and the turn is now spent: a later finalize without a fresh turn must - // not revive the stale turn-start/work signals. - expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); - }); - - it("suggests at most once per turn, so a repeat finalize does not re-fire", () => { - const tracker = trackerAt([userTimeMs, awayTimeMs, awayTimeMs]); - tracker.onTurnPrepare(); - tracker.onToolCall({}); - - expect(tracker.onFinalize({ stopHookActive: false })?.action).toBe("revise"); - expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); - }); - - it("re-arms per turn and does not carry stale work into the next turn", () => { - const laterTimeMs = Date.parse("2026-05-08T18:00:00.000Z"); - const tracker = trackerAt([userTimeMs, awayTimeMs, awayTimeMs, laterTimeMs]); - tracker.onTurnPrepare(); - tracker.onToolCall({}); - expect(tracker.onFinalize({ stopHookActive: false })?.action).toBe("revise"); - - // A fresh turn with no tool work must not fire, even though wall-clock time - // since the previous turn exceeds the threshold. - tracker.onTurnPrepare(); - expect(tracker.onFinalize({ stopHookActive: false })).toBeUndefined(); - }); - - it("does not fire when the current final answer already shared a Mainframe video", () => { - const tracker = trackerAt([userTimeMs, awayTimeMs]); - tracker.onTurnPrepare(); - tracker.onToolCall({}); - - expect( - tracker.onFinalize({ - stopHookActive: false, - lastAssistantMessage: `Shared: ${sharedVideoUrl}`, - }), - ).toBeUndefined(); - }); - - it("does not fire when a Mainframe video appears in a tool result this turn", () => { - const tracker = trackerAt([userTimeMs, awayTimeMs]); - tracker.onTurnPrepare(); - tracker.onToolCall({ result: { watchUrl: sharedVideoUrl } }); - - // The final answer omits the link, but the share already happened via a tool. - expect( - tracker.onFinalize({ stopHookActive: false, lastAssistantMessage: "Shared the video." }), - ).toBeUndefined(); - }); - - it("derives its suggestion from elapsed time only and never echoes turn content", () => { - const tracker = trackerAt([userTimeMs, awayTimeMs]); - tracker.onTurnPrepare(); - tracker.onToolCall({}); - - const result = tracker.onFinalize({ - stopHookActive: false, - lastAssistantMessage: "SECRET_NEVER_LEAK", - }); - - expect(result?.reason).not.toContain("SECRET_NEVER_LEAK"); - }); - - it("exports a default plugin entry that registers the OpenClaw hook names in order", () => { - expect(plugin).toMatchObject({ - id: "mainframe", - name: "Mainframe", - description: "Create and share short video updates from agent work.", - }); - expect(typeof plugin.register).toBe("function"); - - const names: string[] = []; - const api: OpenClawPluginApi = { - on(hookName: string): void { - names.push(hookName); - }, - }; - registerMainframeHooks(api); - - expect(names).toEqual(["agent_turn_prepare", "after_tool_call", "before_agent_finalize"]); - }); -}); diff --git a/hooks/openclaw/register.ts b/hooks/openclaw/register.ts deleted file mode 100644 index 31f3475..0000000 --- a/hooks/openclaw/register.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type OpenClawPluginApi, registerMainframeHooks } from "./runtime.js"; - -// OpenClaw loads this default export and calls `register(api)` once. We model -// the plugin entry shape locally rather than importing the openclaw SDK just -// for its `definePluginEntry` identity helper, keeping this package free of a -// host dependency like the other hosts. -export type OpenClawPluginEntry = { - id: string; - name: string; - description: string; - register: (api: OpenClawPluginApi) => void; -}; - -const plugin: OpenClawPluginEntry = { - id: "mainframe", - name: "Mainframe", - description: "Create and share short video updates from agent work.", - register(api: OpenClawPluginApi): void { - registerMainframeHooks(api); - }, -}; - -export default plugin; -export { registerMainframeHooks }; diff --git a/hooks/openclaw/runtime.ts b/hooks/openclaw/runtime.ts deleted file mode 100644 index 429fd7a..0000000 --- a/hooks/openclaw/runtime.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { decideStop } from "../core/stop-policy.js"; -import { hasMainframeVideoUrl, type TranscriptSummary } from "../core/transcript.js"; - -// Minimal local mirror of the OpenClaw plugin hook surface this plugin uses, -// per https://docs.openclaw.ai/plugins/hooks (openclaw 2026.6.1). Like the -// Cursor, Codex, and Claude Code hooks, this models the host contract locally -// instead of depending on a host SDK. `stopHookActive` is optional so the guard -// can fail closed when the host omits or malforms it, and the assistant/tool -// payloads are `unknown` because `hasMainframeVideoUrl` already recurses over -// strings, arrays, and records. -export type OpenClawFinalizeEvent = { - stopHookActive?: boolean; - lastAssistantMessage?: unknown; -}; - -export type OpenClawToolCallEvent = { - result?: unknown; -}; - -export type OpenClawReviseResult = { action: "revise"; reason: string }; - -// OpenClaw exposes a final-answer review gate (`before_agent_finalize`) instead -// of the stdin/stdout Stop hook the other hosts use, and its event carries no -// per-message wall-clock time. Elapsed time is therefore measured across the -// turn: `agent_turn_prepare` marks the start, `after_tool_call` records that -// work happened and watches tool results for an existing Mainframe share, and -// `before_agent_finalize` feeds the turn-scoped signals into the shared stop -// policy. The tracker fails closed: any finalize spends the armed turn (so it -// suggests at most once), it proceeds only on an explicit `stopHookActive === -// false`, it never suggests without a turn start and observed tool work, and a -// missing or malformed event is tolerated as a no-op rather than throwing. -export type OpenClawPluginApi = { - on(hookName: "agent_turn_prepare", handler: () => void): void; - on(hookName: "after_tool_call", handler: (event: OpenClawToolCallEvent) => void): void; - on( - hookName: "before_agent_finalize", - handler: (event: OpenClawFinalizeEvent) => OpenClawReviseResult | undefined, - ): void; -}; - -export type MainframeFinalizeTracker = { - onTurnPrepare: () => void; - onToolCall: (event?: OpenClawToolCallEvent) => void; - onFinalize: (event?: OpenClawFinalizeEvent) => OpenClawReviseResult | undefined; -}; - -type TrackerState = - | { kind: "idle" } - | { kind: "tracking"; turnStartMs: number; workHappened: boolean; alreadyShared: boolean }; - -export type MainframeTrackerOptions = { - nowMs?: () => number; -}; - -export function createMainframeFinalizeTracker( - options: MainframeTrackerOptions = {}, -): MainframeFinalizeTracker { - const nowMs = options.nowMs ?? (() => Date.now()); - let state: TrackerState = { kind: "idle" }; - - return { - onTurnPrepare(): void { - state = { kind: "tracking", turnStartMs: nowMs(), workHappened: false, alreadyShared: false }; - }, - - onToolCall(event?: OpenClawToolCallEvent): void { - if (state.kind === "tracking") { - state = { - kind: "tracking", - turnStartMs: state.turnStartMs, - workHappened: true, - alreadyShared: state.alreadyShared || hasMainframeVideoUrl(event?.result), - }; - } - }, - - onFinalize(event?: OpenClawFinalizeEvent): OpenClawReviseResult | undefined { - if (state.kind === "idle") { - return undefined; - } - - // Any finalize attempt spends the armed turn, so a re-finalize after a - // revise, or a later finalize the host emits without a fresh - // `agent_turn_prepare`, hits the idle guard instead of reusing stale - // turn-start/work signals. - const { turnStartMs, workHappened, alreadyShared } = state; - state = { kind: "idle" }; - - // Fail closed on the loop guard: proceed only when the host explicitly - // reports the turn is not already being re-prompted. A missing or - // malformed event reads as `undefined` here and skips. - if (event?.stopHookActive !== false) { - return undefined; - } - - // The turn-scoped signals stand in for a transcript summary and run - // through the same stop policy as the other hosts. A share counts when it - // appears in this turn's tool results or the current final answer; older - // history is intentionally not scanned so a stale link cannot mute later - // turns. - const summary: TranscriptSummary = { - kind: "ready", - lastUserTimeMs: turnStartMs, - workHappened, - alreadyShared: alreadyShared || hasMainframeVideoUrl(event?.lastAssistantMessage), - }; - const decision = decideStop(summary, nowMs()); - - return decision.kind === "suggest" - ? { action: "revise", reason: decision.message } - : undefined; - }, - }; -} - -export function registerMainframeHooks( - api: OpenClawPluginApi, - options: MainframeTrackerOptions = {}, -): MainframeFinalizeTracker { - const tracker = createMainframeFinalizeTracker(options); - // The tracker methods close over local state, not `this`, so the host calling - // them detached is safe. Keep them `this`-free if this is ever refactored. - api.on("agent_turn_prepare", tracker.onTurnPrepare); - api.on("after_tool_call", tracker.onToolCall); - api.on("before_agent_finalize", tracker.onFinalize); - return tracker; -} diff --git a/openclaw.plugin.json b/openclaw.plugin.json deleted file mode 100644 index d9ade50..0000000 --- a/openclaw.plugin.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "id": "mainframe", - "name": "Mainframe", - "description": "Create and share short video updates from agent work.", - "version": "0.1.0", - "skills": ["./skills"], - "activation": { - "onStartup": true - }, - "configSchema": { - "type": "object", - "additionalProperties": false - } -} diff --git a/package.json b/package.json index 7c74de0..9e84996 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@mainframe/plugins", "version": "0.1.0", "private": true, - "description": "Mainframe Cursor, Codex, Claude Code, and OpenClaw plugin manifests, skill, MCP wiring, and stop hooks.", + "description": "Mainframe Cursor, Codex, and Claude Code plugin manifests, skill, MCP wiring, and stop hooks.", "keywords": [ "agent-skills", "hooks", @@ -27,7 +27,6 @@ ".codex-plugin", ".cursor-plugin", ".mcp.json", - "openclaw.plugin.json", "LICENSE", "README.md", "assets/logo.png", @@ -42,9 +41,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "build": "rm -rf dist && tsc -p tsconfig.build.json && bun tooling/mark-executable.ts", "build:check": "bun run build && git diff --exit-code -- dist", - "generate": "bun tooling/generate.ts && oxfmt --write package.json .cursor-plugin .codex-plugin .agents .claude-plugin openclaw.plugin.json", + "generate": "bun tooling/generate.ts && oxfmt --write package.json .cursor-plugin .codex-plugin .agents .claude-plugin", "check:generated": "bun tooling/generated-drift-check.ts", - "archive:release": "bun run generate && bun run build && git diff --exit-code -- package.json .cursor-plugin .codex-plugin .agents .claude-plugin openclaw.plugin.json dist && bun tooling/release-archive.ts", + "archive:release": "bun run generate && bun run build && git diff --exit-code -- package.json .cursor-plugin .codex-plugin .agents .claude-plugin dist && bun tooling/release-archive.ts", "pack:release": "bun run verify && bun run archive:release", "lint": "oxlint . --deny-warnings --report-unused-disable-directives", "lint:fix": "oxlint . --fix", @@ -73,16 +72,5 @@ ], "*.{json,jsonc,md,yaml,yml,css,html,svg}": "oxfmt --no-error-on-unmatched-pattern" }, - "packageManager": "bun@1.3.1", - "openclaw": { - "runtimeExtensions": [ - "./dist/hooks/openclaw/register.js" - ], - "compat": { - "pluginApi": ">=2026.6.1" - }, - "build": { - "openclawVersion": "2026.6.1" - } - } + "packageManager": "bun@1.3.1" } diff --git a/tooling/generate.ts b/tooling/generate.ts index 7afb4fb..5af20ba 100644 --- a/tooling/generate.ts +++ b/tooling/generate.ts @@ -31,15 +31,6 @@ const CURSOR_HOOKS = "./hooks/cursor/hooks.json"; const CODEX_HOOKS = "./hooks/codex/hooks.json"; const CLAUDE_HOOKS = "./hooks/claude/hooks.json"; -// OpenClaw reads code entrypoints and npm metadata from the package.json -// `openclaw` block (not the manifest). We ship built JS, so the register module -// is declared as a `runtimeExtensions` entry. The compat/build versions are what -// ClawHub package publishing requires; track the OpenClaw release this plugin is -// built against. -const OPENCLAW_RUNTIME_ENTRYPOINT = "./dist/hooks/openclaw/register.js"; -const OPENCLAW_VERSION = "2026.6.1"; -const OPENCLAW_PLUGIN_API = `>=${OPENCLAW_VERSION}`; - const metadata = MetadataSchema.parse({ name: "mainframe", displayName: "Mainframe", @@ -90,8 +81,6 @@ function main(): void { writeJson(".claude-plugin/plugin.json", claudeManifest()); writeJson(".claude-plugin/marketplace.json", claudeMarketplace()); - writeJson("openclaw.plugin.json", openclawManifest()); - updatePackageJson(); } @@ -185,30 +174,12 @@ function claudeMarketplace() { }; } -// The native OpenClaw manifest is intentionally minimal: it is the cold -// metadata OpenClaw reads before loading plugin code, so it carries only plugin -// identity, the skill directory to load, a startup activation hint for the -// lifecycle hook, and the required (empty) config schema. Entrypoints, MCP -// wiring, and catalog copy are not manifest fields — they live in package.json, -// the user's openclaw.json, and the bundle markers respectively. -function openclawManifest() { - return { - id: metadata.name, - name: metadata.displayName, - description: metadata.description, - version: metadata.version, - skills: [metadata.skillDirectory], - activation: { onStartup: true }, - configSchema: { type: "object", additionalProperties: false }, - }; -} - 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, Codex, Claude Code, and OpenClaw plugin manifests, skill, MCP wiring, and stop hooks."; + "Mainframe Cursor, Codex, and Claude Code plugin manifests, skill, MCP wiring, and stop hooks."; packageJson.private = true; packageJson.license = metadata.license; packageJson.homepage = metadata.homepage; @@ -217,11 +188,6 @@ function updatePackageJson(): void { url: metadata.repository, }; packageJson.keywords = metadata.keywords; - packageJson.openclaw = { - runtimeExtensions: [OPENCLAW_RUNTIME_ENTRYPOINT], - compat: { pluginApi: OPENCLAW_PLUGIN_API }, - build: { openclawVersion: OPENCLAW_VERSION }, - }; packageJson.bin = { "mainframe-hook-cursor": "./dist/hooks/cursor/stop.js", "mainframe-hook-codex": "./dist/hooks/codex/stop.js", diff --git a/tooling/generated-drift-check.ts b/tooling/generated-drift-check.ts index 9bbc93c..b97d339 100644 --- a/tooling/generated-drift-check.ts +++ b/tooling/generated-drift-check.ts @@ -8,7 +8,6 @@ const generatedPaths = [ ".agents/plugins/marketplace.json", ".claude-plugin/plugin.json", ".claude-plugin/marketplace.json", - "openclaw.plugin.json", "package.json", ]; diff --git a/tooling/manifest.test.ts b/tooling/manifest.test.ts index fce4eef..b5eccdf 100644 --- a/tooling/manifest.test.ts +++ b/tooling/manifest.test.ts @@ -64,23 +64,6 @@ const ClaudeManifestSchema = SharedManifestSchema.extend({ hooks: z.literal("./hooks/claude/hooks.json"), }).strict(); -// The OpenClaw manifest is its own minimal contract (id + required configSchema -// + cold metadata), not the shared host manifest: entrypoints and catalog copy -// are deliberately absent because OpenClaw reads those from package.json. -const OpenClawManifestSchema = z - .object({ - id: z.literal("mainframe"), - name: z.literal("Mainframe"), - description: DescriptionSchema, - version: z.literal("0.1.0"), - skills: z.tuple([z.literal("./skills")]), - activation: z.object({ onStartup: z.literal(true) }).strict(), - configSchema: z - .object({ type: z.literal("object"), additionalProperties: z.literal(false) }) - .strict(), - }) - .strict(); - describe("generated plugin manifests", () => { it(".cursor-plugin/plugin.json matches the Cursor plugin schema", () => { const manifest = CursorManifestSchema.parse( @@ -106,33 +89,6 @@ describe("generated plugin manifests", () => { expect(manifest.hooks).toBe("./hooks/claude/hooks.json"); }); - it("openclaw.plugin.json matches the OpenClaw plugin schema", () => { - const manifest = OpenClawManifestSchema.parse( - JSON.parse(readFileSync("openclaw.plugin.json", "utf8")), - ); - - expect(manifest.id).toBe("mainframe"); - expect(manifest.configSchema.additionalProperties).toBe(false); - }); - - it("package.json declares the OpenClaw code-plugin publishing contract", () => { - const packageOpenClawSchema = z - .object({ - openclaw: z - .object({ - runtimeExtensions: z.tuple([z.literal("./dist/hooks/openclaw/register.js")]), - compat: z.object({ pluginApi: z.literal(">=2026.6.1") }).strict(), - build: z.object({ openclawVersion: z.literal("2026.6.1") }).strict(), - }) - .strict(), - }) - .passthrough(); - - const pkg = packageOpenClawSchema.parse(JSON.parse(readFileSync("package.json", "utf8"))); - - expect(pkg.openclaw.build.openclawVersion).toBe("2026.6.1"); - }); - it("Cursor marketplace metadata matches the Cursor marketplace schema", () => { const marketplaceSchema = z .object({ diff --git a/tooling/package-surface.ts b/tooling/package-surface.ts index 5e3603b..e18c3f2 100644 --- a/tooling/package-surface.ts +++ b/tooling/package-surface.ts @@ -7,7 +7,6 @@ export const PACKAGE_FILES = [ ".codex-plugin", ".cursor-plugin", ".mcp.json", - "openclaw.plugin.json", "LICENSE", "README.md", "assets/logo.png", @@ -26,7 +25,6 @@ export const SHIPPED_FILES = [ ".cursor-plugin/marketplace.json", ".cursor-plugin/plugin.json", ".mcp.json", - "openclaw.plugin.json", "LICENSE", "README.md", "assets/logo.png", @@ -45,8 +43,6 @@ export const SHIPPED_FILES = [ "dist/hooks/cursor/stop-evaluator.js", "dist/hooks/cursor/stop.js", "dist/hooks/cursor/transcript.js", - "dist/hooks/openclaw/register.js", - "dist/hooks/openclaw/runtime.js", "hooks/claude/hooks.json", "hooks/codex/hooks.json", "hooks/cursor/hooks.json", diff --git a/tooling/release-archive.ts b/tooling/release-archive.ts index 997684f..85d691b 100644 --- a/tooling/release-archive.ts +++ b/tooling/release-archive.ts @@ -18,7 +18,6 @@ const PackageSchema = z.object({ name: z.string().min(1), version: z.string().min(1), files: z.array(z.string().min(1)).min(1), - openclaw: z.object({ runtimeExtensions: z.array(z.string().min(1)).min(1) }).passthrough(), }); const PluginManifestSchema = z.object({ @@ -68,7 +67,6 @@ const manifestPaths = [ claudeManifest.hooks, claudeManifest.mcpServers, claudeManifest.skills, - ...packageJson.openclaw.runtimeExtensions, ].map((path) => path.replace(/^\.\//, "")); assertPluginPackageSurface(packageJson.files); From 1eb5ae6ed2e0e8702eea5eb89dfcb41883c94800 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 22:50:39 +0000 Subject: [PATCH 10/10] Fix stale ClawHub workflow input description after bundle switch The owner dispatch input only feeds the skill publish now (package publishing was removed when OpenClaw moved to the bundle model), so drop the '+ package' wording. --- .github/workflows/clawhub-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/clawhub-publish.yml b/.github/workflows/clawhub-publish.yml index 5f0470f..35ebe59 100644 --- a/.github/workflows/clawhub-publish.yml +++ b/.github/workflows/clawhub-publish.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: owner: - description: ClawHub owner or publisher handle for the skill and package. Leave empty to publish as the authenticated user. + description: ClawHub owner or publisher handle for the skill publish. Leave empty to publish as the authenticated user. type: string required: false default: ""