hoverify-mcp 0.0.0-stage → 0.1.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.
package/dist/bridge.js ADDED
@@ -0,0 +1,602 @@
1
+ import { WebSocketServer, WebSocket } from "ws";
2
+ import { readFileSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { PROTOCOL, PING_INTERVAL, NOT_CONNECTED, APPROVAL_NEEDED, PEER_REJECTED, PEER_VERSION, EXTENSION_VERSION, BRIDGE_RESET, BROWSER_MOVED, BROWSER_CLAIMED, } from "./protocol.js";
5
+ import * as Browsers from "./browsers.js";
6
+ import { keyId, loadKey, nonce, prove, proves } from "./key.js";
7
+ const EXTENSION_WAIT = 5000;
8
+ const TAKE_OVER = "take over";
9
+ const HANDED_OVER = "handed over";
10
+ // long enough for the newer session to bind the port before the older ones look for a hub again
11
+ const HANDOVER_WAIT = 2000;
12
+ // an extension that's switched on retries every 2 s, or every 30 s after its worker restarts
13
+ const REJECTION_MEMORY = 45_000;
14
+ const REJECTED_RETRY = 2000;
15
+ const IDLE_CHECK = 30_000;
16
+ const EXTENSION_ORIGIN = /^(chrome|moz)-extension:\/\//;
17
+ const ID = /^[\w-]{8,64}$/;
18
+ const validId = (value) => ID.test(String(value)) ? String(value) : "default";
19
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
20
+ const recency = (browser) => browser.focused ? Infinity : browser.focused_at;
21
+ const MAX_CLAIMS = 50;
22
+ const text = (value, limit) => typeof value === "string" ? value.slice(0, limit) : "";
23
+ const projectFrom = (value) => value && typeof value === "object" ? Browsers.projectOf(value.path) : null;
24
+ const display = (agent, project) => project ? `${agent} in ${project.name}` : agent;
25
+ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
26
+ // every agent session runs its own process, but each browser connects once: the first to bind the port relays for the rest
27
+ export class Bridge {
28
+ options;
29
+ role = null;
30
+ server = null;
31
+ browsers = new Map();
32
+ approvals = new Set();
33
+ hub = null;
34
+ peers = new Map();
35
+ pending = new Map();
36
+ routes = new Map();
37
+ // kept by the session, not the hub, so they survive a hub takeover
38
+ pinned = "";
39
+ asked = false;
40
+ session = randomUUID();
41
+ project = Browsers.projectOf(process.cwd());
42
+ seq = 0;
43
+ closed = false;
44
+ dormant = false;
45
+ lastActive = Date.now();
46
+ idleTimer = null;
47
+ extensionOutdatedAt = 0;
48
+ peerProblem = "";
49
+ keyProblem = "";
50
+ constructor(options) {
51
+ this.options = options;
52
+ }
53
+ // election can wait indefinitely on a hub that refuses this session, so the MCP side starts without it
54
+ start() {
55
+ try {
56
+ loadKey();
57
+ }
58
+ catch (error) {
59
+ // without a key this session could only block the port for the sessions that have one
60
+ this.keyProblem = error.message;
61
+ return this.options.log(this.keyProblem);
62
+ }
63
+ this.idleTimer = setInterval(() => this.releaseIfIdle(), Math.min(IDLE_CHECK, this.options.releaseAfter / 4));
64
+ this.idleTimer.unref();
65
+ this.join();
66
+ }
67
+ close() {
68
+ this.closed = true;
69
+ if (this.idleTimer)
70
+ clearInterval(this.idleTimer);
71
+ this.hub?.close();
72
+ this.server?.close();
73
+ for (const client of this.server?.clients || [])
74
+ client.terminate();
75
+ }
76
+ get currentRole() {
77
+ return this.role;
78
+ }
79
+ agentChanged() {
80
+ if (this.role === "hub")
81
+ this.pushAgents();
82
+ else if (this.hub?.readyState === WebSocket.OPEN)
83
+ this.hub.send(JSON.stringify({ type: "agent", ...this.me() }));
84
+ }
85
+ setProject(path) {
86
+ this.project = Browsers.projectOf(path);
87
+ this.agentChanged();
88
+ }
89
+ me() {
90
+ return { agent: this.options.agent(), session: this.session, project: this.project, configured: this.options.configured, pinned: this.pinned, asked: this.asked };
91
+ }
92
+ async call(tool, args, timeoutMs) {
93
+ if (this.keyProblem)
94
+ throw new Error(this.keyProblem);
95
+ this.lastActive = Date.now();
96
+ if (this.dormant) {
97
+ this.dormant = false;
98
+ this.join();
99
+ }
100
+ for (let i = 0; i < 100 && !this.role && !this.peerProblem; i++)
101
+ await sleep(20);
102
+ if (!this.role)
103
+ throw new Error(this.peerProblem || NOT_CONNECTED);
104
+ const id = `${this.options.name}:${++this.seq}`;
105
+ const { browser, ...rest } = args;
106
+ const requested = typeof browser === "string" ? browser.trim() : "";
107
+ // held by id, since its tab ids mean nothing in another browser, even one that takes its name
108
+ const named = requested || (this.asked ? this.pinned : "") || this.options.configured;
109
+ const message = {
110
+ type: "call",
111
+ id,
112
+ tool,
113
+ args: rest,
114
+ ...(this.pinned ? { pinned: this.pinned } : {}),
115
+ ...(named ? { named, named_by: requested || this.asked ? "argument" : "config" } : {}),
116
+ };
117
+ const result = new Promise((resolve, reject) => {
118
+ const timer = setTimeout(() => {
119
+ this.pending.delete(id);
120
+ reject(new Error(`Hoverify didn't answer within ${Math.round(timeoutMs / 1000)} seconds`));
121
+ }, timeoutMs);
122
+ this.pending.set(id, { resolve, reject, timer, asked: !!requested, pinned: this.pinned });
123
+ });
124
+ try {
125
+ if (this.role === "hub")
126
+ await this.forward(message, null, this.me());
127
+ else
128
+ this.hub.send(JSON.stringify(message));
129
+ }
130
+ catch (error) {
131
+ this.settle({ type: "result", id, ok: false, error: error.message });
132
+ }
133
+ return result;
134
+ }
135
+ settle(message) {
136
+ const pending = this.pending.get(message.id);
137
+ if (!pending)
138
+ return;
139
+ this.pending.delete(message.id);
140
+ this.lastActive = Date.now();
141
+ clearTimeout(pending.timer);
142
+ const claimed = message.picked === "claim" && !!this.pinned && message.browser !== this.pinned;
143
+ const asked = this.asked || pending.asked;
144
+ // a call sent before the pin moved finishes late with parallel tool calls, and mustn't move it back
145
+ const fresh = pending.asked || pending.pinned === this.pinned;
146
+ if (message.browser && fresh && (message.browser !== this.pinned || asked !== this.asked)) {
147
+ this.pinned = message.browser;
148
+ this.asked = asked;
149
+ this.agentChanged();
150
+ }
151
+ const notes = message.picked === "moved" ? [BROWSER_MOVED] : claimed ? [BROWSER_CLAIMED] : [];
152
+ if (message.ok && message.output) {
153
+ if (message.browsers)
154
+ notes.push(Browsers.status(message.picked, this.options.configured, this.project, message.browsers));
155
+ const { output } = message;
156
+ pending.resolve({ ...output, content: [...output.content, ...notes.map(text => ({ type: "text", text }))] });
157
+ }
158
+ else {
159
+ const error = message.error || "Hoverify couldn't finish the request";
160
+ pending.reject(new Error([error, ...notes].join("\n\n")));
161
+ }
162
+ }
163
+ failAll() {
164
+ for (const id of [...this.pending.keys()])
165
+ this.settle({ type: "result", id, ok: false, error: BRIDGE_RESET });
166
+ }
167
+ everyone() {
168
+ return [this.me(), ...this.peers.values()];
169
+ }
170
+ // names only: a browser still asking to allow the agent hasn't proved anything yet
171
+ agents() {
172
+ return [...new Set(this.everyone().filter(peer => peer.agent).map(peer => display(peer.agent, peer.project)))];
173
+ }
174
+ // the hub owns name matching, so the popups get the ids a session's binding resolves to
175
+ sessions() {
176
+ const open = [...this.browsers.values()];
177
+ return this.everyone().filter(peer => peer.agent).map(peer => {
178
+ const label = this.browsers.get(peer.pinned)?.label || "";
179
+ const bound = peer.asked
180
+ ? { by: "argument", name: label, ids: [peer.pinned] }
181
+ : peer.configured ? { by: "config", name: peer.configured, ids: Browsers.matching(open, peer.configured).map(browser => browser.id) } : null;
182
+ return { id: peer.session, agent: peer.agent, project: peer.project, bound, browser: peer.pinned, browser_label: label };
183
+ });
184
+ }
185
+ pushAgents() {
186
+ const agents = this.agents();
187
+ const toBrowsers = JSON.stringify({ type: "agents", agents, sessions: this.sessions() });
188
+ const toApprovals = JSON.stringify({ type: "agents", agents });
189
+ for (const browser of this.browsers.values())
190
+ if (browser.socket.readyState === WebSocket.OPEN)
191
+ browser.socket.send(toBrowsers);
192
+ for (const socket of this.approvals)
193
+ if (socket.readyState === WebSocket.OPEN)
194
+ socket.send(toApprovals);
195
+ }
196
+ // ties go to the browser connected longest, since the map keeps connection order
197
+ latest(browsers) {
198
+ let best;
199
+ for (const browser of browsers)
200
+ if (!best || recency(browser) > recency(best))
201
+ best = browser;
202
+ return best;
203
+ }
204
+ accepts(browser, project) {
205
+ return !browser.only || (!!project && browser.claims.includes(project.path));
206
+ }
207
+ list(current = "") {
208
+ return [...this.browsers.values()]
209
+ .sort((a, b) => recency(b) - recency(a))
210
+ .map(browser => ({ id: browser.id, label: browser.label, current: browser.id === current }));
211
+ }
212
+ lastUsed(project, picked) {
213
+ const browser = this.latest([...this.browsers.values()].filter(other => this.accepts(other, project)));
214
+ if (!browser)
215
+ throw new Error(Browsers.allRefused(project, this.list()));
216
+ return { browser, picked };
217
+ }
218
+ pick(message, project) {
219
+ const open = [...this.browsers.values()];
220
+ if (message.named) {
221
+ const matches = Browsers.matching(open, message.named);
222
+ if (matches.length > 1)
223
+ throw new Error(Browsers.ambiguous(message.named, matches));
224
+ if (!matches.length)
225
+ return null;
226
+ if (!this.accepts(matches[0], project))
227
+ throw new Error(Browsers.refused(matches[0].label, project));
228
+ return { browser: matches[0], picked: message.named_by === "config" ? "config" : "argument" };
229
+ }
230
+ const claimed = project ? this.latest(open.filter(browser => browser.claims.includes(project.path))) : undefined;
231
+ if (claimed)
232
+ return { browser: claimed, picked: "claim" };
233
+ if (message.pinned) {
234
+ const pinned = this.browsers.get(message.pinned);
235
+ if (!pinned)
236
+ return null;
237
+ return this.accepts(pinned, project) ? { browser: pinned, picked: "pinned" } : this.lastUsed(project, "moved");
238
+ }
239
+ return open.length ? this.lastUsed(project, "last_used") : null;
240
+ }
241
+ // a named browser is never swapped for another, which has other logins; a session's own browser gets the whole wait before it moves
242
+ async target(message, project) {
243
+ const until = Date.now() + EXTENSION_WAIT;
244
+ for (;;) {
245
+ const found = this.pick(message, project);
246
+ if (found)
247
+ return found;
248
+ if (!this.browsers.size && Date.now() - this.extensionOutdatedAt < REJECTION_MEMORY)
249
+ throw new Error(EXTENSION_VERSION);
250
+ if (Date.now() >= until)
251
+ break;
252
+ await sleep(50);
253
+ }
254
+ if (!this.browsers.size)
255
+ throw new Error(this.approvals.size ? APPROVAL_NEEDED : NOT_CONNECTED);
256
+ if (message.named) {
257
+ // a held browser is known only by an id the user never sees
258
+ const held = message.named_by === "argument" && message.named === message.pinned;
259
+ throw new Error(Browsers.missing(held ? `The browser this session was asked to use (id ${Browsers.shortId(message.named)})` : message.named, this.list()));
260
+ }
261
+ return this.lastUsed(project, "moved");
262
+ }
263
+ // the session comes from the socket the call arrived on, never from the call
264
+ async forward(message, peer, caller) {
265
+ const { pinned, named, named_by, ...call } = message;
266
+ call.session = caller.session;
267
+ const { browser: target, picked } = await this.target(message, caller.project);
268
+ this.routes.set(message.id, { peer, socket: target.socket, picked, status: message.tool === "get_status" });
269
+ target.socket.send(JSON.stringify(call));
270
+ }
271
+ join() {
272
+ this.elect().catch(error => this.options.log(`can't listen on port ${this.options.port}: ${error.message}`));
273
+ }
274
+ releaseIfIdle() {
275
+ if (!this.role || this.pending.size || Date.now() - this.lastActive < this.options.releaseAfter)
276
+ return;
277
+ if (this.role === "hub") {
278
+ if (this.server?.clients.size)
279
+ return;
280
+ this.server?.close();
281
+ this.server = null;
282
+ }
283
+ else {
284
+ // an idle peer would otherwise take the port back the moment the hub lets go
285
+ const hub = this.hub;
286
+ this.hub = null;
287
+ hub?.close();
288
+ }
289
+ this.options.log(this.role === "hub" ? "idle, released the port" : "idle, left the hub");
290
+ this.role = null;
291
+ this.dormant = true;
292
+ }
293
+ async elect() {
294
+ // a random delay spreads the survivors so one wins the port cleanly
295
+ await sleep(Math.random() * 150);
296
+ while (!this.closed) {
297
+ if (await this.becomeHub())
298
+ return;
299
+ if (await this.becomePeer())
300
+ return;
301
+ await sleep(this.peerProblem ? REJECTED_RETRY : 100);
302
+ }
303
+ }
304
+ becomeHub() {
305
+ return new Promise((resolve, reject) => {
306
+ const server = new WebSocketServer({ host: "127.0.0.1", port: this.options.port, maxPayload: 64 * 1024 * 1024 });
307
+ server.once("listening", () => {
308
+ this.server = server;
309
+ this.role = "hub";
310
+ this.peerProblem = "";
311
+ this.lastActive = Date.now();
312
+ this.options.log("listening for Hoverify");
313
+ resolve(true);
314
+ });
315
+ server.once("error", (error) => {
316
+ if (error.code === "EADDRINUSE")
317
+ resolve(false);
318
+ else
319
+ reject(error);
320
+ });
321
+ server.on("connection", (socket, request) => this.onConnection(socket, request));
322
+ });
323
+ }
324
+ becomePeer() {
325
+ return new Promise(resolve => {
326
+ let key;
327
+ try {
328
+ key = loadKey();
329
+ }
330
+ catch {
331
+ return resolve(false);
332
+ }
333
+ const mine = nonce();
334
+ let proven = false;
335
+ const socket = new WebSocket(`ws://127.0.0.1:${this.options.port}`, { maxPayload: 64 * 1024 * 1024 });
336
+ // a hub that neither welcomes nor refuses is treated as gone
337
+ const unanswered = setTimeout(() => socket.terminate(), 2000);
338
+ // the session's project folder waits for the hub's proof, so whatever squats the port never learns it
339
+ socket.once("open", () => socket.send(JSON.stringify({ type: "hello", role: "peer", protocol: PROTOCOL, nonce: mine })));
340
+ socket.once("error", () => resolve(false));
341
+ socket.on("message", raw => {
342
+ let message;
343
+ try {
344
+ message = JSON.parse(String(raw));
345
+ }
346
+ catch {
347
+ return;
348
+ }
349
+ if (message.type === "challenge") {
350
+ // whatever holds the port without this key gets no calls from this session
351
+ if (!proves(message.proof, key, "hub", mine, message.nonce)) {
352
+ this.peerProblem = PEER_REJECTED;
353
+ return socket.close();
354
+ }
355
+ proven = true;
356
+ return socket.send(JSON.stringify({ type: "auth", proof: prove(key, "client", message.nonce, mine) }));
357
+ }
358
+ if (message.type === "welcome") {
359
+ if (message.error)
360
+ return;
361
+ if (!proven) {
362
+ this.peerProblem = PEER_REJECTED;
363
+ return socket.close();
364
+ }
365
+ clearTimeout(unanswered);
366
+ this.hub = socket;
367
+ this.role = "peer";
368
+ this.peerProblem = "";
369
+ this.agentChanged();
370
+ return resolve(true);
371
+ }
372
+ if (this.hub === socket && message.type === "result")
373
+ this.settle(message);
374
+ });
375
+ socket.on("close", (code, reason) => {
376
+ clearTimeout(unanswered);
377
+ if (this.hub !== socket) {
378
+ if (code === 1008 && String(reason) === "wrong key")
379
+ this.peerProblem = PEER_REJECTED;
380
+ if (code === 1008 && String(reason).startsWith("protocol"))
381
+ this.peerProblem = PEER_VERSION;
382
+ if (code === 4000 && String(reason) === TAKE_OVER)
383
+ this.peerProblem = "";
384
+ return resolve(false);
385
+ }
386
+ this.hub = null;
387
+ this.role = null;
388
+ this.failAll();
389
+ if (this.closed)
390
+ return;
391
+ if (code !== 1008 || String(reason) !== HANDED_OVER)
392
+ return this.join();
393
+ this.peerProblem = PEER_VERSION;
394
+ setTimeout(() => this.join(), HANDOVER_WAIT);
395
+ });
396
+ });
397
+ }
398
+ onConnection(socket, request) {
399
+ // unhandled, an error such as a message over maxPayload would end the process; ws closes the socket itself
400
+ socket.on("error", error => this.options.log(`dropped a connection: ${error.message}`));
401
+ const origin = request.headers.origin || "";
402
+ if (/^https?:/i.test(origin))
403
+ return socket.close(1008, "web pages can't connect");
404
+ let hello = null;
405
+ let key = "";
406
+ let mine = "";
407
+ socket.on("message", raw => {
408
+ let message;
409
+ try {
410
+ message = JSON.parse(String(raw));
411
+ }
412
+ catch {
413
+ return socket.close(1008, "bad message");
414
+ }
415
+ if (!hello) {
416
+ const extension = message.role === "ext" && EXTENSION_ORIGIN.test(origin);
417
+ const peer = message.role === "peer" && !origin;
418
+ if (message.type !== "hello" || typeof message.nonce !== "string" || (!extension && !peer))
419
+ return socket.close(1008, "unknown client");
420
+ // a newer session goes on to prove the key, and may then take the port (handOver)
421
+ const newer = peer && typeof message.protocol === "number" && message.protocol > PROTOCOL;
422
+ if (message.protocol !== PROTOCOL && !newer) {
423
+ if (extension)
424
+ this.extensionOutdatedAt = Date.now();
425
+ return this.refuseVersion(socket, message.protocol);
426
+ }
427
+ try {
428
+ key = loadKey();
429
+ }
430
+ catch {
431
+ return socket.close(1011, "no key");
432
+ }
433
+ if (extension)
434
+ this.extensionOutdatedAt = 0;
435
+ hello = message;
436
+ mine = nonce();
437
+ if (extension)
438
+ this.keepAlive(socket);
439
+ return socket.send(JSON.stringify({ type: "challenge", nonce: mine, key_id: keyId(key), proof: prove(key, "hub", message.nonce, mine), agents: this.agents() }));
440
+ }
441
+ if (message.type === "auth") {
442
+ if (!proves(message.proof, key, "client", mine, hello.nonce))
443
+ return socket.close(1008, "wrong key");
444
+ socket.removeAllListeners("message");
445
+ if (hello.role === "ext")
446
+ return this.acceptExtension(socket, hello);
447
+ return hello.protocol === PROTOCOL ? this.acceptPeer(socket) : this.handOver(socket, hello.protocol);
448
+ }
449
+ if (hello.role !== "ext")
450
+ return;
451
+ // approval means Hoverify is asking its user; it sends pair only once they allow
452
+ if (message.type === "approval")
453
+ this.approvals.add(socket);
454
+ if (message.type === "pair")
455
+ socket.send(JSON.stringify({ type: "key", key }));
456
+ });
457
+ socket.on("close", () => {
458
+ this.approvals.delete(socket);
459
+ if (hello)
460
+ this.lastActive = Date.now();
461
+ });
462
+ }
463
+ refuseVersion(socket, protocol) {
464
+ socket.send(JSON.stringify({ type: "welcome", protocol: PROTOCOL, error: "version" }));
465
+ socket.close(1008, `protocol ${protocol} is not ${PROTOCOL}`);
466
+ }
467
+ // after an update the port goes to the newer version, unless a browser on this one still uses it: Hoverify may not have updated yet
468
+ handOver(socket, protocol) {
469
+ if (this.browsers.size || this.approvals.size)
470
+ return this.refuseVersion(socket, protocol);
471
+ this.options.log(`a session on protocol ${protocol} takes the port`);
472
+ this.server?.close();
473
+ this.server = null;
474
+ this.role = null;
475
+ this.peerProblem = PEER_VERSION;
476
+ this.failAll();
477
+ for (const peer of this.peers.keys())
478
+ peer.close(1008, HANDED_OVER);
479
+ this.peers.clear();
480
+ socket.close(4000, TAKE_OVER);
481
+ setTimeout(() => this.join(), HANDOVER_WAIT);
482
+ }
483
+ keepAlive(socket) {
484
+ const ping = setInterval(() => socket.readyState === WebSocket.OPEN && socket.send(JSON.stringify({ type: "ping" })), PING_INTERVAL);
485
+ socket.on("close", () => clearInterval(ping));
486
+ }
487
+ acceptExtension(socket, hello) {
488
+ const id = validId(hello.browser_id);
489
+ const earlier = this.browsers.get(id);
490
+ // only this browser's own earlier socket makes way; re-inserting keeps the map in connection order
491
+ this.browsers.delete(id);
492
+ if (earlier && earlier.socket !== socket)
493
+ earlier.socket.close(1000, "replaced");
494
+ const entry = { id, socket, label: "", focused: false, focused_at: 0, claims: [], only: false };
495
+ const update = (state) => {
496
+ const label = Browsers.labelOf(state.label);
497
+ const renamed = entry.label !== label;
498
+ entry.label = label;
499
+ entry.focused = state.focused === true;
500
+ entry.focused_at = Number(state.focused_at) || 0;
501
+ entry.claims = Array.isArray(state.claims) ? state.claims.filter(path => typeof path === "string").slice(0, MAX_CLAIMS) : [];
502
+ entry.only = state.only === true;
503
+ if (renamed && this.browsers.get(id) === entry)
504
+ this.pushAgents();
505
+ };
506
+ update(hello);
507
+ this.browsers.set(id, entry);
508
+ const name = () => `${entry.label} (${Browsers.shortId(id)})`;
509
+ this.approvals.delete(socket);
510
+ socket.send(JSON.stringify({ type: "welcome", protocol: PROTOCOL }));
511
+ this.options.log(`${name()} connected (${hello.browser}, Hoverify ${hello.extension_version})`);
512
+ // the welcome carries no lists: this push gives the new browser its own and updates the others that name it
513
+ this.pushAgents();
514
+ socket.on("message", raw => {
515
+ let message;
516
+ try {
517
+ message = JSON.parse(String(raw));
518
+ }
519
+ catch {
520
+ return;
521
+ }
522
+ if (message.type === "browser")
523
+ return update(message);
524
+ if (message.type !== "result")
525
+ return;
526
+ const route = this.routes.get(message.id);
527
+ this.routes.delete(message.id);
528
+ message.browser = id;
529
+ if (route) {
530
+ message.picked = route.picked;
531
+ if (route.status)
532
+ message.browsers = this.list(id);
533
+ }
534
+ if (route?.peer) {
535
+ if (route.peer.readyState === WebSocket.OPEN)
536
+ route.peer.send(JSON.stringify(message));
537
+ }
538
+ else
539
+ this.settle(message);
540
+ });
541
+ socket.on("close", () => {
542
+ for (const [callId, route] of this.routes) {
543
+ if (route.socket !== socket)
544
+ continue;
545
+ this.routes.delete(callId);
546
+ const reset = { type: "result", id: callId, ok: false, error: BRIDGE_RESET };
547
+ if (!route.peer)
548
+ this.settle(reset);
549
+ else if (route.peer.readyState === WebSocket.OPEN)
550
+ route.peer.send(JSON.stringify(reset));
551
+ }
552
+ if (this.browsers.get(id)?.socket !== socket)
553
+ return;
554
+ this.browsers.delete(id);
555
+ this.options.log(`${name()} disconnected`);
556
+ this.pushAgents();
557
+ });
558
+ }
559
+ acceptPeer(socket) {
560
+ let caller = null;
561
+ socket.send(JSON.stringify({ type: "welcome", protocol: PROTOCOL }));
562
+ socket.on("message", raw => {
563
+ let message;
564
+ try {
565
+ message = JSON.parse(String(raw));
566
+ }
567
+ catch {
568
+ return;
569
+ }
570
+ if (message.type === "call") {
571
+ // until the peer reports, the hub knows neither its session nor its project, so nothing can route the call
572
+ if (!caller)
573
+ return socket.close(1008, "call before agent");
574
+ this.relay(socket, message, caller);
575
+ }
576
+ if (message.type === "agent") {
577
+ caller = {
578
+ agent: text(message.agent, 100),
579
+ // named once, so a later report can't take over another session's tabs
580
+ session: caller?.session ?? validId(message.session),
581
+ project: projectFrom(message.project),
582
+ configured: text(message.configured, 100),
583
+ pinned: text(message.pinned, 64),
584
+ asked: message.asked === true,
585
+ };
586
+ this.peers.set(socket, caller);
587
+ this.pushAgents();
588
+ }
589
+ });
590
+ socket.on("close", () => {
591
+ if (this.peers.delete(socket))
592
+ this.pushAgents();
593
+ });
594
+ }
595
+ relay(peer, message, caller) {
596
+ this.forward(message, peer, caller).catch(error => {
597
+ this.routes.delete(message.id);
598
+ if (peer.readyState === WebSocket.OPEN)
599
+ peer.send(JSON.stringify({ type: "result", id: message.id, ok: false, error: error.message }));
600
+ });
601
+ }
602
+ }
@@ -0,0 +1,58 @@
1
+ import { basename } from "node:path";
2
+ import { homedir } from "node:os";
3
+ const LABEL_LIMIT = 40;
4
+ export const shortId = (id) => id.slice(0, 8);
5
+ // a server started in / or the home folder (Claude Desktop does) has no project to speak of
6
+ export function projectOf(path) {
7
+ if (typeof path !== "string" || !path || path.length > 1024 || path === "/" || path === homedir())
8
+ return null;
9
+ return { path, name: basename(path) || path };
10
+ }
11
+ const named = (browser) => `${browser.label} (id ${shortId(browser.id)})`;
12
+ export function labelOf(label) {
13
+ return (typeof label === "string" && label.trim().slice(0, LABEL_LIMIT)) || "Browser";
14
+ }
15
+ // a name can look like the start of an id ("cafe"), so names come before id prefixes
16
+ export function matching(browsers, wanted) {
17
+ const want = wanted.toLowerCase();
18
+ const exact = browsers.filter(browser => browser.id.toLowerCase() === want);
19
+ if (exact.length)
20
+ return exact;
21
+ const byName = browsers.filter(browser => browser.label.toLowerCase() === want);
22
+ if (byName.length || want.length < 4)
23
+ return byName;
24
+ return browsers.filter(browser => browser.id.toLowerCase().startsWith(want));
25
+ }
26
+ export function missing(wanted, connected) {
27
+ const now = connected.length ? ` Connected now: ${connected.map(named).join(", ")}.` : "";
28
+ return `${wanted} isn't connected. Switch on Connect to agent in Hoverify in that browser, or pass browser to use another one.${now}`;
29
+ }
30
+ export function ambiguous(wanted, matches) {
31
+ return `More than one connected browser is named ${wanted}: ${matches.map(named).join(", ")}. Pass one of the ids as browser, or give the browsers different names on Hoverify's AI agent page.`;
32
+ }
33
+ export function refused(label, project) {
34
+ return `${label} only works for the projects the user keeps in it, and ${project ? project.name : "this session's folder"} isn't one. The user can keep it there, or switch off "Only kept projects", on Hoverify's AI agent page in ${label}.`;
35
+ }
36
+ export function allRefused(project, connected) {
37
+ return `Every connected browser (${connected.map(named).join(", ")}) only works for the projects the user keeps in it, and ${project ? project.name : "this session's folder"} isn't one. The user can keep it in one of them on Hoverify's AI agent page.`;
38
+ }
39
+ export function status(picked, configured, project, browsers) {
40
+ const current = browsers.find(browser => browser.current);
41
+ const why = {
42
+ argument: "you asked for it with browser",
43
+ config: `this project's MCP config sets --browser ${configured}`,
44
+ claim: `the user keeps ${project?.name || "this project"} in it`,
45
+ pinned: "this session has been working in it",
46
+ last_used: "it's the browser the user used last",
47
+ moved: "the browser this session was using isn't connected anymore",
48
+ };
49
+ const lines = [`Browser: ${named(current)}, because ${why[picked]}.`];
50
+ if (project)
51
+ lines.push(`Project: ${project.name} (${project.path})`);
52
+ if (browsers.length > 1) {
53
+ lines.push(`Hoverify is on in ${browsers.length} browsers, most recently used first:`);
54
+ lines.push(...browsers.map(browser => `- ${named(browser)}${browser.current ? ", this session's" : ""}`));
55
+ lines.push("To use another one, pass its name as browser to any tool, or its id when two share a name. The session stays there afterwards.");
56
+ }
57
+ return lines.join("\n");
58
+ }