feat(tiles): load-on-dispatch replaces lazy stubs (Phase E)
Phase E of the tile-lifecycle rewrite. The lazy-stub subscription
pattern is gone; dispatch now runs through one pubsub pre-publish hook
that awaits the owning tile's boot before delivery.
Why: N per-command stub subscribers bloated the pubsub registry and
split "load" logic across three nearly-identical code paths (command
stub, event interceptor, declarative window short-circuit). The hook
is a single dispatch-site intercept with topic-predicate filtering —
one code path, one place the policy lives.
- `pubsub.ts` — new `registerPrePublishHook(predicate, hook)`. The
hook runs before any subscriber; its return value (`'skip'` /
`'continue'` / `Promise<...>`) controls delivery. Async hooks defer
delivery until the promise resolves. Errors in the hook log and
fall through to normal delivery so a broken loader can't hang every
matching publish. `deliver(...)` extracted so both the sync and
deferred paths share one body. Session stats still count publish
intent (incremented once per call) regardless of skip/defer.
- `tile-lazy.ts` — rewritten around the hook. Removed:
`registerCommandStub`, `registerEventInterceptor`,
`lazyCommandPending`, `lazyEventPending`. Added
`installLoadOnDispatchHook` (called once at init) and a single
`dispatchHookBody` that handles both `cmd:execute:{name}` and
declared lazyEvent topics.
* Predicate fast-rejects `cmd:execute:X:result` sub-topics
(`rest.includes(':')`) so result publishes from handlers don't
re-enter the hook and deadlock.
* Declarative `action.type === 'window'` commands publish
`window:reopen-request` directly and return `'skip'` — the
original cmd:execute never reaches delivery, same behavior as
the old stub shortcut.
* Concurrent dispatches during load share one launch via the
existing `loadingTiles` set + `waitForTileReady` promise; all
three deferred publishes reach the real handler after ready
(test coverage added for this — the old latest-wins stub
buffer dropped earlier messages).
* `registerLazyTile` still publishes one `cmd:register-batch` per
tile so the cmd panel's command list is unchanged.
- `main.ts` — wires `installLoadOnDispatchHook()` in `initialize`,
right after `configureTileLauncher`, before `initializeFeatures` so
every subsequent cmd publish flows through the hook.
- `tile-lazy-events.test.ts` / `tile-command-registration.test.ts` —
rewritten to exercise hook semantics instead of stub-subscription
shape. No more `hasSubscriber('lazy-stub/*')` assertions; tests
verify launch counts, delivery after ready, no relaunch on
subsequent events, declarative-window bypass, and the concurrent-
fires-during-load invariant. One test removed (stub-subscriber
existence check) because the concept no longer exists.
Tests: 2249/2249 unit + 238/238 Playwright (desktop + desktop-serial).
Deliberately NOT removed: `registerLazyTile`, `getLazyTileManifest`,
`isLazyTileRegistered`, `getLazyTileIds`. They're the public API that
callers (tile-compat, ipc.ts) rely on. The internal stub machinery is
gone.