@attson/atwebpilot-mcp 0.0.56 → 0.0.57
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/README.md +23 -6
- package/dist/index.js +538 -263
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,14 +6,22 @@
|
|
|
6
6
|
|
|
7
7
|
claude mcp add atwebpilot --scope user -- npx -y @attson/atwebpilot-mcp
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
然后[下载 release zip](https://github.com/attson/atwebpilot/releases/latest) 加载已解压扩展。
|
|
10
|
+
|
|
11
|
+
**不需要手填端口。** 从 Plan 33 起是配对流程:
|
|
12
|
+
|
|
13
|
+
1. 会话启动时**不绑任何端口** —— 不碰网页的会话零副作用。
|
|
14
|
+
2. AI 第一次调 `list_tabs` / `browser_*` 时才绑一个空闲端口,并自动打开配对页。
|
|
15
|
+
3. 你在浏览器里点「允许」,扩展即接入,重试那次调用即可。
|
|
16
|
+
4. 之后本机的会话都免确认(信任是安装级的);端口能复用时连配对页都不会弹。
|
|
17
|
+
|
|
18
|
+
多个 Claude Code 会话可以同时接入同一个浏览器,各自一条连接。可在扩展设置 →
|
|
19
|
+
Coordinator 查看已接入的会话、单独断开,或撤销信任。
|
|
10
20
|
|
|
11
|
-
|
|
12
|
-
- `ATWEBPILOT_WS_TOKEN`(可选):扩展连接时要求 `bearer.<token>` 子协议
|
|
21
|
+
可选环境变量:
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
`list_tabs` 即可看到当前标签页。
|
|
23
|
+
- `ATWEBPILOT_WS_PORT`:固定端口(默认是自动选一个空闲端口并复用上次的)
|
|
24
|
+
- `ATWEBPILOT_WS_TOKEN`:要求扩展带 `bearer.<token>` 子协议
|
|
17
25
|
|
|
18
26
|
## 给开发者:本地 monorepo
|
|
19
27
|
|
|
@@ -21,6 +29,15 @@
|
|
|
21
29
|
|
|
22
30
|
环境变量同上,路径用 `tsx src/index.ts` 直跑(包内 `start` script 已封)。
|
|
23
31
|
|
|
32
|
+
## 多会话
|
|
33
|
+
|
|
34
|
+
每个 MCP server 进程是 WS **服务端**,扩展是**客户端** —— MV3 service worker 没有
|
|
35
|
+
listen 端口的能力,所以方向只能如此。这也是配对页存在的原因:服务端没法主动找到浏览器,
|
|
36
|
+
只能由它把自己的端口**告知**扩展。
|
|
37
|
+
|
|
38
|
+
扩展侧维护一个连接池,每个已配对会话一条连接。`list_tabs` 会告诉你哪些 tab 正被**别的**
|
|
39
|
+
会话占用(`busy` / `busy_label`)——只是提示,不拦截;AI 看到占用通常直接 `openTab` 开一个新的就行。
|
|
40
|
+
|
|
24
41
|
## 工具面
|
|
25
42
|
|
|
26
43
|
- 控制面 4 个:`list_tabs / open_session / close_session / get_quota`
|
package/dist/index.js
CHANGED
|
@@ -217,6 +217,14 @@ var StepSchema = z4.object({
|
|
|
217
217
|
tool: z4.string(),
|
|
218
218
|
args: z4.unknown()
|
|
219
219
|
});
|
|
220
|
+
var DerivedTabFields = {
|
|
221
|
+
/** true when the receiving connection owns this tab */
|
|
222
|
+
mine: z4.boolean().optional(),
|
|
223
|
+
/** true when some *other* connection owns it */
|
|
224
|
+
busy: z4.boolean().optional(),
|
|
225
|
+
/** the owning session's label, present only when busy */
|
|
226
|
+
busy_label: z4.string().optional()
|
|
227
|
+
};
|
|
220
228
|
var HelloSchema = z4.object({
|
|
221
229
|
...EnvelopeFields,
|
|
222
230
|
type: z4.literal("HELLO"),
|
|
@@ -239,7 +247,8 @@ var HelloSchema = z4.object({
|
|
|
239
247
|
z4.object({
|
|
240
248
|
tab_id: z4.string(),
|
|
241
249
|
url: z4.string(),
|
|
242
|
-
title: z4.string().optional()
|
|
250
|
+
title: z4.string().optional(),
|
|
251
|
+
...DerivedTabFields
|
|
243
252
|
})
|
|
244
253
|
),
|
|
245
254
|
saved_tools: z4.array(
|
|
@@ -390,6 +399,24 @@ var SidepanelStateReplySchema = z4.object({
|
|
|
390
399
|
lastSystemNote: z4.string().optional()
|
|
391
400
|
}).optional()
|
|
392
401
|
});
|
|
402
|
+
var SessionOpenedSchema = z4.object({
|
|
403
|
+
...EnvelopeFields,
|
|
404
|
+
type: z4.literal("SESSION_OPENED"),
|
|
405
|
+
session_id: z4.string(),
|
|
406
|
+
tab_id: z4.string()
|
|
407
|
+
});
|
|
408
|
+
var TabsUpdateSchema = z4.object({
|
|
409
|
+
...EnvelopeFields,
|
|
410
|
+
type: z4.literal("TABS_UPDATE"),
|
|
411
|
+
tabs: z4.array(
|
|
412
|
+
z4.object({
|
|
413
|
+
tab_id: z4.string(),
|
|
414
|
+
url: z4.string(),
|
|
415
|
+
title: z4.string().optional(),
|
|
416
|
+
...DerivedTabFields
|
|
417
|
+
})
|
|
418
|
+
)
|
|
419
|
+
});
|
|
393
420
|
var ClientToServerSchema = z4.discriminatedUnion("type", [
|
|
394
421
|
HelloSchema,
|
|
395
422
|
PingSchema,
|
|
@@ -399,7 +426,8 @@ var ClientToServerSchema = z4.discriminatedUnion("type", [
|
|
|
399
426
|
SessionEventSchema,
|
|
400
427
|
StateSnapshotSchema,
|
|
401
428
|
ChatEventSchema,
|
|
402
|
-
SidepanelStateReplySchema
|
|
429
|
+
SidepanelStateReplySchema,
|
|
430
|
+
TabsUpdateSchema
|
|
403
431
|
]);
|
|
404
432
|
var ServerToClientSchema = z4.discriminatedUnion("type", [
|
|
405
433
|
WelcomeSchema,
|
|
@@ -409,7 +437,8 @@ var ServerToClientSchema = z4.discriminatedUnion("type", [
|
|
|
409
437
|
CloseSessionSchema,
|
|
410
438
|
StartChatSessionSchema,
|
|
411
439
|
AbortSessionSchema,
|
|
412
|
-
ReadSidepanelStateSchema
|
|
440
|
+
ReadSidepanelStateSchema,
|
|
441
|
+
SessionOpenedSchema
|
|
413
442
|
]);
|
|
414
443
|
var ProtocolMessageSchema = z4.union([ClientToServerSchema, ServerToClientSchema]);
|
|
415
444
|
|
|
@@ -818,8 +847,12 @@ var Coordinator = class {
|
|
|
818
847
|
catalog;
|
|
819
848
|
dispatcher;
|
|
820
849
|
hub;
|
|
850
|
+
clock;
|
|
851
|
+
idGen;
|
|
821
852
|
constructor(deps) {
|
|
822
853
|
this.hub = deps.hub;
|
|
854
|
+
this.clock = deps.clock;
|
|
855
|
+
this.idGen = deps.idGen;
|
|
823
856
|
this.sessions = new SessionManager(deps.clock, deps.idGen);
|
|
824
857
|
this.workers = new WorkerRegistry(deps.clock);
|
|
825
858
|
this.catalog = new Catalog(this.workers);
|
|
@@ -841,7 +874,17 @@ var Coordinator = class {
|
|
|
841
874
|
}
|
|
842
875
|
// === Session lifecycle ===
|
|
843
876
|
openSession(input) {
|
|
844
|
-
|
|
877
|
+
const session = this.sessions.open(input);
|
|
878
|
+
void this.hub.send(session.worker_id, {
|
|
879
|
+
type: "SESSION_OPENED",
|
|
880
|
+
nonce: this.idGen.next("nonce"),
|
|
881
|
+
ts: this.clock.now(),
|
|
882
|
+
protocol_version: PROTOCOL_VERSION,
|
|
883
|
+
session_id: session.id,
|
|
884
|
+
tab_id: session.tab_id
|
|
885
|
+
}).catch(() => {
|
|
886
|
+
});
|
|
887
|
+
return session;
|
|
845
888
|
}
|
|
846
889
|
closeSession(id) {
|
|
847
890
|
this.sessions.close(id);
|
|
@@ -873,232 +916,6 @@ var Coordinator = class {
|
|
|
873
916
|
}
|
|
874
917
|
};
|
|
875
918
|
|
|
876
|
-
// src/loopback-ws-hub.ts
|
|
877
|
-
import { WebSocketServer, WebSocket } from "ws";
|
|
878
|
-
var HEARTBEAT_INTERVAL_MS = 2e4;
|
|
879
|
-
var DEFAULT_KEEPALIVE_INTERVAL_MS = 15e3;
|
|
880
|
-
var LoopbackWSHub = class {
|
|
881
|
-
constructor(opts) {
|
|
882
|
-
this.opts = opts;
|
|
883
|
-
this.execTimeoutMs = opts.execTimeoutMs ?? 3e4;
|
|
884
|
-
this.keepaliveIntervalMs = opts.keepaliveIntervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS;
|
|
885
|
-
this.wss = new WebSocketServer({
|
|
886
|
-
port: opts.port,
|
|
887
|
-
path: "/worker",
|
|
888
|
-
handleProtocols: (protocols) => [...protocols][0] ?? false
|
|
889
|
-
});
|
|
890
|
-
this.wss.on("connection", (socket, req) => this.onConnection(socket, req));
|
|
891
|
-
}
|
|
892
|
-
opts;
|
|
893
|
-
wss;
|
|
894
|
-
byWorker = /* @__PURE__ */ new Map();
|
|
895
|
-
workerOf = /* @__PURE__ */ new Map();
|
|
896
|
-
pending = /* @__PURE__ */ new Map();
|
|
897
|
-
msgHandlers = [];
|
|
898
|
-
disconnectHandlers = [];
|
|
899
|
-
execTimeoutMs;
|
|
900
|
-
keepaliveIntervalMs;
|
|
901
|
-
keepaliveTimers = /* @__PURE__ */ new Map();
|
|
902
|
-
ready() {
|
|
903
|
-
return new Promise((resolve) => {
|
|
904
|
-
const addr = this.wss.address();
|
|
905
|
-
if (addr) return resolve(addr.port);
|
|
906
|
-
this.wss.on(
|
|
907
|
-
"listening",
|
|
908
|
-
() => resolve(this.wss.address().port)
|
|
909
|
-
);
|
|
910
|
-
});
|
|
911
|
-
}
|
|
912
|
-
close() {
|
|
913
|
-
for (const p of this.pending.values()) {
|
|
914
|
-
clearTimeout(p.timer);
|
|
915
|
-
p.reject(new Error("hub closing"));
|
|
916
|
-
}
|
|
917
|
-
this.pending.clear();
|
|
918
|
-
for (const timer of this.keepaliveTimers.values()) clearInterval(timer);
|
|
919
|
-
this.keepaliveTimers.clear();
|
|
920
|
-
for (const socket of this.wss.clients) {
|
|
921
|
-
socket.terminate();
|
|
922
|
-
}
|
|
923
|
-
return new Promise((resolve) => this.wss.close(() => resolve()));
|
|
924
|
-
}
|
|
925
|
-
tokenOk(req) {
|
|
926
|
-
if (!this.opts.token) return true;
|
|
927
|
-
const offered = String(req.headers["sec-websocket-protocol"] ?? "").split(",").map((s) => s.trim());
|
|
928
|
-
return offered.includes(`bearer.${this.opts.token}`);
|
|
929
|
-
}
|
|
930
|
-
onConnection(socket, req) {
|
|
931
|
-
if (!this.tokenOk(req)) {
|
|
932
|
-
socket.close(4401, "bad token");
|
|
933
|
-
return;
|
|
934
|
-
}
|
|
935
|
-
socket.on("message", (raw) => this.onMessageRaw(socket, raw.toString()));
|
|
936
|
-
socket.on("close", () => this.onSocketClose(socket));
|
|
937
|
-
socket.on("error", () => {
|
|
938
|
-
});
|
|
939
|
-
}
|
|
940
|
-
onMessageRaw(socket, raw) {
|
|
941
|
-
let parsed;
|
|
942
|
-
try {
|
|
943
|
-
parsed = JSON.parse(raw);
|
|
944
|
-
} catch {
|
|
945
|
-
return;
|
|
946
|
-
}
|
|
947
|
-
const r = ClientToServerSchema.safeParse(parsed);
|
|
948
|
-
if (!r.success) return;
|
|
949
|
-
const msg = r.data;
|
|
950
|
-
if (msg.type === "HELLO") {
|
|
951
|
-
const existingSocket = this.byWorker.get(msg.worker_id);
|
|
952
|
-
if (existingSocket && existingSocket !== socket) {
|
|
953
|
-
this.workerOf.delete(existingSocket);
|
|
954
|
-
for (const [req_id, p] of [...this.pending]) {
|
|
955
|
-
if (p.worker_id === msg.worker_id) {
|
|
956
|
-
clearTimeout(p.timer);
|
|
957
|
-
this.pending.delete(req_id);
|
|
958
|
-
p.reject(new Error(`worker ${msg.worker_id} reconnected, exec cancelled`));
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
existingSocket.terminate();
|
|
962
|
-
}
|
|
963
|
-
this.byWorker.set(msg.worker_id, socket);
|
|
964
|
-
this.workerOf.set(socket, msg.worker_id);
|
|
965
|
-
this.rawSend(socket, {
|
|
966
|
-
type: "WELCOME",
|
|
967
|
-
nonce: this.opts.idGen.next("nonce"),
|
|
968
|
-
ts: this.opts.clock.now(),
|
|
969
|
-
protocol_version: PROTOCOL_VERSION,
|
|
970
|
-
server_time: this.opts.clock.now(),
|
|
971
|
-
heartbeat_interval_ms: HEARTBEAT_INTERVAL_MS
|
|
972
|
-
});
|
|
973
|
-
this.startKeepalive(socket);
|
|
974
|
-
for (const h of this.msgHandlers) h(msg.worker_id, msg);
|
|
975
|
-
return;
|
|
976
|
-
}
|
|
977
|
-
if (msg.type === "RESULT") {
|
|
978
|
-
const p = this.pending.get(msg.req_id);
|
|
979
|
-
if (p) {
|
|
980
|
-
clearTimeout(p.timer);
|
|
981
|
-
this.pending.delete(msg.req_id);
|
|
982
|
-
p.resolve(msg);
|
|
983
|
-
}
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
if (msg.type === "PING") {
|
|
987
|
-
this.rawSend(socket, {
|
|
988
|
-
type: "PONG",
|
|
989
|
-
nonce: this.opts.idGen.next("nonce"),
|
|
990
|
-
ts: this.opts.clock.now(),
|
|
991
|
-
protocol_version: PROTOCOL_VERSION,
|
|
992
|
-
echo_nonce: msg.nonce
|
|
993
|
-
});
|
|
994
|
-
}
|
|
995
|
-
const wid = this.workerOf.get(socket);
|
|
996
|
-
if (wid) {
|
|
997
|
-
for (const h of this.msgHandlers) h(wid, msg);
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
onSocketClose(socket) {
|
|
1001
|
-
this.stopKeepalive(socket);
|
|
1002
|
-
const wid = this.workerOf.get(socket);
|
|
1003
|
-
if (!wid) return;
|
|
1004
|
-
this.workerOf.delete(socket);
|
|
1005
|
-
this.byWorker.delete(wid);
|
|
1006
|
-
for (const [req_id, p] of [...this.pending]) {
|
|
1007
|
-
if (p.worker_id === wid) {
|
|
1008
|
-
clearTimeout(p.timer);
|
|
1009
|
-
this.pending.delete(req_id);
|
|
1010
|
-
p.reject(new Error(`worker ${wid} disconnected`));
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
for (const h of this.disconnectHandlers) h(wid);
|
|
1014
|
-
}
|
|
1015
|
-
startKeepalive(socket) {
|
|
1016
|
-
if (this.keepaliveIntervalMs <= 0) return;
|
|
1017
|
-
if (this.keepaliveTimers.has(socket)) return;
|
|
1018
|
-
const timer = setInterval(() => {
|
|
1019
|
-
if (socket.readyState !== WebSocket.OPEN) return;
|
|
1020
|
-
this.rawSend(socket, {
|
|
1021
|
-
type: "PONG",
|
|
1022
|
-
nonce: this.opts.idGen.next("nonce"),
|
|
1023
|
-
ts: this.opts.clock.now(),
|
|
1024
|
-
protocol_version: PROTOCOL_VERSION,
|
|
1025
|
-
echo_nonce: "server-keepalive"
|
|
1026
|
-
});
|
|
1027
|
-
}, this.keepaliveIntervalMs);
|
|
1028
|
-
if (typeof timer.unref === "function") {
|
|
1029
|
-
timer.unref();
|
|
1030
|
-
}
|
|
1031
|
-
this.keepaliveTimers.set(socket, timer);
|
|
1032
|
-
}
|
|
1033
|
-
stopKeepalive(socket) {
|
|
1034
|
-
const timer = this.keepaliveTimers.get(socket);
|
|
1035
|
-
if (!timer) return;
|
|
1036
|
-
clearInterval(timer);
|
|
1037
|
-
this.keepaliveTimers.delete(socket);
|
|
1038
|
-
}
|
|
1039
|
-
rawSend(socket, msg) {
|
|
1040
|
-
const r = ServerToClientSchema.safeParse(msg);
|
|
1041
|
-
if (!r.success) {
|
|
1042
|
-
console.error("[hub] outgoing failed schema", r.error);
|
|
1043
|
-
return;
|
|
1044
|
-
}
|
|
1045
|
-
if (socket.readyState === WebSocket.OPEN) {
|
|
1046
|
-
socket.send(JSON.stringify(msg));
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
exec(worker_id, params) {
|
|
1050
|
-
const socket = this.byWorker.get(worker_id);
|
|
1051
|
-
if (!socket)
|
|
1052
|
-
return Promise.reject(new Error(`worker ${worker_id} not connected`));
|
|
1053
|
-
const req_id = this.opts.idGen.next("req");
|
|
1054
|
-
return new Promise((resolve, reject) => {
|
|
1055
|
-
const timer = setTimeout(() => {
|
|
1056
|
-
this.pending.delete(req_id);
|
|
1057
|
-
reject(
|
|
1058
|
-
new Error(`EXEC ${req_id} timeout after ${this.execTimeoutMs}ms`)
|
|
1059
|
-
);
|
|
1060
|
-
}, this.execTimeoutMs);
|
|
1061
|
-
this.pending.set(req_id, { resolve, reject, timer, worker_id });
|
|
1062
|
-
this.rawSend(socket, {
|
|
1063
|
-
type: "EXEC",
|
|
1064
|
-
nonce: this.opts.idGen.next("nonce"),
|
|
1065
|
-
ts: this.opts.clock.now(),
|
|
1066
|
-
protocol_version: PROTOCOL_VERSION,
|
|
1067
|
-
req_id,
|
|
1068
|
-
session_id: params.session_id,
|
|
1069
|
-
tab_id: params.tab_id,
|
|
1070
|
-
step: { kind: "tool", tool: params.step.tool, args: params.step.args }
|
|
1071
|
-
});
|
|
1072
|
-
if (this.byWorker.get(worker_id) !== socket) {
|
|
1073
|
-
clearTimeout(timer);
|
|
1074
|
-
this.pending.delete(req_id);
|
|
1075
|
-
reject(new Error(`worker ${worker_id} disconnected`));
|
|
1076
|
-
}
|
|
1077
|
-
});
|
|
1078
|
-
}
|
|
1079
|
-
async send(worker_id, msg) {
|
|
1080
|
-
const socket = this.byWorker.get(worker_id);
|
|
1081
|
-
if (!socket) throw new Error(`worker ${worker_id} not connected`);
|
|
1082
|
-
this.rawSend(socket, msg);
|
|
1083
|
-
}
|
|
1084
|
-
onMessage(handler) {
|
|
1085
|
-
this.msgHandlers.push(handler);
|
|
1086
|
-
}
|
|
1087
|
-
onDisconnect(handler) {
|
|
1088
|
-
this.disconnectHandlers.push(handler);
|
|
1089
|
-
}
|
|
1090
|
-
connectedWorkers() {
|
|
1091
|
-
return [...this.byWorker.keys()];
|
|
1092
|
-
}
|
|
1093
|
-
// Intentionally fire-and-forget: actual cleanup (byWorker/workerOf removal and
|
|
1094
|
-
// pending-exec rejection) happens asynchronously via the socket's close event.
|
|
1095
|
-
// v1 callers do not await this method.
|
|
1096
|
-
async disconnect(worker_id, _reason) {
|
|
1097
|
-
const socket = this.byWorker.get(worker_id);
|
|
1098
|
-
if (socket) socket.close();
|
|
1099
|
-
}
|
|
1100
|
-
};
|
|
1101
|
-
|
|
1102
919
|
// src/mcp-server.ts
|
|
1103
920
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
1104
921
|
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -2040,31 +1857,39 @@ function readToolMode(env) {
|
|
|
2040
1857
|
}
|
|
2041
1858
|
|
|
2042
1859
|
// src/handlers.ts
|
|
2043
|
-
function singleWorkerId(c) {
|
|
1860
|
+
function singleWorkerId(c, pairUrl) {
|
|
2044
1861
|
const workers = c.workers.list();
|
|
2045
|
-
if (workers.length === 0)
|
|
1862
|
+
if (workers.length === 0) {
|
|
1863
|
+
throw new Error(
|
|
1864
|
+
"\u6CA1\u6709\u6D4F\u89C8\u5668\u8FDE\u5165\u3002\u5DF2\u4E3A\u4F60\u6253\u5F00\u914D\u5BF9\u9875" + (pairUrl ? ` ${pairUrl}` : "") + "\uFF0C\u5728\u6D4F\u89C8\u5668\u91CC\u786E\u8BA4\u540E\u91CD\u8BD5\u672C\u6B21\u8C03\u7528\u3002\uFF08\u5982\u679C\u9ED8\u8BA4\u6D4F\u89C8\u5668\u4E0D\u662F\u88C5\u4E86\u6269\u5C55\u7684\u90A3\u4E2A\uFF0C\u8BF7\u628A\u8BE5\u5730\u5740\u7C98\u8D34\u8FC7\u53BB\u6253\u5F00\uFF09"
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
2046
1867
|
if (workers.length > 1) throw new Error("\u68C0\u6D4B\u5230\u591A\u4E2A\u6D4F\u89C8\u5668\u8FDE\u5165\uFF1Bv1 \u4EC5\u652F\u6301\u5355 worker\uFF0C\u8BF7\u53EA\u4FDD\u7559\u4E00\u4E2A\u8FDE\u63A5");
|
|
2047
1868
|
return workers[0].id;
|
|
2048
1869
|
}
|
|
2049
|
-
function handleListTabs(deps) {
|
|
2050
|
-
const
|
|
1870
|
+
async function handleListTabs(deps) {
|
|
1871
|
+
const { coordinator } = await deps.ensure();
|
|
1872
|
+
const w = coordinator.workers.get(singleWorkerId(coordinator, deps.pairUrl()));
|
|
2051
1873
|
return { tabs: w.available_tabs };
|
|
2052
1874
|
}
|
|
2053
|
-
function handleOpenSession(deps, args) {
|
|
2054
|
-
const
|
|
1875
|
+
async function handleOpenSession(deps, args) {
|
|
1876
|
+
const { coordinator } = await deps.ensure();
|
|
1877
|
+
const worker_id = singleWorkerId(coordinator, deps.pairUrl());
|
|
2055
1878
|
const tab_id = String(args.tab_id);
|
|
2056
1879
|
const requested = Array.isArray(args.capabilities) ? args.capabilities.map(String).filter(isCapability) : [];
|
|
2057
1880
|
const scope = new Set(requested.length ? requested : CAPABILITIES);
|
|
2058
1881
|
const idle_timeout_ms = typeof args.idle_timeout_min === "number" ? args.idle_timeout_min * 6e4 : void 0;
|
|
2059
|
-
const s =
|
|
1882
|
+
const s = coordinator.openSession({ ai_client_fingerprint: "mcp-local", worker_id, tab_id, scope, idle_timeout_ms });
|
|
2060
1883
|
return { session_id: s.id };
|
|
2061
1884
|
}
|
|
2062
|
-
function handleCloseSession(deps, args) {
|
|
2063
|
-
deps.
|
|
1885
|
+
async function handleCloseSession(deps, args) {
|
|
1886
|
+
const { coordinator } = await deps.ensure();
|
|
1887
|
+
coordinator.closeSession(String(args.session_id));
|
|
2064
1888
|
return { ok: true };
|
|
2065
1889
|
}
|
|
2066
|
-
function handleGetQuota(deps, args) {
|
|
2067
|
-
const
|
|
1890
|
+
async function handleGetQuota(deps, args) {
|
|
1891
|
+
const { coordinator } = await deps.ensure();
|
|
1892
|
+
const q = coordinator.quotaFor(String(args.session_id));
|
|
2068
1893
|
if (!q) throw new Error(`session ${String(args.session_id)} not found`);
|
|
2069
1894
|
return q;
|
|
2070
1895
|
}
|
|
@@ -2073,18 +1898,19 @@ var NON_BUILTIN_CAPABILITY = {
|
|
|
2073
1898
|
openTab: "tab:open"
|
|
2074
1899
|
};
|
|
2075
1900
|
async function handleBrowserTool(deps, gen, args) {
|
|
1901
|
+
const { coordinator, hub } = await deps.ensure();
|
|
2076
1902
|
const session_id = String(args.session_id);
|
|
2077
|
-
const session =
|
|
1903
|
+
const session = coordinator.sessions.get(session_id);
|
|
2078
1904
|
if (!session) throw new Error(`session ${session_id} not found`);
|
|
2079
1905
|
const { session_id: _omit, ...toolArgs } = args;
|
|
2080
1906
|
const tool = gen.builtinTool;
|
|
2081
1907
|
if (gen.stepKind === "js") {
|
|
2082
1908
|
const source = String(toolArgs.source ?? "");
|
|
2083
1909
|
if (!source) throw new Error("browser_runJS: source required");
|
|
2084
|
-
const v =
|
|
1910
|
+
const v = coordinator.validateCall({ session_id, kind: "runJS", unsafe: true });
|
|
2085
1911
|
if (!v.ok) throw new Error(`${v.error.code}: ${v.error.message}`);
|
|
2086
|
-
|
|
2087
|
-
const jsResult = await
|
|
1912
|
+
coordinator.recordCall(session_id, v.dangerous);
|
|
1913
|
+
const jsResult = await hub.exec(session.worker_id, {
|
|
2088
1914
|
session_id,
|
|
2089
1915
|
tab_id: session.tab_id,
|
|
2090
1916
|
step: { kind: "js", source }
|
|
@@ -2098,12 +1924,12 @@ async function handleBrowserTool(deps, gen, args) {
|
|
|
2098
1924
|
}
|
|
2099
1925
|
const override = NON_BUILTIN_CAPABILITY[gen.builtinTool];
|
|
2100
1926
|
if (override) {
|
|
2101
|
-
const v =
|
|
1927
|
+
const v = coordinator.validateCall({ session_id, kind: "capability", capability: override });
|
|
2102
1928
|
if (!v.ok) throw new Error(`${v.error.code}: ${v.error.message}`);
|
|
2103
|
-
|
|
1929
|
+
coordinator.recordCall(session_id, v.dangerous);
|
|
2104
1930
|
} else {
|
|
2105
1931
|
const raw = toolArgs;
|
|
2106
|
-
const v =
|
|
1932
|
+
const v = coordinator.validateCall({
|
|
2107
1933
|
session_id,
|
|
2108
1934
|
kind: "extension_tool",
|
|
2109
1935
|
tool,
|
|
@@ -2112,9 +1938,9 @@ async function handleBrowserTool(deps, gen, args) {
|
|
|
2112
1938
|
recorderArmsBodies: tool === "recorderConfig" ? raw.bodies === true : void 0
|
|
2113
1939
|
});
|
|
2114
1940
|
if (!v.ok) throw new Error(`${v.error.code}: ${v.error.message}`);
|
|
2115
|
-
|
|
1941
|
+
coordinator.recordCall(session_id, v.dangerous);
|
|
2116
1942
|
}
|
|
2117
|
-
const result = await
|
|
1943
|
+
const result = await hub.exec(session.worker_id, { session_id, tab_id: session.tab_id, step: { kind: "tool", tool, args: toolArgs } });
|
|
2118
1944
|
if (!result.ok) throw new Error(result.error ? `${result.error.code}: ${result.error.message}` : "EXEC failed");
|
|
2119
1945
|
return result.return ?? null;
|
|
2120
1946
|
}
|
|
@@ -2186,8 +2012,9 @@ var LEGACY_TOOLS = [
|
|
|
2186
2012
|
"readStorage"
|
|
2187
2013
|
];
|
|
2188
2014
|
function workerToolSupport(deps) {
|
|
2189
|
-
|
|
2190
|
-
|
|
2015
|
+
const bundle = deps?.peek();
|
|
2016
|
+
if (!bundle) return void 0;
|
|
2017
|
+
const workers = bundle.coordinator.workers.list();
|
|
2191
2018
|
if (workers.length === 0) return void 0;
|
|
2192
2019
|
const supported = workers[0].supported_tools;
|
|
2193
2020
|
return supported ?? new Set(LEGACY_TOOLS);
|
|
@@ -2218,10 +2045,10 @@ async function dispatchCall(deps, name, args) {
|
|
|
2218
2045
|
const bundle = readSkillBundle();
|
|
2219
2046
|
return { content: [{ type: "text", text: bundle.content }] };
|
|
2220
2047
|
}
|
|
2221
|
-
if (name === "list_tabs") return ok(handleListTabs(deps));
|
|
2222
|
-
if (name === "open_session") return ok(handleOpenSession(deps, args));
|
|
2223
|
-
if (name === "close_session") return ok(handleCloseSession(deps, args));
|
|
2224
|
-
if (name === "get_quota") return ok(handleGetQuota(deps, args));
|
|
2048
|
+
if (name === "list_tabs") return ok(await handleListTabs(deps));
|
|
2049
|
+
if (name === "open_session") return ok(await handleOpenSession(deps, args));
|
|
2050
|
+
if (name === "close_session") return ok(await handleCloseSession(deps, args));
|
|
2051
|
+
if (name === "get_quota") return ok(await handleGetQuota(deps, args));
|
|
2225
2052
|
const gen = BROWSER_BY_NAME.get(name);
|
|
2226
2053
|
if (gen) return toolResult(gen, await handleBrowserTool(deps, gen, args));
|
|
2227
2054
|
return fail2(`unknown tool: ${name}`);
|
|
@@ -2239,6 +2066,318 @@ function createMcpServer(deps) {
|
|
|
2239
2066
|
return server;
|
|
2240
2067
|
}
|
|
2241
2068
|
|
|
2069
|
+
// src/loopback-ws-hub.ts
|
|
2070
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
2071
|
+
import { createServer } from "http";
|
|
2072
|
+
|
|
2073
|
+
// ../shared/src/pairing/types.ts
|
|
2074
|
+
var GRACEFUL_CLOSE_CODE = 4e3;
|
|
2075
|
+
var GRACEFUL_CLOSE_REASON = "server-shutting-down";
|
|
2076
|
+
|
|
2077
|
+
// src/pair-page.ts
|
|
2078
|
+
function renderPairPage(payload) {
|
|
2079
|
+
const json = JSON.stringify(payload).replace(/</g, "\\u003c");
|
|
2080
|
+
return `<!doctype html>
|
|
2081
|
+
<meta charset="utf-8">
|
|
2082
|
+
<title>AtWebPilot \u914D\u5BF9</title>
|
|
2083
|
+
<body style="font:14px/1.6 system-ui,sans-serif;padding:2rem;max-width:32rem;margin:auto;color:#222">
|
|
2084
|
+
<h1 style="font-size:1.1rem;margin:0 0 1rem">AtWebPilot \u914D\u5BF9</h1>
|
|
2085
|
+
<p id="atwebpilot-status">\u6B63\u5728\u8054\u7CFB\u6269\u5C55\u2026</p>
|
|
2086
|
+
<p style="color:#666;font-size:13px">\u4F1A\u8BDD\uFF1A${escapeHtml(payload.label)} \xB7 pid ${payload.pid} \xB7 \u7AEF\u53E3 ${payload.port}</p>
|
|
2087
|
+
<p style="color:#666;font-size:13px">\u6CA1\u6709\u53CD\u5E94\uFF1F\u8BF7\u786E\u8BA4\u8FD9\u4E2A\u6D4F\u89C8\u5668\u5DF2\u5B89\u88C5\u5E76\u542F\u7528 AtWebPilot \u6269\u5C55\uFF0C\u7136\u540E\u5237\u65B0\u672C\u9875\u3002</p>
|
|
2088
|
+
<script>
|
|
2089
|
+
(function () {
|
|
2090
|
+
var payload = ${json};
|
|
2091
|
+
window.postMessage({ source: "atwebpilot-pair", payload: payload }, "*");
|
|
2092
|
+
window.addEventListener("message", function (e) {
|
|
2093
|
+
if (!e.data || e.data.source !== "atwebpilot-pair-result") return;
|
|
2094
|
+
var el = document.getElementById("atwebpilot-status");
|
|
2095
|
+
if (e.data.ok) {
|
|
2096
|
+
el.textContent = e.data.trusted ? "\u5DF2\u4FE1\u4EFB\uFF0C\u8FDE\u63A5\u4E2D\u2026" : "\u5DF2\u8FDE\u63A5";
|
|
2097
|
+
setTimeout(function () { window.close(); }, 1200);
|
|
2098
|
+
} else {
|
|
2099
|
+
el.textContent = "\u5DF2\u62D2\u7EDD\u3002\u53EF\u4EE5\u5173\u95ED\u672C\u9875\u3002";
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
})();
|
|
2103
|
+
</script>
|
|
2104
|
+
`;
|
|
2105
|
+
}
|
|
2106
|
+
function escapeHtml(s) {
|
|
2107
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// src/loopback-ws-hub.ts
|
|
2111
|
+
var HEARTBEAT_INTERVAL_MS = 2e4;
|
|
2112
|
+
var DEFAULT_KEEPALIVE_INTERVAL_MS = 15e3;
|
|
2113
|
+
var LoopbackWSHub = class {
|
|
2114
|
+
constructor(opts) {
|
|
2115
|
+
this.opts = opts;
|
|
2116
|
+
this.execTimeoutMs = opts.execTimeoutMs ?? 3e4;
|
|
2117
|
+
this.keepaliveIntervalMs = opts.keepaliveIntervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS;
|
|
2118
|
+
this.http = createServer((req, res) => this.onHttpRequest(req, res));
|
|
2119
|
+
this.readyPromise = new Promise((resolve, reject) => {
|
|
2120
|
+
this.http.once("listening", () => resolve(this.http.address().port));
|
|
2121
|
+
this.http.once("error", (err) => {
|
|
2122
|
+
reject(
|
|
2123
|
+
err.code === "EADDRINUSE" ? new Error(
|
|
2124
|
+
`port ${opts.port} is already in use \u2014 another atwebpilot-mcp may be running; set ATWEBPILOT_WS_PORT to choose another port`
|
|
2125
|
+
) : err
|
|
2126
|
+
);
|
|
2127
|
+
});
|
|
2128
|
+
});
|
|
2129
|
+
this.wss = new WebSocketServer({
|
|
2130
|
+
server: this.http,
|
|
2131
|
+
path: "/worker",
|
|
2132
|
+
handleProtocols: (protocols) => [...protocols][0] ?? false
|
|
2133
|
+
});
|
|
2134
|
+
this.wss.on("connection", (socket, req) => this.onConnection(socket, req));
|
|
2135
|
+
this.http.on("error", () => void 0);
|
|
2136
|
+
this.wss.on("error", () => void 0);
|
|
2137
|
+
this.http.listen(opts.port);
|
|
2138
|
+
}
|
|
2139
|
+
opts;
|
|
2140
|
+
http;
|
|
2141
|
+
wss;
|
|
2142
|
+
/** Settled by the constructor so ready() cannot race the listen outcome. */
|
|
2143
|
+
readyPromise;
|
|
2144
|
+
byWorker = /* @__PURE__ */ new Map();
|
|
2145
|
+
workerOf = /* @__PURE__ */ new Map();
|
|
2146
|
+
pending = /* @__PURE__ */ new Map();
|
|
2147
|
+
msgHandlers = [];
|
|
2148
|
+
disconnectHandlers = [];
|
|
2149
|
+
execTimeoutMs;
|
|
2150
|
+
keepaliveIntervalMs;
|
|
2151
|
+
keepaliveTimers = /* @__PURE__ */ new Map();
|
|
2152
|
+
ready() {
|
|
2153
|
+
return this.readyPromise;
|
|
2154
|
+
}
|
|
2155
|
+
/** Serves the pairing page; everything else is a 404. */
|
|
2156
|
+
onHttpRequest(req, res) {
|
|
2157
|
+
const path = (req.url ?? "").split("?")[0];
|
|
2158
|
+
if (path !== "/pair") {
|
|
2159
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
2160
|
+
res.end("not found");
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
const payload = this.opts.pairPayload?.();
|
|
2164
|
+
if (!payload) {
|
|
2165
|
+
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
|
|
2166
|
+
res.end("pairing unavailable");
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
res.writeHead(200, {
|
|
2170
|
+
"content-type": "text/html; charset=utf-8",
|
|
2171
|
+
"cache-control": "no-store"
|
|
2172
|
+
});
|
|
2173
|
+
res.end(renderPairPage(payload));
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Tells connected extensions this shutdown is deliberate, so they drop the
|
|
2177
|
+
* endpoint instead of retrying it for the rest of the browser session.
|
|
2178
|
+
*/
|
|
2179
|
+
async shutdown() {
|
|
2180
|
+
for (const socket of this.wss.clients) {
|
|
2181
|
+
try {
|
|
2182
|
+
socket.close(GRACEFUL_CLOSE_CODE, GRACEFUL_CLOSE_REASON);
|
|
2183
|
+
} catch {
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
2187
|
+
await this.close();
|
|
2188
|
+
}
|
|
2189
|
+
close() {
|
|
2190
|
+
for (const p of this.pending.values()) {
|
|
2191
|
+
clearTimeout(p.timer);
|
|
2192
|
+
p.reject(new Error("hub closing"));
|
|
2193
|
+
}
|
|
2194
|
+
this.pending.clear();
|
|
2195
|
+
for (const timer of this.keepaliveTimers.values()) clearInterval(timer);
|
|
2196
|
+
this.keepaliveTimers.clear();
|
|
2197
|
+
for (const socket of this.wss.clients) {
|
|
2198
|
+
socket.terminate();
|
|
2199
|
+
}
|
|
2200
|
+
return new Promise(
|
|
2201
|
+
(resolve) => this.wss.close(() => this.http.close(() => resolve()))
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
tokenOk(req) {
|
|
2205
|
+
if (!this.opts.token) return true;
|
|
2206
|
+
const offered = String(req.headers["sec-websocket-protocol"] ?? "").split(",").map((s) => s.trim());
|
|
2207
|
+
return offered.includes(`bearer.${this.opts.token}`);
|
|
2208
|
+
}
|
|
2209
|
+
onConnection(socket, req) {
|
|
2210
|
+
if (!this.tokenOk(req)) {
|
|
2211
|
+
socket.close(4401, "bad token");
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
socket.on("message", (raw) => this.onMessageRaw(socket, raw.toString()));
|
|
2215
|
+
socket.on("close", () => this.onSocketClose(socket));
|
|
2216
|
+
socket.on("error", () => {
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
onMessageRaw(socket, raw) {
|
|
2220
|
+
let parsed;
|
|
2221
|
+
try {
|
|
2222
|
+
parsed = JSON.parse(raw);
|
|
2223
|
+
} catch {
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
const r = ClientToServerSchema.safeParse(parsed);
|
|
2227
|
+
if (!r.success) return;
|
|
2228
|
+
const msg = r.data;
|
|
2229
|
+
if (msg.type === "HELLO") {
|
|
2230
|
+
const existingSocket = this.byWorker.get(msg.worker_id);
|
|
2231
|
+
if (existingSocket && existingSocket !== socket) {
|
|
2232
|
+
this.workerOf.delete(existingSocket);
|
|
2233
|
+
for (const [req_id, p] of [...this.pending]) {
|
|
2234
|
+
if (p.worker_id === msg.worker_id) {
|
|
2235
|
+
clearTimeout(p.timer);
|
|
2236
|
+
this.pending.delete(req_id);
|
|
2237
|
+
p.reject(new Error(`worker ${msg.worker_id} reconnected, exec cancelled`));
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
existingSocket.terminate();
|
|
2241
|
+
}
|
|
2242
|
+
this.byWorker.set(msg.worker_id, socket);
|
|
2243
|
+
this.workerOf.set(socket, msg.worker_id);
|
|
2244
|
+
this.rawSend(socket, {
|
|
2245
|
+
type: "WELCOME",
|
|
2246
|
+
nonce: this.opts.idGen.next("nonce"),
|
|
2247
|
+
ts: this.opts.clock.now(),
|
|
2248
|
+
protocol_version: PROTOCOL_VERSION,
|
|
2249
|
+
server_time: this.opts.clock.now(),
|
|
2250
|
+
heartbeat_interval_ms: HEARTBEAT_INTERVAL_MS
|
|
2251
|
+
});
|
|
2252
|
+
this.startKeepalive(socket);
|
|
2253
|
+
for (const h of this.msgHandlers) h(msg.worker_id, msg);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
if (msg.type === "RESULT") {
|
|
2257
|
+
const p = this.pending.get(msg.req_id);
|
|
2258
|
+
if (p) {
|
|
2259
|
+
clearTimeout(p.timer);
|
|
2260
|
+
this.pending.delete(msg.req_id);
|
|
2261
|
+
p.resolve(msg);
|
|
2262
|
+
}
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
if (msg.type === "PING") {
|
|
2266
|
+
this.rawSend(socket, {
|
|
2267
|
+
type: "PONG",
|
|
2268
|
+
nonce: this.opts.idGen.next("nonce"),
|
|
2269
|
+
ts: this.opts.clock.now(),
|
|
2270
|
+
protocol_version: PROTOCOL_VERSION,
|
|
2271
|
+
echo_nonce: msg.nonce
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
const wid = this.workerOf.get(socket);
|
|
2275
|
+
if (wid) {
|
|
2276
|
+
for (const h of this.msgHandlers) h(wid, msg);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
onSocketClose(socket) {
|
|
2280
|
+
this.stopKeepalive(socket);
|
|
2281
|
+
const wid = this.workerOf.get(socket);
|
|
2282
|
+
if (!wid) return;
|
|
2283
|
+
this.workerOf.delete(socket);
|
|
2284
|
+
this.byWorker.delete(wid);
|
|
2285
|
+
for (const [req_id, p] of [...this.pending]) {
|
|
2286
|
+
if (p.worker_id === wid) {
|
|
2287
|
+
clearTimeout(p.timer);
|
|
2288
|
+
this.pending.delete(req_id);
|
|
2289
|
+
p.reject(new Error(`worker ${wid} disconnected`));
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
for (const h of this.disconnectHandlers) h(wid);
|
|
2293
|
+
}
|
|
2294
|
+
startKeepalive(socket) {
|
|
2295
|
+
if (this.keepaliveIntervalMs <= 0) return;
|
|
2296
|
+
if (this.keepaliveTimers.has(socket)) return;
|
|
2297
|
+
const timer = setInterval(() => {
|
|
2298
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
2299
|
+
this.rawSend(socket, {
|
|
2300
|
+
type: "PONG",
|
|
2301
|
+
nonce: this.opts.idGen.next("nonce"),
|
|
2302
|
+
ts: this.opts.clock.now(),
|
|
2303
|
+
protocol_version: PROTOCOL_VERSION,
|
|
2304
|
+
echo_nonce: "server-keepalive"
|
|
2305
|
+
});
|
|
2306
|
+
}, this.keepaliveIntervalMs);
|
|
2307
|
+
if (typeof timer.unref === "function") {
|
|
2308
|
+
timer.unref();
|
|
2309
|
+
}
|
|
2310
|
+
this.keepaliveTimers.set(socket, timer);
|
|
2311
|
+
}
|
|
2312
|
+
stopKeepalive(socket) {
|
|
2313
|
+
const timer = this.keepaliveTimers.get(socket);
|
|
2314
|
+
if (!timer) return;
|
|
2315
|
+
clearInterval(timer);
|
|
2316
|
+
this.keepaliveTimers.delete(socket);
|
|
2317
|
+
}
|
|
2318
|
+
rawSend(socket, msg) {
|
|
2319
|
+
const r = ServerToClientSchema.safeParse(msg);
|
|
2320
|
+
if (!r.success) {
|
|
2321
|
+
console.error("[hub] outgoing failed schema", r.error);
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
2325
|
+
socket.send(JSON.stringify(msg));
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
exec(worker_id, params) {
|
|
2329
|
+
const socket = this.byWorker.get(worker_id);
|
|
2330
|
+
if (!socket)
|
|
2331
|
+
return Promise.reject(new Error(`worker ${worker_id} not connected`));
|
|
2332
|
+
const req_id = this.opts.idGen.next("req");
|
|
2333
|
+
return new Promise((resolve, reject) => {
|
|
2334
|
+
const timer = setTimeout(() => {
|
|
2335
|
+
this.pending.delete(req_id);
|
|
2336
|
+
reject(
|
|
2337
|
+
new Error(`EXEC ${req_id} timeout after ${this.execTimeoutMs}ms`)
|
|
2338
|
+
);
|
|
2339
|
+
}, this.execTimeoutMs);
|
|
2340
|
+
this.pending.set(req_id, { resolve, reject, timer, worker_id });
|
|
2341
|
+
this.rawSend(socket, {
|
|
2342
|
+
type: "EXEC",
|
|
2343
|
+
nonce: this.opts.idGen.next("nonce"),
|
|
2344
|
+
ts: this.opts.clock.now(),
|
|
2345
|
+
protocol_version: PROTOCOL_VERSION,
|
|
2346
|
+
req_id,
|
|
2347
|
+
session_id: params.session_id,
|
|
2348
|
+
tab_id: params.tab_id,
|
|
2349
|
+
step: { kind: "tool", tool: params.step.tool, args: params.step.args }
|
|
2350
|
+
});
|
|
2351
|
+
if (this.byWorker.get(worker_id) !== socket) {
|
|
2352
|
+
clearTimeout(timer);
|
|
2353
|
+
this.pending.delete(req_id);
|
|
2354
|
+
reject(new Error(`worker ${worker_id} disconnected`));
|
|
2355
|
+
}
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
async send(worker_id, msg) {
|
|
2359
|
+
const socket = this.byWorker.get(worker_id);
|
|
2360
|
+
if (!socket) throw new Error(`worker ${worker_id} not connected`);
|
|
2361
|
+
this.rawSend(socket, msg);
|
|
2362
|
+
}
|
|
2363
|
+
onMessage(handler) {
|
|
2364
|
+
this.msgHandlers.push(handler);
|
|
2365
|
+
}
|
|
2366
|
+
onDisconnect(handler) {
|
|
2367
|
+
this.disconnectHandlers.push(handler);
|
|
2368
|
+
}
|
|
2369
|
+
connectedWorkers() {
|
|
2370
|
+
return [...this.byWorker.keys()];
|
|
2371
|
+
}
|
|
2372
|
+
// Intentionally fire-and-forget: actual cleanup (byWorker/workerOf removal and
|
|
2373
|
+
// pending-exec rejection) happens asynchronously via the socket's close event.
|
|
2374
|
+
// v1 callers do not await this method.
|
|
2375
|
+
async disconnect(worker_id, _reason) {
|
|
2376
|
+
const socket = this.byWorker.get(worker_id);
|
|
2377
|
+
if (socket) socket.close();
|
|
2378
|
+
}
|
|
2379
|
+
};
|
|
2380
|
+
|
|
2242
2381
|
// src/wire.ts
|
|
2243
2382
|
function helloToWorker(h, now) {
|
|
2244
2383
|
return {
|
|
@@ -2265,6 +2404,11 @@ function installWire(hub, coordinator, clock) {
|
|
|
2265
2404
|
case "PING":
|
|
2266
2405
|
coordinator.heartbeatWorker(worker_id);
|
|
2267
2406
|
break;
|
|
2407
|
+
case "TABS_UPDATE": {
|
|
2408
|
+
const w = coordinator.workers.get(worker_id);
|
|
2409
|
+
if (w) w.available_tabs = msg.tabs.map((t) => ({ ...t }));
|
|
2410
|
+
break;
|
|
2411
|
+
}
|
|
2268
2412
|
default:
|
|
2269
2413
|
break;
|
|
2270
2414
|
}
|
|
@@ -2272,19 +2416,150 @@ function installWire(hub, coordinator, clock) {
|
|
|
2272
2416
|
hub.onDisconnect((worker_id) => coordinator.unregisterWorker(worker_id));
|
|
2273
2417
|
}
|
|
2274
2418
|
|
|
2419
|
+
// src/identity.ts
|
|
2420
|
+
import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
2421
|
+
import { homedir } from "os";
|
|
2422
|
+
import { join as join2 } from "path";
|
|
2423
|
+
import { randomBytes, randomUUID } from "crypto";
|
|
2424
|
+
var FILE = "identity.json";
|
|
2425
|
+
function defaultIdentityDir() {
|
|
2426
|
+
return join2(homedir(), ".atwebpilot");
|
|
2427
|
+
}
|
|
2428
|
+
function read(dir) {
|
|
2429
|
+
try {
|
|
2430
|
+
const raw = readFileSync2(join2(dir, FILE), "utf-8");
|
|
2431
|
+
const parsed = JSON.parse(raw);
|
|
2432
|
+
if (typeof parsed.installId !== "string" || typeof parsed.secret !== "string") return null;
|
|
2433
|
+
return parsed;
|
|
2434
|
+
} catch {
|
|
2435
|
+
return null;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
function write(dir, data) {
|
|
2439
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2440
|
+
writeFileSync(join2(dir, FILE), `${JSON.stringify(data, null, 2)}
|
|
2441
|
+
`, { mode: 384 });
|
|
2442
|
+
}
|
|
2443
|
+
function loadOrCreateIdentity(dir = defaultIdentityDir()) {
|
|
2444
|
+
const existing = read(dir);
|
|
2445
|
+
if (existing) return { installId: existing.installId, secret: existing.secret };
|
|
2446
|
+
const created = {
|
|
2447
|
+
installId: `inst_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
|
|
2448
|
+
secret: randomBytes(24).toString("base64url")
|
|
2449
|
+
};
|
|
2450
|
+
write(dir, created);
|
|
2451
|
+
return { installId: created.installId, secret: created.secret };
|
|
2452
|
+
}
|
|
2453
|
+
function loadLastPort(dir = defaultIdentityDir()) {
|
|
2454
|
+
const existing = read(dir);
|
|
2455
|
+
return typeof existing?.lastPort === "number" ? existing.lastPort : void 0;
|
|
2456
|
+
}
|
|
2457
|
+
function saveLastPort(port, dir = defaultIdentityDir()) {
|
|
2458
|
+
const existing = read(dir);
|
|
2459
|
+
const base = existing ?? { ...loadOrCreateIdentity(dir) };
|
|
2460
|
+
write(dir, { ...base, lastPort: port });
|
|
2461
|
+
}
|
|
2462
|
+
function processInfo(cwd = process.cwd()) {
|
|
2463
|
+
const home = homedir();
|
|
2464
|
+
const label = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
|
|
2465
|
+
return {
|
|
2466
|
+
sessionId: `sess_${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
|
2467
|
+
label,
|
|
2468
|
+
pid: process.pid
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// src/ensure-hub.ts
|
|
2473
|
+
function createHubEnsurer(d) {
|
|
2474
|
+
let bundle = null;
|
|
2475
|
+
let inflight = null;
|
|
2476
|
+
let pairUrlValue = null;
|
|
2477
|
+
let opened = false;
|
|
2478
|
+
const info = d.processInfo ?? processInfo();
|
|
2479
|
+
async function bind() {
|
|
2480
|
+
const identity = loadOrCreateIdentity(d.identityDir);
|
|
2481
|
+
const preferred = d.explicitPort ?? loadLastPort(d.identityDir) ?? 0;
|
|
2482
|
+
let hub;
|
|
2483
|
+
let port;
|
|
2484
|
+
const pairPayload = () => ({
|
|
2485
|
+
v: 1,
|
|
2486
|
+
installId: identity.installId,
|
|
2487
|
+
secret: identity.secret,
|
|
2488
|
+
sessionId: info.sessionId,
|
|
2489
|
+
label: info.label,
|
|
2490
|
+
pid: info.pid,
|
|
2491
|
+
port
|
|
2492
|
+
});
|
|
2493
|
+
try {
|
|
2494
|
+
hub = new LoopbackWSHub({ ...common(), port: preferred, pairPayload });
|
|
2495
|
+
port = await hub.ready();
|
|
2496
|
+
} catch (e) {
|
|
2497
|
+
if (d.explicitPort != null) throw e;
|
|
2498
|
+
hub = new LoopbackWSHub({ ...common(), port: 0, pairPayload });
|
|
2499
|
+
port = await hub.ready();
|
|
2500
|
+
}
|
|
2501
|
+
saveLastPort(port, d.identityDir);
|
|
2502
|
+
const coordinator = new Coordinator({ hub, clock: d.clock, idGen: d.idGen });
|
|
2503
|
+
installWire(hub, coordinator, d.clock);
|
|
2504
|
+
pairUrlValue = `http://127.0.0.1:${port}/pair`;
|
|
2505
|
+
return { coordinator, hub, port };
|
|
2506
|
+
function common() {
|
|
2507
|
+
return { token: d.token, clock: d.clock, idGen: d.idGen };
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
async function ensure() {
|
|
2511
|
+
if (bundle) return bundle;
|
|
2512
|
+
inflight ??= bind().then((b2) => {
|
|
2513
|
+
bundle = b2;
|
|
2514
|
+
return b2;
|
|
2515
|
+
});
|
|
2516
|
+
const b = await inflight;
|
|
2517
|
+
if (!opened) {
|
|
2518
|
+
opened = true;
|
|
2519
|
+
if (pairUrlValue) d.openUrl?.(pairUrlValue);
|
|
2520
|
+
}
|
|
2521
|
+
return b;
|
|
2522
|
+
}
|
|
2523
|
+
return {
|
|
2524
|
+
ensure,
|
|
2525
|
+
peek: () => bundle,
|
|
2526
|
+
pairUrl: () => pairUrlValue,
|
|
2527
|
+
bound: () => bundle != null
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
function defaultOpenUrl(url) {
|
|
2531
|
+
void import("child_process").then(({ spawn }) => {
|
|
2532
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
2533
|
+
try {
|
|
2534
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref();
|
|
2535
|
+
} catch {
|
|
2536
|
+
}
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2275
2540
|
// src/index.ts
|
|
2276
2541
|
async function main() {
|
|
2277
|
-
const
|
|
2542
|
+
const explicitPort = process.env.ATWEBPILOT_WS_PORT ? Number(process.env.ATWEBPILOT_WS_PORT) : void 0;
|
|
2278
2543
|
const token = process.env.ATWEBPILOT_WS_TOKEN || void 0;
|
|
2279
2544
|
const clock = new DefaultClock();
|
|
2280
2545
|
const idGen = new DefaultIdGen();
|
|
2281
|
-
const
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2546
|
+
const deps = createHubEnsurer({
|
|
2547
|
+
clock,
|
|
2548
|
+
idGen,
|
|
2549
|
+
explicitPort,
|
|
2550
|
+
token,
|
|
2551
|
+
openUrl: defaultOpenUrl
|
|
2552
|
+
});
|
|
2553
|
+
const server = createMcpServer(deps);
|
|
2286
2554
|
await server.connect(new StdioServerTransport());
|
|
2287
|
-
console.error(
|
|
2555
|
+
console.error("[atwebpilot-mcp] stdio MCP connected; ws port binds on first browser use");
|
|
2556
|
+
const shutdown = () => {
|
|
2557
|
+
const bundle = deps.peek();
|
|
2558
|
+
void (bundle?.hub.shutdown?.() ?? Promise.resolve()).finally(() => process.exit(0));
|
|
2559
|
+
};
|
|
2560
|
+
process.on("SIGTERM", shutdown);
|
|
2561
|
+
process.on("SIGINT", shutdown);
|
|
2562
|
+
process.stdin.on("close", shutdown);
|
|
2288
2563
|
}
|
|
2289
2564
|
main().catch((e) => {
|
|
2290
2565
|
console.error("[atwebpilot-mcp] fatal", e);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@attson/atwebpilot-mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.57",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "MCP server for the atwebpilot browser extension — let Claude Code drive your browser.",
|
|
6
6
|
"homepage": "https://github.com/attson/atwebpilot#readme",
|