From 96b11bd60fd933a1d09daa4ed507d8602838ff31 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 18:43:05 -0800 Subject: [PATCH] feat(companion): add runtime event subscription hooks --- README.md | 2 +- docs/plans/state.json | 14 ++++++++ src/companion/index.ts | 1 + src/companion/runtimeClient.test.ts | 55 +++++++++++++++++++++++++++++ src/companion/runtimeClient.ts | 17 +++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 73afab3..0e8dbb1 100644 --- a/README.md +++ b/README.md @@ -1190,7 +1190,7 @@ Methods: - `system.capabilities` returns gateway protocol and node policy snapshot. Companion runtime helper: -- `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, `system.nodes`, and canvas artifact RPCs (`canvas.put/get/list/delete/clear`), plus optional `autoConnect` mode for one-shot RPC calls. +- `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, `system.nodes`, and canvas artifact RPCs (`canvas.put/get/list/delete/clear`), plus optional `autoConnect` mode and `subscribeEvents()` for gateway stream events. - `src/companion/platformClients.ts` provides platform-focused wrappers: - `MacOSCompanionClient` (`platform: "macos"`, APNs push registration) - `IOSCompanionClient` (`platform: "ios"`, APNs push registration) diff --git a/docs/plans/state.json b/docs/plans/state.json index 23e06ad..708d900 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -299,6 +299,20 @@ ], "test_status": "pnpm test:run src/companion/heartbeatLoop.test.ts src/companion/platformClients.test.ts + pnpm typecheck passing" }, + "companion-runtime-event-subscriptions": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added event subscription support to `CompanionRuntimeClient` via `subscribeEvents()` so companion runtimes can receive gateway stream events with safe callback isolation and explicit unsubscribe flow.", + "files_modified": [ + "src/companion/runtimeClient.ts", + "src/companion/runtimeClient.test.ts", + "src/companion/index.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" + }, "browser-tools-activation-clarity": { "status": "completed", "date": "2026-02-17", diff --git a/src/companion/index.ts b/src/companion/index.ts index e68af16..2795ca3 100644 --- a/src/companion/index.ts +++ b/src/companion/index.ts @@ -11,6 +11,7 @@ export { CompanionHeartbeatLoop } from './heartbeatLoop.js'; export type { CompanionRuntimeClientOptions, + CompanionEventHandler, RegisterNodeInput, ListNodesInput, SetNodeStatusInput, diff --git a/src/companion/runtimeClient.test.ts b/src/companion/runtimeClient.test.ts index 57c2762..bb674df 100644 --- a/src/companion/runtimeClient.test.ts +++ b/src/companion/runtimeClient.test.ts @@ -96,6 +96,61 @@ afterAll(async () => { }); describe('CompanionRuntimeClient', () => { + it('dispatches gateway events to subscribed handlers and supports unsubscribe', () => { + const client = new CompanionRuntimeClient({ + url: 'ws://127.0.0.1:1', + }); + const handler = vi.fn(); + const unsubscribe = client.subscribeEvents(handler); + + (client as unknown as { handleMessage: (raw: string) => void }).handleMessage( + JSON.stringify({ + id: 42, + event: 'agent.stream', + data: { token: 'hello' }, + }), + ); + + expect(handler).toHaveBeenCalledWith('agent.stream', { token: 'hello' }); + + unsubscribe(); + + (client as unknown as { handleMessage: (raw: string) => void }).handleMessage( + JSON.stringify({ + id: 43, + event: 'agent.stream', + data: { token: 'world' }, + }), + ); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('isolates subscriber callback failures', () => { + const client = new CompanionRuntimeClient({ + url: 'ws://127.0.0.1:1', + }); + const badHandler = vi.fn(() => { + throw new Error('subscriber failed'); + }); + const goodHandler = vi.fn(); + client.subscribeEvents(badHandler); + client.subscribeEvents(goodHandler); + + expect(() => { + (client as unknown as { handleMessage: (raw: string) => void }).handleMessage( + JSON.stringify({ + id: 44, + event: 'agent.stream', + data: { token: 'safe' }, + }), + ); + }).not.toThrow(); + + expect(badHandler).toHaveBeenCalledOnce(); + expect(goodHandler).toHaveBeenCalledWith('agent.stream', { token: 'safe' }); + }); + it('connects and performs node registration + capability discovery', async () => { if (!LISTEN_ALLOWED) { return; diff --git a/src/companion/runtimeClient.ts b/src/companion/runtimeClient.ts index 6b4c10f..ce40fa9 100644 --- a/src/companion/runtimeClient.ts +++ b/src/companion/runtimeClient.ts @@ -41,6 +41,8 @@ export interface CompanionRuntimeClientOptions { websocketFactory?: (url: string) => WebSocket; } +export type CompanionEventHandler = (event: string, data: unknown) => void; + export interface RegisterNodeInput { nodeId: string; role: string; @@ -257,6 +259,7 @@ export class CompanionRuntimeClient { private connectPromise: Promise | null = null; private nextId = 1; private pending = new Map(); + private readonly eventHandlers = new Set(); constructor(options: CompanionRuntimeClientOptions) { this.url = options.url; @@ -342,6 +345,13 @@ export class CompanionRuntimeClient { ws.close(code, reason); } + subscribeEvents(handler: CompanionEventHandler): () => void { + this.eventHandlers.add(handler); + return () => { + this.eventHandlers.delete(handler); + }; + } + async call(method: string, params?: Record): Promise { if (!this.connected) { if (!this.autoConnect) { @@ -498,6 +508,13 @@ export class CompanionRuntimeClient { } if ('event' in parsed) { + for (const handler of this.eventHandlers) { + try { + handler(parsed.event, parsed.data); + } catch { + // Event subscribers are userland callbacks; isolate failures. + } + } return; }