Skip to content

feat(sdk): usePluginEvent — one shell SSE connection for all plugins (WS3 A1)#501

Merged
markhayden merged 5 commits into
mainfrom
feat/sdk-gaps
Jun 15, 2026
Merged

feat(sdk): usePluginEvent — one shell SSE connection for all plugins (WS3 A1)#501
markhayden merged 5 commits into
mainfrom
feat/sdk-gaps

Conversation

@markhayden

Copy link
Copy Markdown
Owner

What & why

Part of the 2026-06 audit remediation (WS3, feat/sdk-gaps). This PR ships A1 only — the load-bearing SSE consolidation — so the highest-risk change (it touches the live-update path app-wide) gets reviewed in isolation. A2–A8 (the additive SDK primitives: useJsonFetch, ConfirmDialog, formatters, EmptyState fold, useAvailableModels, tasks workflow types, toneBadge) are carved into a follow-up workstream WS3b and picked up after this merges. See tasks/plan.md for the full backlog.

The problem (audit finding, P1)

The shell already owns exactly one EventSource('/api/events') (src/hooks/use-sse.ts), but it had no subscriber fan-out — its routing was hardcoded to specific plugins (data.type==='taskboard'bumpTaskboard, doctor → bumpDoctor, reindex → setReindexProgress), with those counters living in the host content store. The assets plugin got no such carve-out, so it opened its own 3 raw EventSource connections (one per mounted component, no reconnect). External plugin authors had no sanctioned way to react to their own broadcast() events.

What changed

New primitive — usePluginEvent (src/hooks/use-plugin-event.ts, re-exported from @makinbakin/sdk/hooks):

  • A dependency-free globalThis-backed Map<eventName, Set<handler>> emitter. emitPluginEvent(payload) dispatches to subscribers of payload.event; a throwing handler is isolated so one bad subscriber can't break the others.
  • usePluginEvent(event, handler) is stable across handler-identity changes (latest handler held in a ref; only re-subscribes when event changes — no memoization needed by callers).
  • The shell's single useSSE.onmessage is the sole publisher: {type:'plugin-event'} frames pass through verbatim; the shell also synthesizes taskboard, doctor.run, and reindex.start|progress|complete from their raw frames.

Migrations:

  • Assets: 3 raw EventSources → usePluginEvent (task-assets, VersionedAssetGrid, VersionedAssetDetail). Net: assets goes 3 → 0 connections.
  • Counter refactor (the bigger blast radius, requested explicitly): tasks board + nav badge, health nav badge, and the health reindex tiles now subscribe via usePluginEvent; the content store sheds taskboardVersion/doctorVersion/reindexProgress entirely. The store is back to file/audit/activity/heartbeat state only — it no longer knows which plugin cares about which event.

Verification

  • bun run test4999 pass / 0 fail (new use-plugin-event unit tests; rewritten use-sse-doctor + use-health-summary; obsolete content-store counter test deleted).
  • bun run typecheck + lint (touched files) — clean.
  • Dockerized-rig isolated E2E (real OpenClaw, all 12 plugins loaded):
    • Playwright sweep of all 10 routes → 0 console/page/network errors.
    • Stack-classified EventSource probe on /assets: assets plugin = 0 /api/events connections (was 3), shell singleton = 1. (The one remaining connection is the out-of-repo messaging user plugin in the test home — not repo scope.)

Docs

.claude/knowledge/plugin-system.md (new § Client SSE fan-out + SDK hooks surface + nav-badge bullets), search-system.md, tasks-plugin.md updated to the new wiring.

Risk / rollback

The emitter is additive (publish-only); the shell keeps its existing content-store routing for everything else. Each commit is independent and green. Merge-order note: independent of WS2 #499 (only shared file packages/sdk/src/utils takes non-conflicting additions).

🤖 Generated with Claude Code

