npc-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ import { EventEmitter } from 'events';
2
+ export declare class NPCRelay extends EventEmitter {
3
+ private port;
4
+ private wss;
5
+ private extensionWs;
6
+ private pending;
7
+ private nextId;
8
+ private sessionId;
9
+ private sessions;
10
+ private pingInterval;
11
+ constructor(port?: number);
12
+ private handleExtension;
13
+ /** Wait until the Chrome extension connects and a tab session is ready */
14
+ waitForReady(timeoutMs?: number): Promise<string>;
15
+ /** Send a CDP command and await the response */
16
+ cdp(method: string, params?: Record<string, any>, sessionId?: string): Promise<any>;
17
+ /** CORS-bypassing authenticated fetch via the extension */
18
+ fetch(url: string, options?: Record<string, any>): Promise<any>;
19
+ get activeSession(): string | null;
20
+ get allSessions(): Map<string, string>;
21
+ isConnected(): boolean;
22
+ close(): void;
23
+ }
package/dist/relay.js ADDED
@@ -0,0 +1,259 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ import { EventEmitter } from 'events';
3
+ import { createServer } from 'http';
4
+ export class NPCRelay extends EventEmitter {
5
+ port;
6
+ wss;
7
+ extensionWs = null;
8
+ pending = new Map();
9
+ nextId = 1;
10
+ sessionId = null;
11
+ sessions = new Map(); // sessionId -> targetId
12
+ pingInterval = null;
13
+ constructor(port = 7221) {
14
+ super();
15
+ this.port = port;
16
+ // Use an HTTP server so we can inspect req.url per connection
17
+ const server = createServer(async (req, res) => {
18
+ if (req.method === 'POST' && req.url === '/cdp') {
19
+ let body = '';
20
+ req.on('data', chunk => { body += chunk; });
21
+ req.on('end', async () => {
22
+ res.setHeader('Content-Type', 'application/json');
23
+ try {
24
+ const { method, params, sessionId } = JSON.parse(body);
25
+ if (!this.isConnected()) {
26
+ res.writeHead(503);
27
+ res.end(JSON.stringify({ error: 'Extension not connected' }));
28
+ return;
29
+ }
30
+ if (!this.sessionId && !sessionId) {
31
+ await this.waitForReady(10000);
32
+ }
33
+ const result = await this.cdp(method, params ?? {}, sessionId);
34
+ res.end(JSON.stringify({ result }));
35
+ }
36
+ catch (e) {
37
+ res.writeHead(500);
38
+ res.end(JSON.stringify({ error: e.message }));
39
+ }
40
+ });
41
+ return;
42
+ }
43
+ if (req.method === 'POST' && req.url === '/attach') {
44
+ res.setHeader('Content-Type', 'application/json');
45
+ try {
46
+ if (!this.isConnected()) {
47
+ res.writeHead(503);
48
+ res.end(JSON.stringify({ error: 'Extension not connected' }));
49
+ return;
50
+ }
51
+ const id = this.nextId++;
52
+ await new Promise((resolve, reject) => {
53
+ const timer = setTimeout(() => reject(new Error('attach timeout')), 10000);
54
+ const onSession = () => { clearTimeout(timer); this.removeListener('sessionReady', onSession); resolve(); };
55
+ this.once('sessionReady', onSession);
56
+ this.extensionWs.send(JSON.stringify({ id, method: 'attachActiveTab' }));
57
+ });
58
+ res.end(JSON.stringify({ sessionId: this.sessionId }));
59
+ }
60
+ catch (e) {
61
+ res.writeHead(500);
62
+ res.end(JSON.stringify({ error: e.message }));
63
+ }
64
+ return;
65
+ }
66
+ if (req.method === 'GET' && req.url === '/status') {
67
+ res.setHeader('Content-Type', 'application/json');
68
+ res.end(JSON.stringify({
69
+ connected: this.isConnected(),
70
+ sessionId: this.sessionId,
71
+ sessions: Object.fromEntries(this.sessions)
72
+ }));
73
+ return;
74
+ }
75
+ res.writeHead(404);
76
+ res.end();
77
+ });
78
+ this.wss = new WebSocketServer({ server });
79
+ this.wss.on('connection', (ws, req) => {
80
+ const url = req.url ?? '';
81
+ if (url === '/extension' || url.startsWith('/extension')) {
82
+ this.handleExtension(ws);
83
+ }
84
+ // Unknown paths — ignore (future: /cli for remote control)
85
+ });
86
+ server.listen(port);
87
+ }
88
+ // ─── Extension connection ───────────────────────────────────────────────────
89
+ handleExtension(ws) {
90
+ const prev = this.extensionWs;
91
+ if (prev && prev !== ws) {
92
+ prev.close();
93
+ }
94
+ this.extensionWs = ws;
95
+ if (this.pingInterval) {
96
+ clearInterval(this.pingInterval);
97
+ this.pingInterval = null;
98
+ }
99
+ this.emit('extensionConnected');
100
+ // Send keepalive pings every 20s (extension expects them)
101
+ this.pingInterval = setInterval(() => {
102
+ if (ws.readyState === WebSocket.OPEN) {
103
+ ws.send(JSON.stringify({ method: 'ping' }));
104
+ }
105
+ }, 20_000);
106
+ ws.on('message', (raw) => {
107
+ let msg;
108
+ try {
109
+ msg = JSON.parse(raw.toString());
110
+ }
111
+ catch {
112
+ return;
113
+ }
114
+ // Responses to our commands (have an id)
115
+ if (msg.id !== undefined) {
116
+ const pending = this.pending.get(msg.id);
117
+ if (pending) {
118
+ this.pending.delete(msg.id);
119
+ if (msg.error) {
120
+ pending.reject(new Error(typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error)));
121
+ }
122
+ else {
123
+ pending.resolve(msg.result);
124
+ }
125
+ }
126
+ return;
127
+ }
128
+ // CDP events forwarded from the extension
129
+ if (msg.method === 'forwardCDPEvent') {
130
+ const { method, params } = msg.params ?? {};
131
+ // Track sessions - store all, set most recent as active
132
+ if (method === 'Target.attachedToTarget' && params?.sessionId) {
133
+ this.sessions.set(params.sessionId, params.targetInfo?.targetId ?? '');
134
+ this.sessionId = params.sessionId;
135
+ this.emit('sessionReady', params.sessionId);
136
+ }
137
+ if (method === 'Target.detachedFromTarget' && params?.sessionId) {
138
+ this.sessions.delete(params.sessionId);
139
+ if (this.sessionId === params.sessionId) {
140
+ // Fall back to another session if available
141
+ const remaining = [...this.sessions.keys()];
142
+ this.sessionId = remaining.length > 0 ? remaining[remaining.length - 1] : null;
143
+ }
144
+ }
145
+ this.emit('cdpEvent', { method, params });
146
+ }
147
+ // Pong — nothing to do
148
+ });
149
+ ws.on('close', () => {
150
+ if (this.extensionWs !== ws)
151
+ return;
152
+ this.extensionWs = null;
153
+ this.sessionId = null;
154
+ this.sessions.clear();
155
+ if (this.pingInterval)
156
+ clearInterval(this.pingInterval);
157
+ this.pingInterval = null;
158
+ // Reject all in-flight CDP commands immediately
159
+ for (const [id, pending] of this.pending) {
160
+ pending.reject(new Error('Extension disconnected'));
161
+ }
162
+ this.pending.clear();
163
+ this.emit('extensionDisconnected');
164
+ });
165
+ ws.on('error', (err) => {
166
+ process.stderr.write(`[npc] WebSocket error: ${err.message}\n`);
167
+ });
168
+ }
169
+ // ─── Public API ─────────────────────────────────────────────────────────────
170
+ /** Wait until the Chrome extension connects and a tab session is ready */
171
+ async waitForReady(timeoutMs = 30_000) {
172
+ if (this.sessionId)
173
+ return this.sessionId;
174
+ return new Promise((resolve, reject) => {
175
+ const cleanup = () => {
176
+ clearTimeout(timer);
177
+ this.removeListener('sessionReady', onSession);
178
+ this.removeListener('extensionConnected', onExtension);
179
+ };
180
+ const timer = setTimeout(() => {
181
+ cleanup();
182
+ reject(new Error('Timed out waiting for browser extension.\n' +
183
+ '1. Start relay: npm run relay\n' +
184
+ '2. Load extension in Brave: brave://extensions -> Load unpacked -> ./extension/\n' +
185
+ '3. Click the NPC icon on any tab (badge turns green)'));
186
+ }, timeoutMs);
187
+ const onSession = (sid) => {
188
+ cleanup();
189
+ resolve(sid);
190
+ };
191
+ const onExtension = async () => {
192
+ if (this.sessionId) {
193
+ cleanup();
194
+ resolve(this.sessionId);
195
+ return;
196
+ }
197
+ // Ask extension to attach active tab (triggers Target.attachedToTarget event)
198
+ try {
199
+ const id = this.nextId++;
200
+ this.extensionWs.send(JSON.stringify({ id, method: 'attachActiveTab' }));
201
+ }
202
+ catch { }
203
+ };
204
+ this.once('sessionReady', onSession);
205
+ this.once('extensionConnected', onExtension);
206
+ });
207
+ }
208
+ /** Send a CDP command and await the response */
209
+ async cdp(method, params = {}, sessionId) {
210
+ const sid = sessionId ?? this.sessionId;
211
+ if (!this.extensionWs || this.extensionWs.readyState !== WebSocket.OPEN) {
212
+ throw new Error('Chrome extension not connected');
213
+ }
214
+ const id = this.nextId++;
215
+ return new Promise((resolve, reject) => {
216
+ const timer = setTimeout(() => {
217
+ this.pending.delete(id);
218
+ reject(new Error(`CDP command "${method}" timed out after 30s`));
219
+ }, 30_000);
220
+ this.pending.set(id, {
221
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
222
+ reject: (e) => { clearTimeout(timer); reject(e); }
223
+ });
224
+ this.extensionWs.send(JSON.stringify({
225
+ id,
226
+ method: 'forwardCDPCommand',
227
+ params: { method, params, sessionId: sid }
228
+ }));
229
+ });
230
+ }
231
+ /** CORS-bypassing authenticated fetch via the extension */
232
+ async fetch(url, options = {}) {
233
+ if (!this.extensionWs || this.extensionWs.readyState !== WebSocket.OPEN) {
234
+ throw new Error('Chrome extension not connected');
235
+ }
236
+ const id = this.nextId++;
237
+ return new Promise((resolve, reject) => {
238
+ const timer = setTimeout(() => {
239
+ this.pending.delete(id);
240
+ reject(new Error(`corsFetch timed out for ${url}`));
241
+ }, 30_000);
242
+ this.pending.set(id, {
243
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
244
+ reject: (e) => { clearTimeout(timer); reject(e); }
245
+ });
246
+ this.extensionWs.send(JSON.stringify({ id, method: 'corsFetch', params: { url, options } }));
247
+ });
248
+ }
249
+ get activeSession() { return this.sessionId; }
250
+ get allSessions() { return new Map(this.sessions); }
251
+ isConnected() {
252
+ return this.extensionWs?.readyState === WebSocket.OPEN;
253
+ }
254
+ close() {
255
+ if (this.pingInterval)
256
+ clearInterval(this.pingInterval);
257
+ this.wss.close();
258
+ }
259
+ }
@@ -0,0 +1,20 @@
1
+ # NPC Chrome Extension
2
+
3
+ ## Install
4
+
5
+ 1. Open `chrome://extensions` (or `brave://extensions`)
6
+ 2. Enable Developer mode (top right toggle)
7
+ 3. Click Load unpacked
8
+ 4. Select this folder (`extension/`)
9
+ 5. Click the NPC icon on any tab - green badge = connected
10
+
11
+ ## How it works
12
+
13
+ The extension connects to the NPC relay server via WebSocket (localhost:7221).
14
+ It uses chrome.debugger (CDP) to control browser tabs on behalf of your IDE.
15
+
16
+ ## Troubleshooting
17
+
18
+ - Red `!` badge: relay server not running. Start NPC from your IDE or run `npc`
19
+ - Gray icon: click the NPC icon on the tab you want to control
20
+ - Green `1` badge: connected and ready