From cfd2ec445b73a6762b869cd3a67129a87ed62fc5 Mon Sep 17 00:00:00 2001 From: hzhang Date: Mon, 25 May 2026 10:25:41 +0100 Subject: [PATCH] fix(cross-plugin-api): drain __prismFacetPending queue from early consumers Plugin load order is undefined. If a consumer (ClawPrompts) loads before PrismFacet, it can't call __prismFacet.addRouter directly (slot is undefined). Now it pushes ops onto the well-known __prismFacetPending array; PrismFacet's installCrossPluginApi() drains the queue when it runs. Marker __real: true on the installed API so consumers can tell stub from real. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugin/core/cross-plugin-api.ts | 51 +++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/plugin/core/cross-plugin-api.ts b/plugin/core/cross-plugin-api.ts index e6ac5cb..4526304 100644 --- a/plugin/core/cross-plugin-api.ts +++ b/plugin/core/cross-plugin-api.ts @@ -48,22 +48,34 @@ export interface PrismFacetCrossPluginApi { ): void; } -/** Idempotent install. Called from PrismFacet's register(). Also runs - * at module-import time below so that consumer plugins loading before - * PrismFacet still find a usable API. */ +type PendingOp = + | { type: "router"; name: string; resolve: (ctx: RouterContext) => string | Promise } + | { type: "rule"; routerName: string; key: string; prompt: { file?: string; text?: string } }; + +/** + * Idempotent install. Called from PrismFacet's register(). Also runs + * at module-import time below so consumer plugins loading before + * PrismFacet still find a usable API. + * + * Plugin load order is undefined — if ClawPrompts loads before + * PrismFacet, the `_G['__prismFacet']` slot stays at the buffering stub + * created by ClawPrompts (or, if neither has installed yet, missing). + * To make this race-free, we install the real API on first call from + * either side AND drain any queued ops a buffering caller may have + * accumulated via the well-known `__prismFacetPending` array. ClawPrompts + * (and any future consumer) only needs to: push ops onto the pending + * array when `__prismFacet?.addRouter` isn't callable; PrismFacet's + * install() will drain them when it runs. + */ export function installCrossPluginApi(): PrismFacetCrossPluginApi { const existing = _G["__prismFacet"] as PrismFacetCrossPluginApi | undefined; - if (existing && typeof existing.addRouter === "function") return existing; + if (existing && typeof existing.addRouter === "function" && (existing as any).__real) return existing; - const api: PrismFacetCrossPluginApi = { + const api: PrismFacetCrossPluginApi & { __real: true } = { addRouter(name, resolve) { addExternalRouter(name, resolve); }, addRule(routerName, key, prompt) { - // Phase 1: only file-based prompts are wired through the existing - // injector. `text` inline prompts will need a tmpfile-or-store - // backend; surfaced as a TODO so an early caller failing loudly - // beats silently dropping the prompt. if (!prompt.file) { throw new Error( "__prismFacet.addRule: only { file: '/abs/path.md' } is supported today; " + @@ -72,12 +84,27 @@ export function installCrossPluginApi(): PrismFacetCrossPluginApi { } addExternalRule(routerName, key, prompt.file); }, + __real: true, }; _G["__prismFacet"] = api; + + // Drain any ops queued by consumer plugins that ran before us. + const pending = _G["__prismFacetPending"] as PendingOp[] | undefined; + if (Array.isArray(pending) && pending.length > 0) { + for (const op of pending) { + try { + if (op.type === "router") api.addRouter(op.name, op.resolve); + else api.addRule(op.routerName, op.key, op.prompt); + } catch { + // Swallow per-op errors so one bad pending entry doesn't block + // the rest. Consumer plugin logs already fired at queue time. + } + } + _G["__prismFacetPending"] = []; + } + return api; } -// Eager install on module load so a consumer plugin importing into the -// same gateway process can register even if it runs before PrismFacet's -// register() does. +// Eager install on module load. installCrossPluginApi();