@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.
Files changed (3) hide show
  1. package/README.md +23 -6
  2. package/dist/index.js +538 -263
  3. 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
- - `ATWEBPILOT_WS_PORT`(默认 8787):本地 ws 监听端口
12
- - `ATWEBPILOT_WS_TOKEN`(可选):扩展连接时要求 `bearer.<token>` 子协议
21
+ 可选环境变量:
13
22
 
14
- 然后[下载 release zip](https://github.com/attson/atwebpilot/releases/latest) 加载已解压扩展,
15
- 在扩展设置 → Coordinator 子页填 `ws://127.0.0.1:8787/worker` → 连接。新会话 Claude 调
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
- return this.sessions.open(input);
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) throw new Error("\u6CA1\u6709\u6D4F\u89C8\u5668\u8FDE\u5165\uFF0C\u8BF7\u5728\u6269\u5C55\u8BBE\u7F6E\u9875\u586B ws://127.0.0.1:<port>/worker \u8FDE\u63A5");
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 w = deps.coordinator.workers.get(singleWorkerId(deps.coordinator));
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 worker_id = singleWorkerId(deps.coordinator);
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 = deps.coordinator.openSession({ ai_client_fingerprint: "mcp-local", worker_id, tab_id, scope, idle_timeout_ms });
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.coordinator.closeSession(String(args.session_id));
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 q = deps.coordinator.quotaFor(String(args.session_id));
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 = deps.coordinator.sessions.get(session_id);
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 = deps.coordinator.validateCall({ session_id, kind: "runJS", unsafe: true });
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
- deps.coordinator.recordCall(session_id, v.dangerous);
2087
- const jsResult = await deps.hub.exec(session.worker_id, {
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 = deps.coordinator.validateCall({ session_id, kind: "capability", capability: override });
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
- deps.coordinator.recordCall(session_id, v.dangerous);
1929
+ coordinator.recordCall(session_id, v.dangerous);
2104
1930
  } else {
2105
1931
  const raw = toolArgs;
2106
- const v = deps.coordinator.validateCall({
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
- deps.coordinator.recordCall(session_id, v.dangerous);
1941
+ coordinator.recordCall(session_id, v.dangerous);
2116
1942
  }
2117
- const result = await deps.hub.exec(session.worker_id, { session_id, tab_id: session.tab_id, step: { kind: "tool", tool, args: toolArgs } });
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
- if (!deps) return void 0;
2190
- const workers = deps.coordinator.workers.list();
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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 port = Number(process.env.ATWEBPILOT_WS_PORT ?? 8787);
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 hub = new LoopbackWSHub({ port, token, clock, idGen });
2282
- await hub.ready();
2283
- const coordinator = new Coordinator({ hub, clock, idGen });
2284
- installWire(hub, coordinator, clock);
2285
- const server = createMcpServer({ coordinator, hub });
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(`[atwebpilot-mcp] ws://127.0.0.1:${port}/worker ready; stdio MCP connected`);
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.56",
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",