Skip to content

Commit 369c674

Browse files
authored
Add built-in plugin system for namespaced target-scoped integrations (#28)
## Summary Closes #26. This PR adds a built-in plugin system that lets target-scoped integrations (e.g. Rozenite) be added without leaking runtime-specific behavior into the core daemon, dispatcher, CLI, or protocol types. - **Protocol** — one generic `plugin-command` IPC envelope added to `IpcCommand`; the union never needs to widen for a new plugin - **`AgentPlugin` interface** (`src/plugin.ts`) — plugins declare a unique id, static commands, a `supportsTarget()` predicate, a state machine (`idle / ready / unsupported-target / waiting-for-runtime / error`), and optional daemon/target lifecycle hooks - **`PluginOrchestrator`** (`src/plugin-orchestrator.ts`) — validates unique plugin/command ids at construction, wraps `RuntimeSession` into a narrow `AgentPluginTargetSession`, fans out lifecycle events, and dispatches `plugin-command` IPC with state enforcement before calling `execute()` - **`AgentRuntimeBridge` → plugin** — migrated to `src/plugins/runtime-bridge/index.ts` implementing `AgentPlugin`; uses an injected `CoreCommandRelay` instead of holding the dispatcher directly; checks `supportsTarget` in lifecycle hooks (RN-only) - **Daemon** — intercepts `plugin-command` before the dispatcher; sequences orchestrator lifecycle alongside core attachments via existing callbacks; calls `orchestrator.start/stop` around daemon lifetime - **CLI** — `createProgram` gains a `plugins: AgentPluginRegistration[]` parameter; `BUILT_IN_PLUGINS` assembled in `cli/index.ts`; bridge `registerCliCommands` is a no-op placeholder for future subcommands ## Backward compatibility All existing commands, flags, outputs, and daemon behavior are unchanged. The `plugin-command` IPC type is additive. The runtime bridge behavior is identical — it now runs through the plugin seam transparently. ## Risks - The `CoreCommandRelay` is a closure over `this.commandDispatcher` constructed before the dispatcher is assigned. This is safe because the relay is only called at runtime, never during construction — same pattern as the original bridge. - `onTargetDisconnected` is declared in the interface but not yet wired. No existing behavior changes. - `beforeClearTarget` fires orchestrator cleanup as fire-and-forget (`void`). Plugin `onTargetCleared` implementations that do meaningful async work should be aware of this for the shutdown path. ## Manual testing Requires a React Native app with `@agent-cdp/sdk` installed (e.g. the `playground` app in this repo). ```sh # build pnpm build # start daemon pnpm run agent-cdp start # confirm daemon is running and bridge plugin is active pnpm run agent-cdp status # select a React Native target pnpm run agent-cdp target list pnpm run agent-cdp target select <rn-target-id> # trigger an in-app SDK measurement (via playground or your own app) # verify it routes correctly — e.g. sample memory from the app pnpm run agent-cdp memory usage list # confirm unknown plugin-command returns a clean error echo '{"type":"plugin-command","pluginId":"nope","command":"cmd"}' | nc -U ~/.agent-cdp/daemon.sock # stop daemon pnpm run agent-cdp stop ``` Expected: all existing workflows unchanged; unknown plugin command returns `{"ok":false,"error":"Unknown plugin 'nope'"}`.
1 parent f2ad4cf commit 369c674

15 files changed

Lines changed: 947 additions & 358 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ coverage/
55
.DS_Store
66
smoke-trace.json
77
smoke.heapsnapshot
8+
.idea

AGENTS.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ Minimal operating guide for AI coding agents in this repo.
4343
- Keep files agent-readable:
4444
- avoid growing already-large router files
4545
- prefer extracting focused helpers before adding another major command branch to `src/cli.ts` or `src/daemon.ts`
46-
- Keep `packages/agent-cdp/src/daemon.ts` as an IPC command router and orchestrator, not the home for analysis logic.
46+
- 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`.
47+
- 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.
48+
- 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.
4749
- Keep `packages/agent-cdp/src/cli.ts` focused on argument parsing, command dispatch, and formatting.
4850
- Put command logic in domain modules:
4951
- target discovery: `src/discovery.ts`
@@ -98,6 +100,9 @@ Minimal operating guide for AI coding agents in this repo.
98100
- JS CPU profiling: `src/js-profiler/*`
99101
- CLI help and parsing: `src/cli.ts`, `src/__tests__/cli.test.ts`
100102
- formatting: `src/formatters.ts`, `src/heap-snapshot/formatters.ts`, `src/js-memory/formatters.ts`, `src/js-profiler/formatters.ts`
103+
- plugin system interfaces: `src/plugin.ts`
104+
- plugin orchestrator (routing, lifecycle, dispatch): `src/plugin-orchestrator.ts`, `src/__tests__/plugin-orchestrator.test.ts`
105+
- runtime bridge plugin: `src/plugins/runtime-bridge/index.ts`, `src/__tests__/runtime-bridge.test.ts`
101106

102107
## Pull Requests
103108
- Start the PR description with end-user impact:

docs/PLUGINS.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Built-in plugin system
2+
3+
`agent-cdp` has a built-in plugin system for adding target-scoped integrations without touching the core daemon, dispatcher, CLI, or protocol types.
4+
5+
## How it works
6+
7+
- **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.
8+
- **`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.).
9+
- **`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.
10+
- **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.
11+
12+
## Adding a plugin
13+
14+
1. Create `packages/agent-cdp/src/plugins/<id>/index.ts` implementing `AgentPlugin`.
15+
2. Export `registerCliCommands(program, deps)` from the same file. CLI subcommands send `{ type: "plugin-command", pluginId: "<id>", command: "<name>", input: {...} }`.
16+
3. Instantiate the plugin and add it to `new PluginOrchestrator([...])` in `src/daemon.ts`.
17+
4. Add `{ registerCliCommands }` to `BUILT_IN_PLUGINS` in `src/cli/index.ts`.
18+
19+
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.

packages/agent-cdp/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ Commands are grouped as **daemon**, **target**, **console**, **runtime**, **netw
112112
113113
For the runtime SDK bridge and in-app profiling, see `docs/SDK.md`.
114114
115+
## Built-in plugin system
116+
117+
For architecture, interface contract, and a step-by-step guide to adding a plugin, see [`docs/PLUGINS.md`](../../docs/PLUGINS.md).
118+
115119
## Runtime inspection
116120
117121
Use `runtime` for live state inspection when you need more than captured console output.
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import type { AgentPlugin, AgentPluginCommand, AgentPluginState, AgentPluginTargetSession } from "../plugin.js";
4+
import { PluginOrchestrator } from "../plugin-orchestrator.js";
5+
import type { CdpTransport, CdpEventMessage, RuntimeSession } from "../types.js";
6+
7+
function makeTransport(overrides: Partial<CdpTransport> = {}): CdpTransport {
8+
return {
9+
connect: vi.fn(),
10+
disconnect: vi.fn(),
11+
isConnected: vi.fn(() => true),
12+
send: vi.fn(async () => undefined),
13+
onEvent: vi.fn(() => () => {}),
14+
...overrides,
15+
};
16+
}
17+
18+
function makeSession(transport = makeTransport()): RuntimeSession {
19+
return {
20+
target: {
21+
id: "rn:test:1",
22+
rawId: "test-1",
23+
title: "Test App",
24+
kind: "react-native",
25+
description: "Test",
26+
webSocketDebuggerUrl: "ws://localhost/devtools/1",
27+
sourceUrl: "http://localhost",
28+
},
29+
transport,
30+
ensureConnected: vi.fn(async () => {}),
31+
close: vi.fn(async () => {}),
32+
};
33+
}
34+
35+
function makePlugin(
36+
id: string,
37+
overrides: Partial<AgentPlugin> & { commands?: AgentPluginCommand[]; state?: AgentPluginState } = {},
38+
): AgentPlugin {
39+
const { state = { kind: "idle" }, commands = [], ...rest } = overrides;
40+
return {
41+
id,
42+
displayName: id,
43+
commands,
44+
supportsTarget: vi.fn(() => true),
45+
getState: vi.fn(() => state),
46+
...rest,
47+
};
48+
}
49+
50+
function makeCommand(name: string, result: unknown = null): AgentPluginCommand {
51+
return {
52+
name,
53+
summary: name,
54+
execute: vi.fn(async () => result),
55+
};
56+
}
57+
58+
describe("PluginOrchestrator", () => {
59+
describe("constructor validation", () => {
60+
it("throws on duplicate plugin ids", () => {
61+
expect(() => new PluginOrchestrator([makePlugin("foo"), makePlugin("foo")])).toThrow(
62+
"Duplicate plugin id: 'foo'",
63+
);
64+
});
65+
66+
it("throws on duplicate derived command ids across plugins", () => {
67+
const a = makePlugin("foo", { commands: [makeCommand("bar")] });
68+
const b = makePlugin("foo", { commands: [makeCommand("bar")] });
69+
// same plugin id already triggers first — use different plugin ids but same derived id isn't possible
70+
// test same command name within one plugin isn't a derived-id collision, but two plugins with same id is caught first
71+
// test the command id path: same plugin id would be caught first, so we need to test a hypothetical
72+
// where two different plugins share a derived id — that cannot happen since derived = pluginId.commandName
73+
// and plugin ids must be unique. So just verify the plugin-id duplicate is caught.
74+
expect(() => new PluginOrchestrator([a, b])).toThrow("Duplicate plugin id: 'foo'");
75+
});
76+
77+
it("accepts distinct plugin ids", () => {
78+
expect(() => new PluginOrchestrator([makePlugin("foo"), makePlugin("bar")])).not.toThrow();
79+
});
80+
});
81+
82+
describe("dispatch", () => {
83+
it("returns error for unknown plugin", async () => {
84+
const o = new PluginOrchestrator([]);
85+
const result = await o.dispatch("nope", "cmd");
86+
expect(result).toEqual({ ok: false, error: "Unknown plugin 'nope'" });
87+
});
88+
89+
it("returns error for unknown command", async () => {
90+
const o = new PluginOrchestrator([makePlugin("p")]);
91+
const result = await o.dispatch("p", "nope");
92+
expect(result).toEqual({ ok: false, error: "Unknown command 'nope' for plugin 'p'" });
93+
});
94+
95+
it("returns error when state is unsupported-target", async () => {
96+
const plugin = makePlugin("p", {
97+
state: { kind: "unsupported-target", reason: "chrome target not supported" },
98+
commands: [makeCommand("cmd")],
99+
});
100+
const o = new PluginOrchestrator([plugin]);
101+
const result = await o.dispatch("p", "cmd");
102+
expect(result).toEqual({
103+
ok: false,
104+
error: "Plugin 'p' does not support the current target: chrome target not supported",
105+
});
106+
});
107+
108+
it("returns error when state is waiting-for-runtime", async () => {
109+
const plugin = makePlugin("p", {
110+
state: { kind: "waiting-for-runtime", reason: "bridge not installed" },
111+
commands: [makeCommand("cmd")],
112+
});
113+
const o = new PluginOrchestrator([plugin]);
114+
const result = await o.dispatch("p", "cmd");
115+
expect(result).toEqual({
116+
ok: false,
117+
error: "Plugin 'p' is waiting for runtime: bridge not installed",
118+
});
119+
});
120+
121+
it("returns error when state is error", async () => {
122+
const plugin = makePlugin("p", {
123+
state: { kind: "error", reason: "crashed" },
124+
commands: [makeCommand("cmd")],
125+
});
126+
const o = new PluginOrchestrator([plugin]);
127+
const result = await o.dispatch("p", "cmd");
128+
expect(result).toEqual({ ok: false, error: "Plugin 'p' is in error state: crashed" });
129+
});
130+
131+
it("executes command and returns data when state is ready", async () => {
132+
const cmd = makeCommand("cmd", { value: 42 });
133+
const plugin = makePlugin("p", { state: { kind: "ready" }, commands: [cmd] });
134+
const o = new PluginOrchestrator([plugin]);
135+
const result = await o.dispatch("p", "cmd", { x: 1 });
136+
expect(result).toEqual({ ok: true, data: { value: 42 } });
137+
expect(cmd.execute).toHaveBeenCalledWith(expect.objectContaining({ pluginId: "p" }), { x: 1 });
138+
});
139+
140+
it("executes command when state is idle", async () => {
141+
const cmd = makeCommand("cmd", "ok");
142+
const plugin = makePlugin("p", { state: { kind: "idle" }, commands: [cmd] });
143+
const o = new PluginOrchestrator([plugin]);
144+
const result = await o.dispatch("p", "cmd");
145+
expect(result).toEqual({ ok: true, data: "ok" });
146+
});
147+
148+
it("returns error when command throws", async () => {
149+
const cmd: AgentPluginCommand = {
150+
name: "cmd",
151+
summary: "cmd",
152+
execute: vi.fn(async () => {
153+
throw new Error("boom");
154+
}),
155+
};
156+
const plugin = makePlugin("p", { commands: [cmd] });
157+
const o = new PluginOrchestrator([plugin]);
158+
const result = await o.dispatch("p", "cmd");
159+
expect(result).toEqual({ ok: false, error: "boom" });
160+
});
161+
162+
it("passes current session through command context", async () => {
163+
let capturedSession: AgentPluginTargetSession | null | undefined;
164+
const cmd: AgentPluginCommand = {
165+
name: "cmd",
166+
summary: "cmd",
167+
execute: vi.fn(async (ctx) => {
168+
capturedSession = ctx.session;
169+
return null;
170+
}),
171+
};
172+
const plugin = makePlugin("p", { commands: [cmd] });
173+
const o = new PluginOrchestrator([plugin]);
174+
const session = makeSession();
175+
await o.onTargetSelected(session);
176+
await o.dispatch("p", "cmd");
177+
expect(capturedSession).not.toBeNull();
178+
expect(capturedSession?.target.id).toBe("rn:test:1");
179+
});
180+
181+
it("exposes null session in command context when no target is selected", async () => {
182+
let capturedSession: AgentPluginTargetSession | null | undefined;
183+
const cmd: AgentPluginCommand = {
184+
name: "cmd",
185+
summary: "cmd",
186+
execute: vi.fn(async (ctx) => {
187+
capturedSession = ctx.session;
188+
return null;
189+
}),
190+
};
191+
const plugin = makePlugin("p", { commands: [cmd] });
192+
const o = new PluginOrchestrator([plugin]);
193+
await o.dispatch("p", "cmd");
194+
expect(capturedSession).toBeNull();
195+
});
196+
});
197+
198+
describe("lifecycle", () => {
199+
it("calls onDaemonStart on all plugins", async () => {
200+
const a = makePlugin("a", { onDaemonStart: vi.fn(async () => {}) });
201+
const b = makePlugin("b", { onDaemonStart: vi.fn(async () => {}) });
202+
const o = new PluginOrchestrator([a, b]);
203+
await o.start();
204+
expect(a.onDaemonStart).toHaveBeenCalled();
205+
expect(b.onDaemonStart).toHaveBeenCalled();
206+
});
207+
208+
it("calls onDaemonStop on all plugins", async () => {
209+
const a = makePlugin("a", { onDaemonStop: vi.fn(async () => {}) });
210+
const o = new PluginOrchestrator([a]);
211+
await o.stop();
212+
expect(a.onDaemonStop).toHaveBeenCalled();
213+
});
214+
215+
it("calls onTargetSelected with correct context", async () => {
216+
const plugin = makePlugin("p", { onTargetSelected: vi.fn(async () => {}) });
217+
const o = new PluginOrchestrator([plugin]);
218+
await o.onTargetSelected(makeSession());
219+
expect(plugin.onTargetSelected).toHaveBeenCalledWith(
220+
expect.objectContaining({ pluginId: "p", session: expect.objectContaining({ target: expect.any(Object) }) }),
221+
);
222+
});
223+
224+
it("calls onTargetReconnected with correct context", async () => {
225+
const plugin = makePlugin("p", { onTargetReconnected: vi.fn(async () => {}) });
226+
const o = new PluginOrchestrator([plugin]);
227+
await o.onTargetSelected(makeSession());
228+
await o.onTargetReconnected(makeSession());
229+
expect(plugin.onTargetReconnected).toHaveBeenCalledWith(
230+
expect.objectContaining({ pluginId: "p" }),
231+
);
232+
});
233+
234+
it("calls onTargetCleared with reason target-cleared and clears session", async () => {
235+
const plugin = makePlugin("p", { onTargetCleared: vi.fn(async () => {}) });
236+
const o = new PluginOrchestrator([plugin]);
237+
const session = makeSession();
238+
await o.onTargetSelected(session);
239+
await o.onTargetCleared();
240+
expect(plugin.onTargetCleared).toHaveBeenCalledWith(
241+
expect.objectContaining({ pluginId: "p", reason: "target-cleared" }),
242+
);
243+
// session should be null after clear
244+
let capturedSession: AgentPluginTargetSession | null | undefined;
245+
const cmd: AgentPluginCommand = {
246+
name: "cmd",
247+
summary: "cmd",
248+
execute: vi.fn(async (ctx) => { capturedSession = ctx.session; return null; }),
249+
};
250+
(plugin.commands as AgentPluginCommand[]).push(cmd);
251+
await o.dispatch("p", "cmd");
252+
expect(capturedSession).toBeNull();
253+
});
254+
255+
it("wraps RuntimeSession transport correctly", async () => {
256+
const transport = makeTransport();
257+
const session = makeSession(transport);
258+
let capturedSession: AgentPluginTargetSession | null | undefined;
259+
const cmd: AgentPluginCommand = {
260+
name: "cmd",
261+
summary: "cmd",
262+
execute: vi.fn(async (ctx) => { capturedSession = ctx.session; return null; }),
263+
};
264+
const plugin = makePlugin("p", { commands: [cmd] });
265+
const o = new PluginOrchestrator([plugin]);
266+
await o.onTargetSelected(session);
267+
await o.dispatch("p", "cmd");
268+
269+
expect(capturedSession?.isConnected()).toBe(true);
270+
await capturedSession?.send("Runtime.enable");
271+
expect(transport.send).toHaveBeenCalledWith("Runtime.enable", undefined);
272+
273+
const listener = vi.fn((_event: CdpEventMessage) => {});
274+
capturedSession?.onEvent(listener);
275+
expect(transport.onEvent).toHaveBeenCalled();
276+
});
277+
});
278+
});

0 commit comments

Comments
 (0)