Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ coverage/
.DS_Store
smoke-trace.json
smoke.heapsnapshot
.idea
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/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`
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions docs/PLUGINS.md
Original file line number Diff line number Diff line change
@@ -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 <plugin-id> <command>` subcommand family. Commands are never added dynamically after a target connects.

## Adding a plugin

1. Create `packages/agent-cdp/src/plugins/<id>/index.ts` implementing `AgentPlugin`.
2. Export `registerCliCommands(program, deps)` from the same file. CLI subcommands send `{ type: "plugin-command", pluginId: "<id>", command: "<name>", 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.
4 changes: 4 additions & 0 deletions packages/agent-cdp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
278 changes: 278 additions & 0 deletions packages/agent-cdp/src/__tests__/plugin-orchestrator.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<AgentPlugin> & { 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();
});
});
});
Loading
Loading