openclaw-navigator 5.0.2 → 5.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.
Files changed (3) hide show
  1. package/cli.mjs +279 -48
  2. package/mcp.mjs +69 -3
  3. package/package.json +1 -1
package/cli.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { spawn } from "node:child_process";
18
- import { randomUUID } from "node:crypto";
18
+ import { randomUUID, createHash } from "node:crypto";
19
19
  import { existsSync, appendFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
20
20
  import { createServer, request as httpRequest } from "node:http";
21
21
  import { connect as netConnect } from "node:net";
@@ -57,6 +57,24 @@ const recentEvents = [];
57
57
  const MAX_EVENTS = 200;
58
58
  const validTokens = new Set();
59
59
 
60
+ // ── Chat session state ────────────────────────────────────────────────────
61
+ const chatSessions = new Map(); // sessionKey → { messages: [...] }
62
+ const wsClients = new Set(); // connected WebSocket clients
63
+
64
+ function getChatSession(key = "main") {
65
+ if (!chatSessions.has(key)) {
66
+ chatSessions.set(key, { messages: [] });
67
+ }
68
+ return chatSessions.get(key);
69
+ }
70
+
71
+ function broadcastToWS(event) {
72
+ const data = JSON.stringify(event);
73
+ for (const ws of wsClients) {
74
+ try { ws.send(data); } catch {}
75
+ }
76
+ }
77
+
60
78
  // OC Web UI reverse proxy target (configurable via --ui-port or env)
61
79
  let ocUIPort = parseInt(process.env.OPENCLAW_UI_PORT ?? "4000", 10);
62
80
 
@@ -445,8 +463,9 @@ function handleRequest(req, res) {
445
463
  },
446
464
  routing: {
447
465
  "/ui/*": `localhost:${ocUIPort}`,
448
- "/api/*": `localhost:${ocGatewayPort}`,
449
- "/ws": `localhost:${ocGatewayPort}`,
466
+ "/api/sessions/*": "bridge (in-process chat)",
467
+ "/api/*": `localhost:${ocGatewayPort} (fallback)`,
468
+ "/ws": "bridge (in-process WebSocket)",
450
469
  },
451
470
  tunnel: activeTunnelURL
452
471
  ? {
@@ -905,8 +924,117 @@ function handleRequest(req, res) {
905
924
  return;
906
925
  }
907
926
 
927
+ // ── POST /api/sessions/send — Chat: receive user message ──
928
+ if (req.method === "POST" && path === "/api/sessions/send") {
929
+ readBody(req).then(raw => {
930
+ const body = JSON.parse(raw);
931
+ const sessionKey = body.sessionKey || "main";
932
+ const message = body.message;
933
+ if (!message) {
934
+ sendJSON(res, 400, { ok: false, error: "Missing 'message'" });
935
+ return;
936
+ }
937
+
938
+ const session = getChatSession(sessionKey);
939
+ const userMsg = {
940
+ role: "user",
941
+ content: message,
942
+ timestamp: Date.now(),
943
+ id: randomUUID(),
944
+ };
945
+ session.messages.push(userMsg);
946
+
947
+ // Also push as a bridge event so MCP tools can see it
948
+ recentEvents.push({
949
+ type: "chat.user_message",
950
+ sessionKey,
951
+ message,
952
+ messageId: userMsg.id,
953
+ timestamp: userMsg.timestamp,
954
+ });
955
+ if (recentEvents.length > MAX_EVENTS) recentEvents.shift();
956
+
957
+ // Broadcast to WebSocket clients
958
+ broadcastToWS({ type: "chat.user_message", sessionKey, message, messageId: userMsg.id });
959
+
960
+ sendJSON(res, 200, { ok: true, messageId: userMsg.id });
961
+ }).catch(() => {
962
+ sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
963
+ });
964
+ return;
965
+ }
966
+
967
+ // ── GET /api/sessions/history — Chat: fetch history ──
968
+ if (req.method === "GET" && path === "/api/sessions/history") {
969
+ const sessionKey = url.searchParams.get("sessionKey") || "main";
970
+ const session = getChatSession(sessionKey);
971
+ sendJSON(res, 200, { ok: true, messages: session.messages });
972
+ return;
973
+ }
974
+
975
+ // ── POST /api/sessions/respond — Chat: agent sends response ──
976
+ // Used by MCP tools to send agent responses to the chat
977
+ if (req.method === "POST" && path === "/api/sessions/respond") {
978
+ readBody(req).then(raw => {
979
+ const body = JSON.parse(raw);
980
+ const sessionKey = body.sessionKey || "main";
981
+ const content = body.content || body.message;
982
+ if (!content) {
983
+ sendJSON(res, 400, { ok: false, error: "Missing 'content'" });
984
+ return;
985
+ }
986
+
987
+ const session = getChatSession(sessionKey);
988
+ const assistantMsg = {
989
+ role: "assistant",
990
+ content,
991
+ timestamp: Date.now(),
992
+ id: randomUUID(),
993
+ };
994
+ session.messages.push(assistantMsg);
995
+
996
+ // Broadcast final response via WebSocket
997
+ broadcastToWS({
998
+ type: "chat.final",
999
+ text: content,
1000
+ content,
1001
+ runId: body.runId || null,
1002
+ sessionKey,
1003
+ });
1004
+
1005
+ sendJSON(res, 200, { ok: true, messageId: assistantMsg.id });
1006
+ }).catch(() => {
1007
+ sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
1008
+ });
1009
+ return;
1010
+ }
1011
+
1012
+ // ── POST /api/sessions/stream — Chat: agent streams partial text ──
1013
+ if (req.method === "POST" && path === "/api/sessions/stream") {
1014
+ readBody(req).then(raw => {
1015
+ const body = JSON.parse(raw);
1016
+ const text = body.text || body.delta || "";
1017
+ const runId = body.runId || null;
1018
+ const sessionKey = body.sessionKey || "main";
1019
+
1020
+ broadcastToWS({
1021
+ type: "chat.delta",
1022
+ text,
1023
+ delta: text,
1024
+ runId,
1025
+ sessionKey,
1026
+ });
1027
+
1028
+ sendJSON(res, 200, { ok: true });
1029
+ }).catch(() => {
1030
+ sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
1031
+ });
1032
+ return;
1033
+ }
1034
+
908
1035
  // ── Reverse proxy: /api/* → OC Gateway (localhost:ocGatewayPort) ─────
909
1036
  // Keeps /api/ prefix intact so /api/sessions/send → localhost:18789/api/sessions/send
1037
+ // This is a fallback — specific chat paths above are handled directly.
910
1038
  if (path.startsWith("/api/")) {
911
1039
  const targetURL = `${path}${url.search}`;
912
1040
 
@@ -1149,11 +1277,12 @@ ${BOLD}Stability (recommended for production):${RESET}
1149
1277
  --tunnel-hostname <host> Hostname for named tunnel (e.g. nav.yourdomain.com)
1150
1278
 
1151
1279
  ${BOLD}Routing (through Cloudflare tunnel):${RESET}
1152
- /ui/* → localhost:<ui-port> Web UI (login page, dashboard)
1153
- /api/* localhost:<gateway-port> Sessions API, chat endpoints
1154
- /ws, WebSocket localhost:<gateway-port> Real-time streaming, chat events
1155
- /health bridge itself Health check
1156
- /navigator/* → bridge itself Navigator control endpoints
1280
+ /ui/* → localhost:<ui-port> Web UI (login page, dashboard)
1281
+ /api/sessions/* bridge (in-process) Chat send/history/respond/stream
1282
+ /ws, WebSocket bridge (in-process) Real-time chat events
1283
+ /api/* (other) localhost:<gateway-port> Gateway API fallback
1284
+ /health → bridge itself Health check
1285
+ /navigator/* → bridge itself Navigator control endpoints
1157
1286
 
1158
1287
  ${BOLD}Environment variables:${RESET}
1159
1288
  OPENCLAW_UI_PORT=4000 Where the web UI runs
@@ -1239,49 +1368,142 @@ module.exports = {
1239
1368
  server.listen(port, bindHost, () => resolve());
1240
1369
  });
1241
1370
 
1242
- // ── WebSocket upgrade proxy OC Gateway (localhost:ocGatewayPort) ───
1243
- // Proxies WebSocket connections so Navigator can stream chat events
1371
+ // ── WebSocket handler manages chat connections directly ─────────────
1244
1372
  server.on("upgrade", (req, socket, head) => {
1245
1373
  const reqUrl = new URL(req.url ?? "/", "http://localhost");
1246
1374
  const reqPath = reqUrl.pathname;
1247
1375
 
1248
- // Only proxy WebSocket paths intended for the OC gateway
1249
- if (reqPath === "/ws" || reqPath.startsWith("/ws/") || reqPath.startsWith("/api/") || reqPath === "/") {
1250
- const targetPath = `${reqPath}${reqUrl.search}`;
1251
-
1252
- const proxy = netConnect(ocGatewayPort, "127.0.0.1", () => {
1253
- // Forward the original HTTP upgrade request over the TCP socket
1254
- const upgradeReq =
1255
- `${req.method} ${targetPath} HTTP/${req.httpVersion}\r\n` +
1256
- Object.entries(req.headers)
1257
- .map(([k, v]) => `${k}: ${v}`)
1258
- .join("\r\n") +
1259
- "\r\n\r\n";
1260
- proxy.write(upgradeReq);
1261
- if (head && head.length > 0) {
1262
- proxy.write(head);
1263
- }
1264
- // Pipe bidirectionally
1265
- socket.pipe(proxy);
1266
- proxy.pipe(socket);
1267
- });
1376
+ // Only handle /ws paths
1377
+ if (reqPath !== "/ws" && !reqPath.startsWith("/ws/")) {
1378
+ socket.destroy();
1379
+ return;
1380
+ }
1268
1381
 
1269
- proxy.on("error", (err) => {
1270
- console.log(` ${DIM}WebSocket proxy error: ${err.message}${RESET}`);
1382
+ // Perform WebSocket handshake
1383
+ const key = req.headers["sec-websocket-key"];
1384
+ if (!key) {
1385
+ socket.destroy();
1386
+ return;
1387
+ }
1388
+
1389
+ const accept = createHash("sha1")
1390
+ .update(key + "258EAFA5-E914-47DA-95CA-5AB5DC799C07")
1391
+ .digest("base64");
1392
+
1393
+ socket.write(
1394
+ "HTTP/1.1 101 Switching Protocols\r\n" +
1395
+ "Upgrade: websocket\r\n" +
1396
+ "Connection: Upgrade\r\n" +
1397
+ `Sec-WebSocket-Accept: ${accept}\r\n` +
1398
+ "\r\n"
1399
+ );
1400
+
1401
+ // Create a minimal WebSocket wrapper
1402
+ const ws = {
1403
+ socket,
1404
+ send(data) {
1405
+ const payload = Buffer.from(data);
1406
+ const frame = [];
1407
+ frame.push(0x81); // text frame, FIN bit
1408
+ if (payload.length < 126) {
1409
+ frame.push(payload.length);
1410
+ } else if (payload.length < 65536) {
1411
+ frame.push(126, (payload.length >> 8) & 0xFF, payload.length & 0xFF);
1412
+ } else {
1413
+ frame.push(127);
1414
+ for (let i = 7; i >= 0; i--) {
1415
+ frame.push((payload.length >> (i * 8)) & 0xFF);
1416
+ }
1417
+ }
1418
+ socket.write(Buffer.concat([Buffer.from(frame), payload]));
1419
+ },
1420
+ close() {
1421
+ wsClients.delete(ws);
1271
1422
  socket.destroy();
1272
- });
1423
+ }
1424
+ };
1273
1425
 
1274
- socket.on("error", () => {
1275
- proxy.destroy();
1276
- });
1426
+ wsClients.add(ws);
1427
+ console.log(` ${DIM}WebSocket client connected (${wsClients.size} active)${RESET}`);
1428
+
1429
+ // Send connected event
1430
+ ws.send(JSON.stringify({ type: "connected" }));
1431
+
1432
+ // Handle incoming WebSocket frames (simplified parser)
1433
+ let frameBuffer = Buffer.alloc(0);
1434
+ socket.on("data", (chunk) => {
1435
+ frameBuffer = Buffer.concat([frameBuffer, chunk]);
1436
+
1437
+ while (frameBuffer.length >= 2) {
1438
+ const firstByte = frameBuffer[0];
1439
+ const secondByte = frameBuffer[1];
1440
+ const opcode = firstByte & 0x0F;
1441
+ const masked = (secondByte & 0x80) !== 0;
1442
+ let payloadLen = secondByte & 0x7F;
1443
+ let offset = 2;
1444
+
1445
+ if (payloadLen === 126) {
1446
+ if (frameBuffer.length < 4) return;
1447
+ payloadLen = (frameBuffer[2] << 8) | frameBuffer[3];
1448
+ offset = 4;
1449
+ } else if (payloadLen === 127) {
1450
+ if (frameBuffer.length < 10) return;
1451
+ payloadLen = 0;
1452
+ for (let i = 0; i < 8; i++) {
1453
+ payloadLen = payloadLen * 256 + frameBuffer[2 + i];
1454
+ }
1455
+ offset = 10;
1456
+ }
1277
1457
 
1278
- socket.on("close", () => {
1279
- proxy.destroy();
1280
- });
1281
- } else {
1282
- // Not a proxied path — reject
1283
- socket.destroy();
1284
- }
1458
+ const maskSize = masked ? 4 : 0;
1459
+ const totalFrameLen = offset + maskSize + payloadLen;
1460
+ if (frameBuffer.length < totalFrameLen) return;
1461
+
1462
+ let payload;
1463
+ if (masked) {
1464
+ const mask = frameBuffer.slice(offset, offset + 4);
1465
+ payload = Buffer.alloc(payloadLen);
1466
+ for (let i = 0; i < payloadLen; i++) {
1467
+ payload[i] = frameBuffer[offset + 4 + i] ^ mask[i % 4];
1468
+ }
1469
+ } else {
1470
+ payload = frameBuffer.slice(offset, offset + payloadLen);
1471
+ }
1472
+
1473
+ // Handle by opcode
1474
+ if (opcode === 0x08) {
1475
+ // Close frame
1476
+ ws.close();
1477
+ return;
1478
+ } else if (opcode === 0x09) {
1479
+ // Ping — send pong
1480
+ const pongFrame = Buffer.from([0x8A, payload.length, ...payload]);
1481
+ socket.write(pongFrame);
1482
+ } else if (opcode === 0x01) {
1483
+ // Text frame — handle as message
1484
+ const text = payload.toString("utf8");
1485
+ try {
1486
+ const msg = JSON.parse(text);
1487
+ if (msg.type === "ping") {
1488
+ ws.send(JSON.stringify({ type: "pong" }));
1489
+ }
1490
+ } catch {
1491
+ // Non-JSON message, ignore
1492
+ }
1493
+ }
1494
+
1495
+ frameBuffer = frameBuffer.slice(totalFrameLen);
1496
+ }
1497
+ });
1498
+
1499
+ socket.on("close", () => {
1500
+ wsClients.delete(ws);
1501
+ console.log(` ${DIM}WebSocket client disconnected (${wsClients.size} active)${RESET}`);
1502
+ });
1503
+
1504
+ socket.on("error", () => {
1505
+ wsClients.delete(ws);
1506
+ });
1285
1507
  });
1286
1508
 
1287
1509
  ok(`Bridge server running on ${bindHost}:${port}`);
@@ -1492,9 +1714,10 @@ module.exports = {
1492
1714
  // ── Show OC Web UI access + routing info ─────────────────────────────
1493
1715
  const uiURL = tunnelURL ? `${tunnelURL}/ui/` : `http://localhost:${port}/ui/`;
1494
1716
  console.log(` ${BOLD}OC Web UI:${RESET} ${CYAN}${uiURL}${RESET}`);
1495
- info(` /ui/* → localhost:${ocUIPort} (web UI)`);
1496
- info(` /api/* localhost:${ocGatewayPort} (gateway API)`);
1497
- info(` /ws localhost:${ocGatewayPort} (WebSocket)`);
1717
+ info(` /ui/* → localhost:${ocUIPort} (web UI)`);
1718
+ info(` /api/sessions/* bridge (in-process chat)`);
1719
+ info(` /ws bridge (in-process WebSocket)`);
1720
+ info(` /api/* (other) → localhost:${ocGatewayPort} (gateway fallback)`);
1498
1721
 
1499
1722
  // ── Startup health checks ──────────────────────────────────────────
1500
1723
  console.log("");
@@ -1627,7 +1850,7 @@ module.exports = {
1627
1850
 
1628
1851
  mkdirSync(mcporterDir, { recursive: true });
1629
1852
  writeFileSync(mcporterConfigPath, JSON.stringify(mcporterConfig, null, 2) + "\n", "utf8");
1630
- ok("Registered MCP server with mcporter (31 tools: 16 browser + 10 AI + 5 profiling)");
1853
+ ok("Registered MCP server with mcporter (34 tools: 16 browser + 10 AI + 5 profiling + 3 chat)");
1631
1854
  info(` Config: ${mcporterConfigPath}`);
1632
1855
 
1633
1856
  // Step 3: Install the navigator-bridge skill so the OC agent knows about all tools
@@ -1672,7 +1895,7 @@ module.exports = {
1672
1895
  "mcporter call navigator.navigator_status",
1673
1896
  BT,
1674
1897
  "",
1675
- "## All 31 tools",
1898
+ "## All 34 tools",
1676
1899
  "",
1677
1900
  "### Browser Control (16 tools)",
1678
1901
  "",
@@ -1720,6 +1943,14 @@ module.exports = {
1720
1943
  "| `navigator_get_user_profile` | Get aggregated user interest profile |",
1721
1944
  "| `navigator_save_user_profile` | Save/update user profile from browsing patterns |",
1722
1945
  "",
1946
+ "### Chat (3 tools)",
1947
+ "",
1948
+ "| Tool | What it does |",
1949
+ "|------|-------------|",
1950
+ "| `navigator_get_chat_messages` | Get recent messages from the Navigator chat pane |",
1951
+ "| `navigator_chat_respond` | Send a response message to the Navigator chat pane |",
1952
+ "| `navigator_chat_stream` | Stream partial text to the chat pane (typing effect) |",
1953
+ "",
1723
1954
  "## Usage",
1724
1955
  "",
1725
1956
  BT + "bash",
package/mcp.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * openclaw-navigator MCP server v5.0.1
4
+ * openclaw-navigator MCP server v5.1.0
5
5
  *
6
6
  * Exposes the Navigator bridge HTTP API as MCP tools so the OpenClaw agent
7
7
  * can control the browser natively via its tool schema.
@@ -204,7 +204,7 @@ async function sendCommand(command, payload, poll) {
204
204
  };
205
205
  }
206
206
 
207
- // ── Tool definitions (31 tools: 16 browser + 10 AI intelligence + 5 profiling) ────────────────
207
+ // ── Tool definitions (34 tools: 16 browser + 10 AI intelligence + 5 profiling + 3 chat) ────────────────
208
208
 
209
209
  const TOOLS = [
210
210
  // ── Direct HTTP ──
@@ -596,6 +596,44 @@ const TOOLS = [
596
596
  required: ["profile"],
597
597
  },
598
598
  },
599
+
600
+ // ── Chat Tools ──
601
+ {
602
+ name: "navigator_get_chat_messages",
603
+ description: "Get recent chat messages from the Navigator chat pane. Use this to see what the user is asking.",
604
+ inputSchema: {
605
+ type: "object",
606
+ properties: {
607
+ sessionKey: { type: "string", description: "Chat session key (default: main)", default: "main" },
608
+ limit: { type: "number", description: "Max messages to return (default: 20)", default: 20 },
609
+ },
610
+ },
611
+ },
612
+ {
613
+ name: "navigator_chat_respond",
614
+ description: "Send a response message to the Navigator chat pane. The user will see this as an agent message.",
615
+ inputSchema: {
616
+ type: "object",
617
+ properties: {
618
+ message: { type: "string", description: "The response message to send" },
619
+ sessionKey: { type: "string", description: "Chat session key (default: main)", default: "main" },
620
+ },
621
+ required: ["message"],
622
+ },
623
+ },
624
+ {
625
+ name: "navigator_chat_stream",
626
+ description: "Stream a partial response to the Navigator chat pane (for real-time typing effect).",
627
+ inputSchema: {
628
+ type: "object",
629
+ properties: {
630
+ text: { type: "string", description: "Partial text to stream" },
631
+ sessionKey: { type: "string", description: "Chat session key (default: main)", default: "main" },
632
+ runId: { type: "string", description: "Run ID for grouping streamed chunks" },
633
+ },
634
+ required: ["text"],
635
+ },
636
+ },
599
637
  ];
600
638
 
601
639
  // ── Tool handler dispatch ─────────────────────────────────────────────────
@@ -823,6 +861,34 @@ async function handleTool(name, args) {
823
861
  }),
824
862
  );
825
863
 
864
+ // ── Chat Tools ──
865
+ case "navigator_get_chat_messages": {
866
+ const sessionKey = args.sessionKey || "main";
867
+ const limit = args.limit ?? 20;
868
+ const data = await bridgeGet(`/api/sessions/history?sessionKey=${encodeURIComponent(sessionKey)}`);
869
+ if (data.ok && Array.isArray(data.messages)) {
870
+ return jsonResult({ ok: true, messages: data.messages.slice(-limit) });
871
+ }
872
+ return jsonResult(data);
873
+ }
874
+
875
+ case "navigator_chat_respond":
876
+ return jsonResult(
877
+ await bridgePost("/api/sessions/respond", {
878
+ content: args.message,
879
+ sessionKey: args.sessionKey || "main",
880
+ }),
881
+ );
882
+
883
+ case "navigator_chat_stream":
884
+ return jsonResult(
885
+ await bridgePost("/api/sessions/stream", {
886
+ text: args.text,
887
+ sessionKey: args.sessionKey || "main",
888
+ runId: args.runId || null,
889
+ }),
890
+ );
891
+
826
892
  default:
827
893
  return errorResult(`Unknown tool: ${name}`);
828
894
  }
@@ -835,7 +901,7 @@ async function handleTool(name, args) {
835
901
  // ── MCP server wiring ─────────────────────────────────────────────────────
836
902
 
837
903
  const server = new Server(
838
- { name: "openclaw-navigator", version: "5.0.0" },
904
+ { name: "openclaw-navigator", version: "5.1.0" },
839
905
  { capabilities: { tools: {} } },
840
906
  );
841
907
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-navigator",
3
- "version": "5.0.2",
3
+ "version": "5.1.0",
4
4
  "description": "One-command bridge + tunnel for the Navigator browser — works on any machine, any OS",
5
5
  "keywords": [
6
6
  "browser",