markhayden and others added 5 commits June 13, 2026 18:35
Six client SDK primitives (usePluginEvent, useJsonFetch, ConfirmDialog,
formatDuration/DateTime, EmptyState consolidation, useAvailableModels) +
the tasks workflow-types migration (WS1 A8 deferral) + toneBadge. A1 also
refactors the shell's hardcoded taskboard/doctor/reindex SSE routing onto
the new fan-out (Mark's call). Client/browser work — PR gate runs the
dockerized-rig E2E + Playwright sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…off raw EventSource

The shell owns one /api/events connection but had no subscriber fan-out,
so the assets plugin opened three of its own (no reconnect). usePluginEvent
(src/hooks/use-plugin-event.ts, re-exported from @makinbakin/sdk/hooks)
subscribes to a named plugin-event over a process-global emitter; the shell
useSSE fans every {type:'plugin-event'} payload into it. The 3 assets
EventSources (task-assets, VersionedAssetGrid, VersionedAssetDetail) now
subscribe via the hook — one connection, reconnect handled once by the
shell. Handler gets the full payload for assetId/taskId filtering. New
emitter unit tests. (A1b migrates the shell's taskboard/doctor/reindex
routing onto the same fan-out.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…inEvent

The shell useSSE hardcoded per-plugin routing into the content store
(bumpTaskboard/bumpDoctor/setReindexProgress + the taskboardVersion/
doctorVersion counters + the reindexProgress record). It now emits
'taskboard', 'doctor.run', and 'reindex.start/progress/complete' through
the usePluginEvent fan-out instead, and the consumers subscribe:
kanban-board + use-task-summary ('taskboard'), use-health-summary
('doctor.run'), and health-page owns its reindex progress as local state
fed by the three reindex events. The content store sheds all three counter
families. No behavior change (live updates verified by the rewritten
use-sse-doctor / use-health-summary tests + the E2E gate). The shell no
longer knows which plugin cares about which event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Knowledge docs were describing the pre-A1 SSE wiring. Updated:
- plugin-system.md: new § Client SSE fan-out (usePluginEvent — globalThis
  emitter, single-publisher shell, event-name mapping); add usePluginEvent
  to the @bakin/sdk/hooks surface row; rewrite the nav-badge refresh-signal
  bullets to usePluginEvent('taskboard'|'doctor.run') (drop the stale
  messaging/EventSource + taskboardVersion/doctorVersion counter mentions).
- search-system.md: health reindex tiles consume reindex.* via usePluginEvent
  + local state, not useContentStore.reindexProgress.
- tasks-plugin.md: kanban-board + use-task-summary re-fetch on
  usePluginEvent('taskboard'); the shell re-emits the taskboard frame via
  emitPluginEvent (no bumpTaskboard).

plan.md: record the 2026-06-13 scope decision — ship A1 (the load-bearing
SSE consolidation, E2E-verified) as this PR; A2-A8 become the WS3b backlog,
picked up after merge. A1 E2E result captured (assets 3→0 connections, shell
singleton=1, 0 page errors across 10 routes on the dockerized rig).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@markhayden markhayden merged commit 5887fdc into main Jun 15, 2026
1 check passed
@markhayden markhayden deleted the feat/sdk-gaps branch June 15, 2026 02:33
markhayden added a commit that referenced this pull request Jun 15, 2026
main advanced past WS2's branch point (WS3 #501 usePluginEvent, #500 models
cost). Conflicts + semantic fixes resolved:

- tasks/plan.md + tasks/todo.md: textual conflicts (each workstream rewrites
  these). Archived WS2's as tasks/{plan,todo}-ws2-core-extractions.md (matches
  the plan-ws1-contract-types.md convention); kept main's active WS3 plan/todo.
- #500 added two NEW getHookRegistry consumers on the OLD import path that
  WS2's K1 moved to the leaf module: plugins/health/lib/system-checks/budget.ts
  and src/core/agent-cost.ts → repointed both to
  @bakin/core/hooks/hook-registry-singleton (relative for the plugin file).
- #500's tests (agent-cost, budget-gate, health/budget) mocked getHookRegistry
  only on the legacy facade; added the leaf mock (K1 partial-mock sweep) so all
  three exercise the real import site.

Verified: bun run typecheck clean; bun run test 5072 pass / 0 fail; madge shows
6 type-only cycles (the 4 WS2 documented + 2 docs/ cycles inherited from main,
all erased at compile) — none route through scripts/lib/registry, so WS2's
runtime cycle break holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant