diff --git a/.gitignore b/.gitignore index d5a191d..b18e8e8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage/ .DS_Store smoke-trace.json smoke.heapsnapshot +.idea \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 752ea88..c0ed453 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,9 @@ Minimal operating guide for AI coding agents in this repo. - Keep files agent-readable: - avoid growing already-large router files - prefer extracting focused helpers before adding another major command branch to `src/cli.ts` or `src/daemon.ts` -- Keep `packages/agent-cdp/src/daemon.ts` as an IPC command router and orchestrator, not the home for analysis logic. +- Keep `packages/agent-cdp/src/daemon.ts` as the composition root and IPC router. Analysis logic belongs in domain modules. Plugin routing belongs in `PluginOrchestrator`. +- Plugin commands arrive as `{ type: "plugin-command", pluginId, command, input }` and are intercepted in the daemon's IPC loop before reaching `AgentCdpCommandDispatcher`. Do not add plugin-specific branches to the dispatcher. +- To add a new built-in plugin: implement `AgentPlugin` in `src/plugins//index.ts`, export `registerCliCommands`, register the plugin in `PluginOrchestrator` in `daemon.ts`, and add to `BUILT_IN_PLUGINS` in `cli/index.ts`. Nothing else changes. - Keep `packages/agent-cdp/src/cli.ts` focused on argument parsing, command dispatch, and formatting. - Put command logic in domain modules: - target discovery: `src/discovery.ts` @@ -98,6 +100,9 @@ Minimal operating guide for AI coding agents in this repo. - JS CPU profiling: `src/js-profiler/*` - CLI help and parsing: `src/cli.ts`, `src/__tests__/cli.test.ts` - formatting: `src/formatters.ts`, `src/heap-snapshot/formatters.ts`, `src/js-memory/formatters.ts`, `src/js-profiler/formatters.ts` +- plugin system interfaces: `src/plugin.ts` +- plugin orchestrator (routing, lifecycle, dispatch): `src/plugin-orchestrator.ts`, `src/__tests__/plugin-orchestrator.test.ts` +- runtime bridge plugin: `src/plugins/runtime-bridge/index.ts`, `src/__tests__/runtime-bridge.test.ts` ## Pull Requests - Start the PR description with end-user impact: diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md new file mode 100644 index 0000000..e78e985 --- /dev/null +++ b/docs/PLUGINS.md @@ -0,0 +1,19 @@ +# Built-in plugin system + +`agent-cdp` has a built-in plugin system for adding target-scoped integrations without touching the core daemon, dispatcher, CLI, or protocol types. + +## How it works + +- **Protocol** — all plugin traffic uses one generic IPC envelope: `{ type: "plugin-command", pluginId, command, input }`. The protocol union never needs to widen for a new plugin. +- **`AgentPlugin` interface** — a plugin declares a unique `id`, a list of static `AgentPluginCommand` entries, a `supportsTarget()` predicate, a `getState()` method, and optional daemon/target lifecycle hooks (`onDaemonStart`, `onTargetSelected`, `onTargetReconnected`, `onTargetCleared`, etc.). +- **`PluginOrchestrator`** — the daemon-owned host that registers plugins, validates unique ids, routes lifecycle events, and dispatches plugin IPC commands. It enforces `supportsTarget` state checks before calling `execute()` so plugin commands fail with a clear message when the active target is unsupported. +- **CLI registration** — each plugin module exports a `registerCliCommands(program, deps)` function. `createProgram` calls it at startup, adding a static `agent-cdp ` subcommand family. Commands are never added dynamically after a target connects. + +## Adding a plugin + +1. Create `packages/agent-cdp/src/plugins//index.ts` implementing `AgentPlugin`. +2. Export `registerCliCommands(program, deps)` from the same file. CLI subcommands send `{ type: "plugin-command", pluginId: "", command: "", input: {...} }`. +3. Instantiate the plugin and add it to `new PluginOrchestrator([...])` in `src/daemon.ts`. +4. Add `{ registerCliCommands }` to `BUILT_IN_PLUGINS` in `src/cli/index.ts`. + +No other files need to change. See `src/plugin.ts` for the full interface contract and `src/plugins/runtime-bridge/index.ts` for the reference implementation. \ No newline at end of file diff --git a/packages/agent-cdp/README.md b/packages/agent-cdp/README.md index 99c3c73..b704e93 100644 --- a/packages/agent-cdp/README.md +++ b/packages/agent-cdp/README.md @@ -112,6 +112,10 @@ Commands are grouped as **daemon**, **target**, **console**, **runtime**, **netw For the runtime SDK bridge and in-app profiling, see `docs/SDK.md`. +## Built-in plugin system + +For architecture, interface contract, and a step-by-step guide to adding a plugin, see [`docs/PLUGINS.md`](../../docs/PLUGINS.md). + ## Runtime inspection Use `runtime` for live state inspection when you need more than captured console output. diff --git a/packages/agent-cdp/src/__tests__/plugin-orchestrator.test.ts b/packages/agent-cdp/src/__tests__/plugin-orchestrator.test.ts new file mode 100644 index 0000000..e00761c --- /dev/null +++ b/packages/agent-cdp/src/__tests__/plugin-orchestrator.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AgentPlugin, AgentPluginCommand, AgentPluginState, AgentPluginTargetSession } from "../plugin.js"; +import { PluginOrchestrator } from "../plugin-orchestrator.js"; +import type { CdpTransport, CdpEventMessage, RuntimeSession } from "../types.js"; + +function makeTransport(overrides: Partial = {}): CdpTransport { + return { + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(() => true), + send: vi.fn(async () => undefined), + onEvent: vi.fn(() => () => {}), + ...overrides, + }; +} + +function makeSession(transport = makeTransport()): RuntimeSession { + return { + target: { + id: "rn:test:1", + rawId: "test-1", + title: "Test App", + kind: "react-native", + description: "Test", + webSocketDebuggerUrl: "ws://localhost/devtools/1", + sourceUrl: "http://localhost", + }, + transport, + ensureConnected: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; +} + +function makePlugin( + id: string, + overrides: Partial & { commands?: AgentPluginCommand[]; state?: AgentPluginState } = {}, +): AgentPlugin { + const { state = { kind: "idle" }, commands = [], ...rest } = overrides; + return { + id, + displayName: id, + commands, + supportsTarget: vi.fn(() => true), + getState: vi.fn(() => state), + ...rest, + }; +} + +function makeCommand(name: string, result: unknown = null): AgentPluginCommand { + return { + name, + summary: name, + execute: vi.fn(async () => result), + }; +} + +describe("PluginOrchestrator", () => { + describe("constructor validation", () => { + it("throws on duplicate plugin ids", () => { + expect(() => new PluginOrchestrator([makePlugin("foo"), makePlugin("foo")])).toThrow( + "Duplicate plugin id: 'foo'", + ); + }); + + it("throws on duplicate derived command ids across plugins", () => { + const a = makePlugin("foo", { commands: [makeCommand("bar")] }); + const b = makePlugin("foo", { commands: [makeCommand("bar")] }); + // same plugin id already triggers first — use different plugin ids but same derived id isn't possible + // test same command name within one plugin isn't a derived-id collision, but two plugins with same id is caught first + // test the command id path: same plugin id would be caught first, so we need to test a hypothetical + // where two different plugins share a derived id — that cannot happen since derived = pluginId.commandName + // and plugin ids must be unique. So just verify the plugin-id duplicate is caught. + expect(() => new PluginOrchestrator([a, b])).toThrow("Duplicate plugin id: 'foo'"); + }); + + it("accepts distinct plugin ids", () => { + expect(() => new PluginOrchestrator([makePlugin("foo"), makePlugin("bar")])).not.toThrow(); + }); + }); + + describe("dispatch", () => { + it("returns error for unknown plugin", async () => { + const o = new PluginOrchestrator([]); + const result = await o.dispatch("nope", "cmd"); + expect(result).toEqual({ ok: false, error: "Unknown plugin 'nope'" }); + }); + + it("returns error for unknown command", async () => { + const o = new PluginOrchestrator([makePlugin("p")]); + const result = await o.dispatch("p", "nope"); + expect(result).toEqual({ ok: false, error: "Unknown command 'nope' for plugin 'p'" }); + }); + + it("returns error when state is unsupported-target", async () => { + const plugin = makePlugin("p", { + state: { kind: "unsupported-target", reason: "chrome target not supported" }, + commands: [makeCommand("cmd")], + }); + const o = new PluginOrchestrator([plugin]); + const result = await o.dispatch("p", "cmd"); + expect(result).toEqual({ + ok: false, + error: "Plugin 'p' does not support the current target: chrome target not supported", + }); + }); + + it("returns error when state is waiting-for-runtime", async () => { + const plugin = makePlugin("p", { + state: { kind: "waiting-for-runtime", reason: "bridge not installed" }, + commands: [makeCommand("cmd")], + }); + const o = new PluginOrchestrator([plugin]); + const result = await o.dispatch("p", "cmd"); + expect(result).toEqual({ + ok: false, + error: "Plugin 'p' is waiting for runtime: bridge not installed", + }); + }); + + it("returns error when state is error", async () => { + const plugin = makePlugin("p", { + state: { kind: "error", reason: "crashed" }, + commands: [makeCommand("cmd")], + }); + const o = new PluginOrchestrator([plugin]); + const result = await o.dispatch("p", "cmd"); + expect(result).toEqual({ ok: false, error: "Plugin 'p' is in error state: crashed" }); + }); + + it("executes command and returns data when state is ready", async () => { + const cmd = makeCommand("cmd", { value: 42 }); + const plugin = makePlugin("p", { state: { kind: "ready" }, commands: [cmd] }); + const o = new PluginOrchestrator([plugin]); + const result = await o.dispatch("p", "cmd", { x: 1 }); + expect(result).toEqual({ ok: true, data: { value: 42 } }); + expect(cmd.execute).toHaveBeenCalledWith(expect.objectContaining({ pluginId: "p" }), { x: 1 }); + }); + + it("executes command when state is idle", async () => { + const cmd = makeCommand("cmd", "ok"); + const plugin = makePlugin("p", { state: { kind: "idle" }, commands: [cmd] }); + const o = new PluginOrchestrator([plugin]); + const result = await o.dispatch("p", "cmd"); + expect(result).toEqual({ ok: true, data: "ok" }); + }); + + it("returns error when command throws", async () => { + const cmd: AgentPluginCommand = { + name: "cmd", + summary: "cmd", + execute: vi.fn(async () => { + throw new Error("boom"); + }), + }; + const plugin = makePlugin("p", { commands: [cmd] }); + const o = new PluginOrchestrator([plugin]); + const result = await o.dispatch("p", "cmd"); + expect(result).toEqual({ ok: false, error: "boom" }); + }); + + it("passes current session through command context", async () => { + let capturedSession: AgentPluginTargetSession | null | undefined; + const cmd: AgentPluginCommand = { + name: "cmd", + summary: "cmd", + execute: vi.fn(async (ctx) => { + capturedSession = ctx.session; + return null; + }), + }; + const plugin = makePlugin("p", { commands: [cmd] }); + const o = new PluginOrchestrator([plugin]); + const session = makeSession(); + await o.onTargetSelected(session); + await o.dispatch("p", "cmd"); + expect(capturedSession).not.toBeNull(); + expect(capturedSession?.target.id).toBe("rn:test:1"); + }); + + it("exposes null session in command context when no target is selected", async () => { + let capturedSession: AgentPluginTargetSession | null | undefined; + const cmd: AgentPluginCommand = { + name: "cmd", + summary: "cmd", + execute: vi.fn(async (ctx) => { + capturedSession = ctx.session; + return null; + }), + }; + const plugin = makePlugin("p", { commands: [cmd] }); + const o = new PluginOrchestrator([plugin]); + await o.dispatch("p", "cmd"); + expect(capturedSession).toBeNull(); + }); + }); + + describe("lifecycle", () => { + it("calls onDaemonStart on all plugins", async () => { + const a = makePlugin("a", { onDaemonStart: vi.fn(async () => {}) }); + const b = makePlugin("b", { onDaemonStart: vi.fn(async () => {}) }); + const o = new PluginOrchestrator([a, b]); + await o.start(); + expect(a.onDaemonStart).toHaveBeenCalled(); + expect(b.onDaemonStart).toHaveBeenCalled(); + }); + + it("calls onDaemonStop on all plugins", async () => { + const a = makePlugin("a", { onDaemonStop: vi.fn(async () => {}) }); + const o = new PluginOrchestrator([a]); + await o.stop(); + expect(a.onDaemonStop).toHaveBeenCalled(); + }); + + it("calls onTargetSelected with correct context", async () => { + const plugin = makePlugin("p", { onTargetSelected: vi.fn(async () => {}) }); + const o = new PluginOrchestrator([plugin]); + await o.onTargetSelected(makeSession()); + expect(plugin.onTargetSelected).toHaveBeenCalledWith( + expect.objectContaining({ pluginId: "p", session: expect.objectContaining({ target: expect.any(Object) }) }), + ); + }); + + it("calls onTargetReconnected with correct context", async () => { + const plugin = makePlugin("p", { onTargetReconnected: vi.fn(async () => {}) }); + const o = new PluginOrchestrator([plugin]); + await o.onTargetSelected(makeSession()); + await o.onTargetReconnected(makeSession()); + expect(plugin.onTargetReconnected).toHaveBeenCalledWith( + expect.objectContaining({ pluginId: "p" }), + ); + }); + + it("calls onTargetCleared with reason target-cleared and clears session", async () => { + const plugin = makePlugin("p", { onTargetCleared: vi.fn(async () => {}) }); + const o = new PluginOrchestrator([plugin]); + const session = makeSession(); + await o.onTargetSelected(session); + await o.onTargetCleared(); + expect(plugin.onTargetCleared).toHaveBeenCalledWith( + expect.objectContaining({ pluginId: "p", reason: "target-cleared" }), + ); + // session should be null after clear + let capturedSession: AgentPluginTargetSession | null | undefined; + const cmd: AgentPluginCommand = { + name: "cmd", + summary: "cmd", + execute: vi.fn(async (ctx) => { capturedSession = ctx.session; return null; }), + }; + (plugin.commands as AgentPluginCommand[]).push(cmd); + await o.dispatch("p", "cmd"); + expect(capturedSession).toBeNull(); + }); + + it("wraps RuntimeSession transport correctly", async () => { + const transport = makeTransport(); + const session = makeSession(transport); + let capturedSession: AgentPluginTargetSession | null | undefined; + const cmd: AgentPluginCommand = { + name: "cmd", + summary: "cmd", + execute: vi.fn(async (ctx) => { capturedSession = ctx.session; return null; }), + }; + const plugin = makePlugin("p", { commands: [cmd] }); + const o = new PluginOrchestrator([plugin]); + await o.onTargetSelected(session); + await o.dispatch("p", "cmd"); + + expect(capturedSession?.isConnected()).toBe(true); + await capturedSession?.send("Runtime.enable"); + expect(transport.send).toHaveBeenCalledWith("Runtime.enable", undefined); + + const listener = vi.fn((_event: CdpEventMessage) => {}); + capturedSession?.onEvent(listener); + expect(transport.onEvent).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/packages/agent-cdp/src/__tests__/runtime-bridge.test.ts b/packages/agent-cdp/src/__tests__/runtime-bridge.test.ts index d3d3998..4b2a29f 100644 --- a/packages/agent-cdp/src/__tests__/runtime-bridge.test.ts +++ b/packages/agent-cdp/src/__tests__/runtime-bridge.test.ts @@ -1,21 +1,23 @@ import { AGENT_CDP_BINDING_NAME, AGENT_CDP_RECEIVE_NAME } from "@agent-cdp/protocol"; import { vi } from "vitest"; -import { AgentRuntimeBridge } from "../bridge/runtime-bridge.js"; -import type { AgentCdpCommandDispatcher } from "../command-dispatcher.js"; -import type { CdpEventMessage, CdpTransport, RuntimeSession, TargetDescriptor } from "../types.js"; +import type { AgentPluginTargetContext, AgentPluginTargetSession } from "../plugin.js"; +import { AgentRuntimeBridgePlugin } from "../plugins/runtime-bridge/index.js"; +import type { CdpEventMessage, TargetDescriptor } from "../types.js"; -class FakeBridgeTransport implements CdpTransport { +class FakeBridgeSession implements AgentPluginTargetSession { private listener: ((message: CdpEventMessage) => void) | null = null; readonly sent: Array<{ method: string; params?: Record }> = []; - connect(): Promise { - return Promise.resolve(); - } - - disconnect(): Promise { - return Promise.resolve(); - } + readonly target: TargetDescriptor = { + id: "rn:test:page-1", + rawId: "page-1", + title: "Example", + kind: "react-native", + description: "Test page", + webSocketDebuggerUrl: "ws://example.test/devtools/page/1", + sourceUrl: "http://example.test", + }; isConnected(): boolean { return true; @@ -33,42 +35,31 @@ class FakeBridgeTransport implements CdpTransport { }; } + onDisconnected(_listener: (error?: Error) => void): () => void { + return () => {}; + } + emit(message: CdpEventMessage): void { this.listener?.(message); } } -function createSession(transport: CdpTransport): RuntimeSession { - return { - target: { - id: "chrome:test:page-1", - rawId: "page-1", - title: "Example", - kind: "chrome", - description: "Test page", - webSocketDebuggerUrl: "ws://example.test/devtools/page/1", - sourceUrl: "http://example.test", - } satisfies TargetDescriptor, - transport, - ensureConnected: () => Promise.resolve(), - close: () => Promise.resolve(), - }; +function makeContext(session: FakeBridgeSession, pluginId = "runtime-bridge"): AgentPluginTargetContext { + return { pluginId, session }; } -describe("AgentRuntimeBridge", () => { - it("installs the runtime binding and routes bridge requests through the dispatcher", async () => { - const dispatched: unknown[] = []; - const dispatcher = { - dispatch: async (command: unknown) => { - dispatched.push(command); - return { ok: true, data: "profile-1" }; - }, - } as AgentCdpCommandDispatcher; - const transport = new FakeBridgeTransport(); - const bridge = new AgentRuntimeBridge(dispatcher); +describe("AgentRuntimeBridgePlugin", () => { + it("installs the runtime binding and routes bridge requests through the relay", async () => { + const relayed: unknown[] = []; + const relay = async (command: unknown) => { + relayed.push(command); + return { ok: true as const, data: "profile-1" }; + }; + const session = new FakeBridgeSession(); + const plugin = new AgentRuntimeBridgePlugin(relay); - await bridge.attach(createSession(transport)); - transport.emit({ + await plugin.onTargetSelected(makeContext(session)); + session.emit({ method: "Runtime.bindingCalled", params: { name: AGENT_CDP_BINDING_NAME, @@ -77,27 +68,25 @@ describe("AgentRuntimeBridge", () => { }); await Promise.resolve(); - expect(transport.sent[0]).toEqual({ method: "Runtime.enable", params: undefined }); - expect(transport.sent[1]).toEqual({ method: "Runtime.addBinding", params: { name: AGENT_CDP_BINDING_NAME } }); - expect(dispatched).toEqual([{ type: "js-profile-stop" }]); - expect(transport.sent[2]?.method).toBe("Runtime.evaluate"); - expect(String(transport.sent[2]?.params?.expression)).toContain(AGENT_CDP_RECEIVE_NAME); - expect(String(transport.sent[2]?.params?.expression)).toContain("profile-1"); + expect(session.sent[0]).toEqual({ method: "Runtime.enable", params: undefined }); + expect(session.sent[1]).toEqual({ method: "Runtime.addBinding", params: { name: AGENT_CDP_BINDING_NAME } }); + expect(relayed).toEqual([{ type: "js-profile-stop" }]); + expect(session.sent[2]?.method).toBe("Runtime.evaluate"); + expect(String(session.sent[2]?.params?.expression)).toContain(AGENT_CDP_RECEIVE_NAME); + expect(String(session.sent[2]?.params?.expression)).toContain("profile-1"); }); - it("routes trace measurement commands through the dispatcher", async () => { - const dispatched: unknown[] = []; - const dispatcher = { - dispatch: async (command: unknown) => { - dispatched.push(command); - return { ok: true, data: { active: true, elapsedMs: 12, sessionCount: 0 } }; - }, - } as AgentCdpCommandDispatcher; - const transport = new FakeBridgeTransport(); - const bridge = new AgentRuntimeBridge(dispatcher); + it("routes trace measurement commands through the relay", async () => { + const relayed: unknown[] = []; + const relay = async (command: unknown) => { + relayed.push(command); + return { ok: true as const, data: { active: true, elapsedMs: 12, sessionCount: 0 } }; + }; + const session = new FakeBridgeSession(); + const plugin = new AgentRuntimeBridgePlugin(relay); - await bridge.attach(createSession(transport)); - transport.emit({ + await plugin.onTargetSelected(makeContext(session)); + session.emit({ method: "Runtime.bindingCalled", params: { name: AGENT_CDP_BINDING_NAME, @@ -106,23 +95,24 @@ describe("AgentRuntimeBridge", () => { }); await Promise.resolve(); - expect(dispatched).toEqual([{ type: "trace-status" }]); - expect(String(transport.sent[2]?.params?.expression)).toContain('\\"active\\":true'); + expect(relayed).toEqual([{ type: "trace-status" }]); + expect(String(session.sent[2]?.params?.expression)).toContain('\\"active\\":true'); }); - it("routes allocation measurement commands through the dispatcher", async () => { - const dispatched: unknown[] = []; - const dispatcher = { - dispatch: async (command: unknown) => { - dispatched.push(command); - return { ok: true, data: { active: true, activeName: "checkout", elapsedMs: 25, sessionCount: 1 } }; - }, - } as AgentCdpCommandDispatcher; - const transport = new FakeBridgeTransport(); - const bridge = new AgentRuntimeBridge(dispatcher); + it("routes allocation measurement commands through the relay", async () => { + const relayed: unknown[] = []; + const relay = async (command: unknown) => { + relayed.push(command); + return { + ok: true as const, + data: { active: true, activeName: "checkout", elapsedMs: 25, sessionCount: 1 }, + }; + }; + const session = new FakeBridgeSession(); + const plugin = new AgentRuntimeBridgePlugin(relay); - await bridge.attach(createSession(transport)); - transport.emit({ + await plugin.onTargetSelected(makeContext(session)); + session.emit({ method: "Runtime.bindingCalled", params: { name: AGENT_CDP_BINDING_NAME, @@ -131,23 +121,21 @@ describe("AgentRuntimeBridge", () => { }); await Promise.resolve(); - expect(dispatched).toEqual([{ type: "js-allocation-status" }]); - expect(String(transport.sent[2]?.params?.expression)).toContain('\\"activeName\\":\\"checkout\\"'); + expect(relayed).toEqual([{ type: "js-allocation-status" }]); + expect(String(session.sent[2]?.params?.expression)).toContain('\\"activeName\\":\\"checkout\\"'); }); - it("routes allocation timeline measurement commands through the dispatcher", async () => { - const dispatched: unknown[] = []; - const dispatcher = { - dispatch: async (command: unknown) => { - dispatched.push(command); - return { ok: true, data: "jat_1" }; - }, - } as AgentCdpCommandDispatcher; - const transport = new FakeBridgeTransport(); - const bridge = new AgentRuntimeBridge(dispatcher); + it("routes allocation timeline measurement commands through the relay", async () => { + const relayed: unknown[] = []; + const relay = async (command: unknown) => { + relayed.push(command); + return { ok: true as const, data: "jat_1" }; + }; + const session = new FakeBridgeSession(); + const plugin = new AgentRuntimeBridgePlugin(relay); - await bridge.attach(createSession(transport)); - transport.emit({ + await plugin.onTargetSelected(makeContext(session)); + session.emit({ method: "Runtime.bindingCalled", params: { name: AGENT_CDP_BINDING_NAME, @@ -156,37 +144,30 @@ describe("AgentRuntimeBridge", () => { }); await Promise.resolve(); - expect(dispatched).toEqual([{ type: "js-allocation-timeline-stop" }]); - expect(String(transport.sent[2]?.params?.expression)).toContain("jat_1"); + expect(relayed).toEqual([{ type: "js-allocation-timeline-stop" }]); + expect(String(session.sent[2]?.params?.expression)).toContain("jat_1"); }); - it("routes network measurement commands through the dispatcher", async () => { - const dispatched: unknown[] = []; - const dispatcher = { - dispatch: async (command: unknown) => { - dispatched.push(command); - return { - ok: true, - data: { - attached: true, - liveRequestCount: 1, - liveBufferLimit: 200, - activeSession: { - id: "net_1", - startedAt: 10, - preserveAcrossNavigation: false, - requestCount: 1, - }, - storedSessionCount: 1, - }, - }; - }, - } as AgentCdpCommandDispatcher; - const transport = new FakeBridgeTransport(); - const bridge = new AgentRuntimeBridge(dispatcher); + it("routes network measurement commands through the relay", async () => { + const relayed: unknown[] = []; + const relay = async (command: unknown) => { + relayed.push(command); + return { + ok: true as const, + data: { + attached: true, + liveRequestCount: 1, + liveBufferLimit: 200, + activeSession: { id: "net_1", startedAt: 10, preserveAcrossNavigation: false, requestCount: 1 }, + storedSessionCount: 1, + }, + }; + }; + const session = new FakeBridgeSession(); + const plugin = new AgentRuntimeBridgePlugin(relay); - await bridge.attach(createSession(transport)); - transport.emit({ + await plugin.onTargetSelected(makeContext(session)); + session.emit({ method: "Runtime.bindingCalled", params: { name: AGENT_CDP_BINDING_NAME, @@ -195,126 +176,103 @@ describe("AgentRuntimeBridge", () => { }); await Promise.resolve(); - expect(dispatched).toEqual([{ type: "network-status" }]); - expect(String(transport.sent[2]?.params?.expression)).toContain('\\"id\\":\\"net_1\\"'); + expect(relayed).toEqual([{ type: "network-status" }]); + expect(String(session.sent[2]?.params?.expression)).toContain('\\"id\\":\\"net_1\\"'); }); - it("routes memory measurement commands through the dispatcher", async () => { - const dispatched: unknown[] = []; - const dispatcher = { - dispatch: async (command: unknown) => { - dispatched.push(command); - return { - ok: true, - data: { - sampleId: "jm_1", - label: "checkout", - timestamp: 100, - usedJSHeapSize: 25, - totalJSHeapSize: 40, - jsHeapSizeLimit: 256, - source: "performance.memory", - collectGarbageRequested: true, - caveats: [], - }, - }; - }, - } as AgentCdpCommandDispatcher; - const transport = new FakeBridgeTransport(); - const bridge = new AgentRuntimeBridge(dispatcher); + it("routes memory measurement commands through the relay", async () => { + const relayed: unknown[] = []; + const relay = async (command: unknown) => { + relayed.push(command); + return { + ok: true as const, + data: { + sampleId: "jm_1", + label: "checkout", + timestamp: 100, + usedJSHeapSize: 25, + totalJSHeapSize: 40, + jsHeapSizeLimit: 256, + source: "performance.memory", + collectGarbageRequested: true, + caveats: [], + }, + }; + }; + const session = new FakeBridgeSession(); + const plugin = new AgentRuntimeBridgePlugin(relay); - await bridge.attach(createSession(transport)); - transport.emit({ + await plugin.onTargetSelected(makeContext(session)); + session.emit({ method: "Runtime.bindingCalled", params: { name: AGENT_CDP_BINDING_NAME, - payload: JSON.stringify({ - id: "1", - command: { type: "js-memory-sample", label: "checkout", collectGarbage: true }, - }), + payload: JSON.stringify({ id: "1", command: { type: "js-memory-sample", label: "checkout", collectGarbage: true } }), }, }); await Promise.resolve(); - expect(dispatched).toEqual([{ type: "js-memory-sample", label: "checkout", collectGarbage: true }]); - expect(String(transport.sent[2]?.params?.expression)).toContain('\\"sampleId\\":\\"jm_1\\"'); + expect(relayed).toEqual([{ type: "js-memory-sample", label: "checkout", collectGarbage: true }]); + expect(String(session.sent[2]?.params?.expression)).toContain('\\"sampleId\\":\\"jm_1\\"'); }); - it("routes memory snapshot capture commands through the dispatcher", async () => { - const dispatched: unknown[] = []; - const dispatcher = { - dispatch: async (command: unknown) => { - dispatched.push(command); - return { - ok: true, - data: { - snapshotId: "hs_1", - name: "before-checkout", - filePath: "/tmp/before-checkout.heapsnapshot", - capturedAt: 200, - collectGarbageRequested: true, - nodeCount: 10, - totalSelfSize: 20, - totalRetainedSize: 30, - }, - }; - }, - } as AgentCdpCommandDispatcher; - const transport = new FakeBridgeTransport(); - const bridge = new AgentRuntimeBridge(dispatcher); + it("routes memory snapshot capture commands through the relay", async () => { + const relayed: unknown[] = []; + const relay = async (command: unknown) => { + relayed.push(command); + return { + ok: true as const, + data: { + snapshotId: "hs_1", + name: "before-checkout", + filePath: "/tmp/before-checkout.heapsnapshot", + capturedAt: 200, + collectGarbageRequested: true, + nodeCount: 10, + totalSelfSize: 20, + totalRetainedSize: 30, + }, + }; + }; + const session = new FakeBridgeSession(); + const plugin = new AgentRuntimeBridgePlugin(relay); - await bridge.attach(createSession(transport)); - transport.emit({ + await plugin.onTargetSelected(makeContext(session)); + session.emit({ method: "Runtime.bindingCalled", params: { name: AGENT_CDP_BINDING_NAME, payload: JSON.stringify({ id: "1", - command: { - type: "mem-snapshot-capture", - name: "before-checkout", - collectGarbage: true, - filePath: "/tmp/before-checkout.heapsnapshot", - }, + command: { type: "mem-snapshot-capture", name: "before-checkout", collectGarbage: true, filePath: "/tmp/before-checkout.heapsnapshot" }, }), }, }); await Promise.resolve(); - expect(dispatched).toEqual([ - { - type: "mem-snapshot-capture", - name: "before-checkout", - collectGarbage: true, - filePath: "/tmp/before-checkout.heapsnapshot", - }, - ]); - expect(String(transport.sent[2]?.params?.expression)).toContain('\\"snapshotId\\":\\"hs_1\\"'); + expect(relayed).toEqual([{ type: "mem-snapshot-capture", name: "before-checkout", collectGarbage: true, filePath: "/tmp/before-checkout.heapsnapshot" }]); + expect(String(session.sent[2]?.params?.expression)).toContain('\\"snapshotId\\":\\"hs_1\\"'); }); it("reinstalls the runtime binding after execution context resets", async () => { - const dispatcher = { - dispatch: vi.fn(), - } as unknown as AgentCdpCommandDispatcher; - const transport = new FakeBridgeTransport(); - const bridge = new AgentRuntimeBridge(dispatcher); - - await bridge.attach(createSession(transport)); - transport.emit({ method: "Runtime.executionContextsCleared", params: {} }); + const relay = vi.fn(async () => ({ ok: true as const, data: null })); + const session = new FakeBridgeSession(); + const plugin = new AgentRuntimeBridgePlugin(relay); + + await plugin.onTargetSelected(makeContext(session)); + session.emit({ method: "Runtime.executionContextsCleared", params: {} }); await Promise.resolve(); - expect(transport.sent[2]).toEqual({ method: "Runtime.addBinding", params: { name: AGENT_CDP_BINDING_NAME } }); + expect(session.sent[2]).toEqual({ method: "Runtime.addBinding", params: { name: AGENT_CDP_BINDING_NAME } }); }); - it("rejects unsupported bridge commands without dispatching", async () => { - const dispatcher = { - dispatch: vi.fn(), - } as unknown as AgentCdpCommandDispatcher; - const transport = new FakeBridgeTransport(); - const bridge = new AgentRuntimeBridge(dispatcher); + it("rejects unsupported bridge commands without relaying", async () => { + const relay = vi.fn(async () => ({ ok: true as const, data: null })); + const session = new FakeBridgeSession(); + const plugin = new AgentRuntimeBridgePlugin(relay); - await bridge.attach(createSession(transport)); - transport.emit({ + await plugin.onTargetSelected(makeContext(session)); + session.emit({ method: "Runtime.bindingCalled", params: { name: AGENT_CDP_BINDING_NAME, @@ -323,19 +281,17 @@ describe("AgentRuntimeBridge", () => { }); await Promise.resolve(); - expect(dispatcher.dispatch).not.toHaveBeenCalled(); - expect(String(transport.sent[2]?.params?.expression)).toContain("Unsupported agent-cdp bridge request"); + expect(relay).not.toHaveBeenCalled(); + expect(String(session.sent[2]?.params?.expression)).toContain("Unsupported agent-cdp bridge request"); }); it("rejects allocation analysis commands at the runtime bridge boundary", async () => { - const dispatcher = { - dispatch: vi.fn(), - } as unknown as AgentCdpCommandDispatcher; - const transport = new FakeBridgeTransport(); - const bridge = new AgentRuntimeBridge(dispatcher); - - await bridge.attach(createSession(transport)); - transport.emit({ + const relay = vi.fn(async () => ({ ok: true as const, data: null })); + const session = new FakeBridgeSession(); + const plugin = new AgentRuntimeBridgePlugin(relay); + + await plugin.onTargetSelected(makeContext(session)); + session.emit({ method: "Runtime.bindingCalled", params: { name: AGENT_CDP_BINDING_NAME, @@ -344,7 +300,31 @@ describe("AgentRuntimeBridge", () => { }); await Promise.resolve(); - expect(dispatcher.dispatch).not.toHaveBeenCalled(); - expect(String(transport.sent[2]?.params?.expression)).toContain("Unsupported agent-cdp bridge request"); + expect(relay).not.toHaveBeenCalled(); + expect(String(session.sent[2]?.params?.expression)).toContain("Unsupported agent-cdp bridge request"); + }); + + it("sets state to unsupported-target for non-React-Native targets", async () => { + const relay = vi.fn(async () => ({ ok: true as const, data: null })); + const session = new FakeBridgeSession(); + session.target.kind = "chrome"; + const plugin = new AgentRuntimeBridgePlugin(relay); + + await plugin.onTargetSelected(makeContext(session)); + + expect(plugin.getState()).toEqual({ kind: "unsupported-target", reason: "only React Native targets are supported" }); + expect(session.sent).toHaveLength(0); + }); + + it("sets state to ready after successful attach and idle after clear", async () => { + const relay = vi.fn(async () => ({ ok: true as const, data: null })); + const session = new FakeBridgeSession(); + const plugin = new AgentRuntimeBridgePlugin(relay); + + expect(plugin.getState()).toEqual({ kind: "idle" }); + await plugin.onTargetSelected(makeContext(session)); + expect(plugin.getState()).toEqual({ kind: "ready" }); + await plugin.onTargetCleared({ pluginId: "runtime-bridge", target: session.target, reason: "target-cleared" }); + expect(plugin.getState()).toEqual({ kind: "idle" }); }); -}); +}); \ No newline at end of file diff --git a/packages/agent-cdp/src/bridge/runtime-bridge.ts b/packages/agent-cdp/src/bridge/runtime-bridge.ts deleted file mode 100644 index 5391d03..0000000 --- a/packages/agent-cdp/src/bridge/runtime-bridge.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - AGENT_CDP_BINDING_NAME, - AGENT_CDP_RECEIVE_NAME, - type AgentRuntimeCommand, - type AgentRuntimeBridgeRequest, - type AgentRuntimeBridgeResponse, -} from "@agent-cdp/protocol"; - -import type { AgentCdpCommandDispatcher } from "../command-dispatcher.js"; -import type { CdpEventMessage, RuntimeSession } from "../types.js"; - -interface RuntimeBindingCalledParams { - name?: string; - payload?: string; -} - -const SUPPORTED_RUNTIME_COMMANDS = new Set([ - "js-allocation-start", - "js-allocation-status", - "js-allocation-stop", - "js-allocation-timeline-start", - "js-allocation-timeline-status", - "js-allocation-timeline-stop", - "js-memory-sample", - "js-profile-start", - "js-profile-status", - "js-profile-stop", - "mem-snapshot-capture", - "network-start", - "network-status", - "network-stop", - "start-trace", - "trace-status", - "stop-trace", -]); - -export class AgentRuntimeBridge { - private session: RuntimeSession | null = null; - private removeEventListener: (() => void) | null = null; - - constructor(private readonly dispatcher: AgentCdpCommandDispatcher) {} - - async attach(session: RuntimeSession): Promise { - if (this.session === session && this.removeEventListener) { - return; - } - - this.detach(); - this.session = session; - await session.transport.send("Runtime.enable"); - await this.installBinding(session); - this.removeEventListener = session.transport.onEvent((message) => { - if (message.method === "Runtime.executionContextsCleared" || message.method === "Runtime.executionContextCreated") { - void this.reinstallBinding(session); - return; - } - if (message.method !== "Runtime.bindingCalled") { - return; - } - void this.handleBindingCalled(session, message); - }); - } - - detach(): void { - this.removeEventListener?.(); - this.removeEventListener = null; - this.session = null; - } - - private async handleBindingCalled(session: RuntimeSession, message: CdpEventMessage): Promise { - const params = message.params as RuntimeBindingCalledParams | undefined; - if (params?.name !== AGENT_CDP_BINDING_NAME) { - return; - } - - let request: AgentRuntimeBridgeRequest | null = null; - try { - request = JSON.parse(params.payload || "") as AgentRuntimeBridgeRequest; - if (!request.id || !this.isSupportedCommand(request.command)) { - throw new Error("Unsupported agent-cdp bridge request"); - } - } catch (error) { - const response: AgentRuntimeBridgeResponse = { - id: request?.id || "unknown", - ok: false, - error: error instanceof Error ? error.message : String(error), - }; - await this.sendResponse(session, response); - return; - } - - const ipcResponse = await this.dispatcher.dispatch(request.command); - const response: AgentRuntimeBridgeResponse = ipcResponse.ok - ? { id: request.id, ok: true, data: ipcResponse.data } - : { id: request.id, ok: false, error: ipcResponse.error }; - await this.sendResponse(session, response); - } - - private isSupportedCommand(command: unknown): command is AgentRuntimeBridgeRequest["command"] { - if (!command || typeof command !== "object" || !("type" in command)) { - return false; - } - - return SUPPORTED_RUNTIME_COMMANDS.has(String(command.type) as AgentRuntimeCommand["type"]); - } - - private async installBinding(session: RuntimeSession): Promise { - await session.transport.send("Runtime.addBinding", { name: AGENT_CDP_BINDING_NAME }); - } - - private async reinstallBinding(session: RuntimeSession): Promise { - if (this.session !== session || !session.transport.isConnected()) { - return; - } - - try { - await this.installBinding(session); - } catch { - // Runtime replacement can race with transport reconnect; a later reconnect attach will retry. - } - } - - private async sendResponse(session: RuntimeSession, response: AgentRuntimeBridgeResponse): Promise { - const payload = JSON.stringify(response); - const expression = `globalThis[${JSON.stringify(AGENT_CDP_RECEIVE_NAME)}]?.(${JSON.stringify(payload)})`; - await session.transport.send("Runtime.evaluate", { expression, awaitPromise: false }); - } -} diff --git a/packages/agent-cdp/src/cli/index.ts b/packages/agent-cdp/src/cli/index.ts index 88521d4..214571e 100644 --- a/packages/agent-cdp/src/cli/index.ts +++ b/packages/agent-cdp/src/cli/index.ts @@ -2,10 +2,16 @@ import { CommanderError } from "commander"; import type { CliDeps } from "./context.js"; import { defaultCliDeps, ensureTargetSelected, MULTIPLE_TARGETS_AVAILABLE_MESSAGE } from "./context.js"; import { usage } from "./help.js"; +import type { AgentPluginRegistration } from "./program.js"; import { createProgram } from "./program.js"; +import { registerCliCommands as registerRuntimeBridgeCliCommands } from "../plugins/runtime-bridge/index.js"; export { ensureTargetSelected, MULTIPLE_TARGETS_AVAILABLE_MESSAGE, usage }; +const BUILT_IN_PLUGINS: AgentPluginRegistration[] = [ + { registerCliCommands: registerRuntimeBridgeCliCommands }, +]; + function shouldPrintHelp(argv: string[]): boolean { return argv.length === 0 || argv[0] === "help" || (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")); } @@ -16,7 +22,7 @@ export async function main(argv = process.argv.slice(2), deps: CliDeps = default return; } - const program = createProgram(deps); + const program = createProgram(deps, BUILT_IN_PLUGINS); try { await program.parseAsync(argv, { from: "user" }); diff --git a/packages/agent-cdp/src/cli/program.ts b/packages/agent-cdp/src/cli/program.ts index 020b00f..70e35c2 100644 --- a/packages/agent-cdp/src/cli/program.ts +++ b/packages/agent-cdp/src/cli/program.ts @@ -8,7 +8,11 @@ import { registerProfilingCommands } from "./commands/profiling.js"; import { registerRuntimeAndConsoleCommands } from "./commands/runtime-console.js"; import { registerTargetCommands } from "./commands/target.js"; -export function createProgram(deps: CliDeps = defaultCliDeps): Command { +export interface AgentPluginRegistration { + registerCliCommands(program: Command, deps: CliDeps): void; +} + +export function createProgram(deps: CliDeps = defaultCliDeps, plugins: AgentPluginRegistration[] = []): Command { const program = new Command(); program.name("agent-cdp"); program.description("CLI for Chrome DevTools Protocol workflows"); @@ -26,5 +30,9 @@ export function createProgram(deps: CliDeps = defaultCliDeps): Command { registerMemoryCommands(program, deps); registerProfilingCommands(program, deps); + for (const plugin of plugins) { + plugin.registerCliCommands(program, deps); + } + return program; -} +} \ No newline at end of file diff --git a/packages/agent-cdp/src/daemon.ts b/packages/agent-cdp/src/daemon.ts index b6265c4..278bc24 100644 --- a/packages/agent-cdp/src/daemon.ts +++ b/packages/agent-cdp/src/daemon.ts @@ -7,7 +7,6 @@ import { getConnectionErrorMessage, shouldReattachConsoleCollector, } from "./command-dispatcher.js"; -import { AgentRuntimeBridge } from "./bridge/runtime-bridge.js"; import { ConsoleCollector } from "./console.js"; import { HeapSnapshotManager } from "./heap-snapshot/index.js"; import { JsAllocationProfiler } from "./js-allocation/index.js"; @@ -15,6 +14,8 @@ import { JsAllocationTimelineProfiler } from "./js-allocation-timeline/index.js" import { JsHeapUsageMonitor } from "./js-memory/index.js"; import { JsProfiler } from "./js-profiler/index.js"; import { NetworkManager } from "./network/index.js"; +import { PluginOrchestrator } from "./plugin-orchestrator.js"; +import { AgentRuntimeBridgePlugin } from "./plugins/runtime-bridge/index.js"; import { createTargetProviders } from "./providers.js"; import { RuntimeManager } from "./runtime/index.js"; import { SessionManager } from "./session-manager.js"; @@ -46,10 +47,13 @@ class Daemon { private readonly traceManager = new TraceManager(); private readonly jsProfiler = new JsProfiler(); private readonly commandDispatcher: AgentCdpCommandDispatcher; - private readonly runtimeBridge: AgentRuntimeBridge; + private readonly orchestrator: PluginOrchestrator; private ipcServer: net.Server | null = null; constructor() { + const bridgePlugin = new AgentRuntimeBridgePlugin((cmd) => this.commandDispatcher.dispatch(cmd)); + this.orchestrator = new PluginOrchestrator([bridgePlugin]); + this.commandDispatcher = new AgentCdpCommandDispatcher({ startedAt: this.startedAt, providers: this.providers, @@ -63,11 +67,16 @@ class Daemon { runtimeManager: this.runtimeManager, traceManager: this.traceManager, jsProfiler: this.jsProfiler, - beforeClearTarget: () => this.runtimeBridge.detach(), - afterTargetSelected: (session) => this.runtimeBridge.attach(session), - afterTargetReconnected: (session) => this.runtimeBridge.attach(session), + beforeClearTarget: () => { + void this.orchestrator.onTargetCleared(); + }, + afterTargetSelected: async (session) => { + await this.orchestrator.onTargetSelected(session); + }, + afterTargetReconnected: async (session) => { + await this.orchestrator.onTargetReconnected(session); + }, }); - this.runtimeBridge = new AgentRuntimeBridge(this.commandDispatcher); } async start(): Promise { @@ -81,6 +90,7 @@ class Daemon { } await this.startIpc(socketPath); + await this.orchestrator.start(); let buildMtime: number | undefined; try { @@ -98,10 +108,10 @@ class Daemon { fs.writeFileSync(getDaemonInfoPath(), JSON.stringify(info, null, 2)); const shutdown = () => { - void this.sessionManager.clearTarget().finally(() => { + void this.sessionManager.clearTarget().finally(async () => { this.consoleCollector.detach(); this.networkManager.detach(); - this.runtimeBridge.detach(); + await this.orchestrator.stop(); this.stop(); process.exit(0); }); @@ -139,7 +149,11 @@ class Daemon { try { const command = JSON.parse(line) as IpcCommand; - void this.commandDispatcher.dispatch(command).then((response) => { + const responsePromise = + command.type === "plugin-command" + ? this.orchestrator.dispatch(command.pluginId, command.command, command.input) + : this.commandDispatcher.dispatch(command); + void responsePromise.then((response) => { if (!connection.destroyed) { connection.write(JSON.stringify(response) + "\n"); } @@ -161,4 +175,4 @@ if (process.argv[1] && import.meta.url === new URL(process.argv[1], "file://").h void daemon.start(); } -export { Daemon, getConnectionErrorMessage, shouldReattachConsoleCollector }; +export { Daemon, getConnectionErrorMessage, shouldReattachConsoleCollector }; \ No newline at end of file diff --git a/packages/agent-cdp/src/plugin-orchestrator.ts b/packages/agent-cdp/src/plugin-orchestrator.ts new file mode 100644 index 0000000..d150a08 --- /dev/null +++ b/packages/agent-cdp/src/plugin-orchestrator.ts @@ -0,0 +1,149 @@ +import type { TargetDescriptor } from "@agent-cdp/protocol"; + +import type { + AgentPlugin, + AgentPluginCommandContext, + AgentPluginDetachContext, + AgentPluginState, + AgentPluginTargetContext, + AgentPluginTargetSession, +} from "./plugin.js"; +import type { CdpEventMessage, IpcResponse, RuntimeSession } from "./types.js"; + +export class PluginOrchestrator { + private currentSession: AgentPluginTargetSession | null = null; + private currentTarget: TargetDescriptor | null = null; + + constructor(private readonly plugins: AgentPlugin[]) { + this.validateIds(); + } + + async start(): Promise { + for (const plugin of this.plugins) { + await plugin.onDaemonStart?.(); + } + } + + async stop(): Promise { + for (const plugin of this.plugins) { + await plugin.onDaemonStop?.(); + } + } + + async onTargetSelected(session: RuntimeSession): Promise { + this.currentSession = this.wrapSession(session); + this.currentTarget = session.target; + for (const plugin of this.plugins) { + const context = this.buildTargetContext(plugin.id); + await plugin.onTargetSelected?.(context); + } + } + + async onTargetReconnected(session: RuntimeSession): Promise { + this.currentSession = this.wrapSession(session); + this.currentTarget = session.target; + for (const plugin of this.plugins) { + const context = this.buildTargetContext(plugin.id); + await plugin.onTargetReconnected?.(context); + } + } + + async onTargetCleared(): Promise { + const target = this.currentTarget; + for (const plugin of this.plugins) { + const context: AgentPluginDetachContext = { + pluginId: plugin.id, + target, + reason: "target-cleared", + }; + await plugin.onTargetCleared?.(context); + } + this.currentSession = null; + this.currentTarget = null; + } + + async dispatch(pluginId: string, command: string, input?: unknown): Promise { + const plugin = this.plugins.find((p) => p.id === pluginId); + if (!plugin) { + return { ok: false, error: `Unknown plugin '${pluginId}'` }; + } + + const state = plugin.getState(); + const stateError = this.getStateError(pluginId, state); + if (stateError) { + return { ok: false, error: stateError }; + } + + const cmd = plugin.commands.find((c) => c.name === command); + if (!cmd) { + return { ok: false, error: `Unknown command '${command}' for plugin '${pluginId}'` }; + } + + const context = this.buildCommandContext(plugin); + try { + const data = await cmd.execute(context, input); + return { ok: true, data }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + private wrapSession(session: RuntimeSession): AgentPluginTargetSession { + return { + target: session.target, + send: (method, params) => session.transport.send(method, params), + isConnected: () => session.transport.isConnected(), + onEvent: (listener) => session.transport.onEvent(listener as (event: CdpEventMessage) => void), + onDisconnected: (_listener) => () => {}, + }; + } + + private buildTargetContext(pluginId: string): AgentPluginTargetContext { + if (!this.currentSession) { + throw new Error(`Plugin '${pluginId}': no active session for target context`); + } + return { pluginId, session: this.currentSession }; + } + + private buildCommandContext(plugin: AgentPlugin): AgentPluginCommandContext { + const session = this.currentSession; + return { + pluginId: plugin.id, + session, + getState: () => plugin.getState(), + }; + } + + private getStateError(pluginId: string, state: AgentPluginState): string | null { + switch (state.kind) { + case "unsupported-target": + return `Plugin '${pluginId}' does not support the current target: ${state.reason}`; + case "waiting-for-runtime": + return `Plugin '${pluginId}' is waiting for runtime: ${state.reason}`; + case "error": + return `Plugin '${pluginId}' is in error state: ${state.reason}`; + default: + return null; + } + } + + private validateIds(): void { + const pluginIds = new Set(); + const commandIds = new Set(); + + for (const plugin of this.plugins) { + if (pluginIds.has(plugin.id)) { + throw new Error(`Duplicate plugin id: '${plugin.id}'`); + } + pluginIds.add(plugin.id); + + for (const cmd of plugin.commands) { + const derivedId = `${plugin.id}.${cmd.name}`; + if (commandIds.has(derivedId)) { + throw new Error(`Duplicate derived command id: '${derivedId}'`); + } + commandIds.add(derivedId); + } + } + } +} \ No newline at end of file diff --git a/packages/agent-cdp/src/plugin.ts b/packages/agent-cdp/src/plugin.ts new file mode 100644 index 0000000..fe6a795 --- /dev/null +++ b/packages/agent-cdp/src/plugin.ts @@ -0,0 +1,63 @@ +import type { TargetDescriptor } from "@agent-cdp/protocol"; + +import type { CdpEventMessage } from "./types.js"; + +export type AgentPluginState = + | { kind: "idle" } + | { kind: "unsupported-target"; reason: string } + | { kind: "waiting-for-runtime"; reason: string } + | { kind: "ready" } + | { kind: "error"; reason: string }; + +export interface AgentPluginTargetSession { + readonly target: TargetDescriptor; + + send(method: string, params?: Record): Promise; + isConnected(): boolean; + + onEvent(listener: (event: CdpEventMessage) => void): () => void; + onDisconnected(listener: (error?: Error) => void): () => void; +} + +export interface AgentPluginCommandContext { + readonly pluginId: string; + readonly session: AgentPluginTargetSession | null; + + getState(): AgentPluginState; +} + +export interface AgentPluginTargetContext { + readonly pluginId: string; + readonly session: AgentPluginTargetSession; +} + +export interface AgentPluginDetachContext { + readonly pluginId: string; + readonly target: TargetDescriptor | null; + readonly reason: "target-cleared" | "target-disconnected" | "daemon-stopping"; +} + +export interface AgentPluginCommand { + readonly name: string; + readonly summary: string; + readonly description?: string; + + execute(context: AgentPluginCommandContext, input?: unknown): Promise; +} + +export interface AgentPlugin { + readonly id: string; + readonly displayName: string; + readonly description?: string; + readonly commands: readonly AgentPluginCommand[]; + + supportsTarget(target: TargetDescriptor): boolean; + getState(): AgentPluginState; + + onDaemonStart?(): Promise; + onDaemonStop?(): Promise; + onTargetSelected?(context: AgentPluginTargetContext): Promise; + onTargetReconnected?(context: AgentPluginTargetContext): Promise; + onTargetCleared?(context: AgentPluginDetachContext): Promise; + onTargetDisconnected?(context: AgentPluginDetachContext): Promise; +} \ No newline at end of file diff --git a/packages/agent-cdp/src/plugins/runtime-bridge/index.ts b/packages/agent-cdp/src/plugins/runtime-bridge/index.ts new file mode 100644 index 0000000..93f06bb --- /dev/null +++ b/packages/agent-cdp/src/plugins/runtime-bridge/index.ts @@ -0,0 +1,185 @@ +import { + AGENT_CDP_BINDING_NAME, + AGENT_CDP_RECEIVE_NAME, + type AgentRuntimeBridgeRequest, + type AgentRuntimeBridgeResponse, + type AgentRuntimeMeasurementCommand, + type IpcResponse, + type TargetDescriptor, +} from "@agent-cdp/protocol"; +import type { Command } from "commander"; + +import type { CliDeps } from "../../cli/context.js"; + +import type { + AgentPlugin, + AgentPluginCommand, + AgentPluginDetachContext, + AgentPluginState, + AgentPluginTargetContext, + AgentPluginTargetSession, +} from "../../plugin.js"; + +type CoreCommandRelay = (cmd: AgentRuntimeMeasurementCommand) => Promise; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function registerCliCommands(_program: Command, _deps: CliDeps): void { + // No CLI commands for the runtime bridge yet. +} + +interface RuntimeBindingCalledParams { + name?: string; + payload?: string; +} + +const SUPPORTED_RUNTIME_COMMANDS = new Set([ + "js-allocation-start", + "js-allocation-status", + "js-allocation-stop", + "js-allocation-timeline-start", + "js-allocation-timeline-status", + "js-allocation-timeline-stop", + "js-memory-sample", + "js-profile-start", + "js-profile-status", + "js-profile-stop", + "mem-snapshot-capture", + "network-start", + "network-status", + "network-stop", + "start-trace", + "trace-status", + "stop-trace", +]); + +export class AgentRuntimeBridgePlugin implements AgentPlugin { + readonly id = "runtime-bridge"; + readonly displayName = "Runtime Bridge"; + readonly description = "In-app SDK measurement bridge for React Native targets"; + readonly commands: readonly AgentPluginCommand[] = []; + + private state: AgentPluginState = { kind: "idle" }; + private session: AgentPluginTargetSession | null = null; + private removeEventListener: (() => void) | null = null; + + constructor(private readonly dispatchCoreCommand: CoreCommandRelay) {} + + getState(): AgentPluginState { + return this.state; + } + + supportsTarget(target: TargetDescriptor): boolean { + return target.kind === "react-native"; + } + + async onTargetSelected(ctx: AgentPluginTargetContext): Promise { + if (!this.supportsTarget(ctx.session.target)) { + this.state = { kind: "unsupported-target", reason: "only React Native targets are supported" }; + return; + } + await this.attach(ctx.session); + } + + async onTargetReconnected(ctx: AgentPluginTargetContext): Promise { + if (!this.supportsTarget(ctx.session.target)) { + this.state = { kind: "unsupported-target", reason: "only React Native targets are supported" }; + return; + } + await this.attach(ctx.session); + } + + async onTargetCleared(_ctx: AgentPluginDetachContext): Promise { + this.detach(); + } + + private async attach(session: AgentPluginTargetSession): Promise { + if (this.session === session && this.removeEventListener) { + return; + } + + this.detach(); + this.session = session; + await session.send("Runtime.enable"); + await this.installBinding(session); + this.removeEventListener = session.onEvent((message) => { + if ( + message.method === "Runtime.executionContextsCleared" || + message.method === "Runtime.executionContextCreated" + ) { + void this.reinstallBinding(session); + return; + } + if (message.method !== "Runtime.bindingCalled") { + return; + } + void this.handleBindingCalled(session, message.params as RuntimeBindingCalledParams | undefined); + }); + this.state = { kind: "ready" }; + } + + private detach(): void { + this.removeEventListener?.(); + this.removeEventListener = null; + this.session = null; + this.state = { kind: "idle" }; + } + + private async handleBindingCalled( + session: AgentPluginTargetSession, + params: RuntimeBindingCalledParams | undefined, + ): Promise { + if (params?.name !== AGENT_CDP_BINDING_NAME) { + return; + } + + let request: AgentRuntimeBridgeRequest | null = null; + try { + request = JSON.parse(params.payload || "") as AgentRuntimeBridgeRequest; + if (!request.id || !this.isSupportedCommand(request.command)) { + throw new Error("Unsupported agent-cdp bridge request"); + } + } catch (error) { + const response: AgentRuntimeBridgeResponse = { + id: request?.id || "unknown", + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + await this.sendResponse(session, response); + return; + } + + const ipcResponse = await this.dispatchCoreCommand(request.command); + const response: AgentRuntimeBridgeResponse = ipcResponse.ok + ? { id: request.id, ok: true, data: ipcResponse.data } + : { id: request.id, ok: false, error: ipcResponse.error }; + await this.sendResponse(session, response); + } + + private isSupportedCommand(command: unknown): command is AgentRuntimeBridgeRequest["command"] { + if (!command || typeof command !== "object" || !("type" in command)) { + return false; + } + return SUPPORTED_RUNTIME_COMMANDS.has(String(command.type) as AgentRuntimeMeasurementCommand["type"]); + } + + private async installBinding(session: AgentPluginTargetSession): Promise { + await session.send("Runtime.addBinding", { name: AGENT_CDP_BINDING_NAME }); + } + + private async reinstallBinding(session: AgentPluginTargetSession): Promise { + if (this.session !== session || !session.isConnected()) { + return; + } + try { + await this.installBinding(session); + } catch { + // Runtime replacement can race with transport reconnect; a later reconnect attach will retry. + } + } + + private async sendResponse(session: AgentPluginTargetSession, response: AgentRuntimeBridgeResponse): Promise { + const payload = JSON.stringify(response); + const expression = `globalThis[${JSON.stringify(AGENT_CDP_RECEIVE_NAME)}]?.(${JSON.stringify(payload)})`; + await session.send("Runtime.evaluate", { expression, awaitPromise: false }); + } +} \ No newline at end of file diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 936be18..7af4d17 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -171,7 +171,8 @@ export type IpcCommand = | { type: "js-memory-summary" } | { type: "js-memory-diff"; baseSampleId: string; compareSampleId: string } | { type: "js-memory-trend"; limit?: number } - | { type: "js-memory-leak-signal"; sinceSampleId?: string }; + | { type: "js-memory-leak-signal"; sinceSampleId?: string } + | { type: "plugin-command"; pluginId: string; command: string; input?: unknown }; export type IpcResponse = | { ok: true; data: unknown } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0e90cf8..0669272 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ packages: - packages/* - playground +allowBuilds: + esbuild: true + simple-git-hooks: true + unrs-resolver: true