EXTENSION for treeos-connect
browser-bridge
The AI sees and acts through your browser. A Chrome extension connects via Socket.IO. The AI gets accessibility trees, clicks elements, types text, navigates pages, takes screenshots. Confined scope: inactive everywhere until explicitly allowed per branch. Write actions require operator approval unless auto-approved by site scoping. Read actions (page state, extract, screenshot) are always allowed. Every browser action is logged as a note. The most powerful and most dangerous extension in the ecosystem. All safety layers active by default.
v1.0.3 by TreeOS Site 0 downloads 12 files 2,665 lines 136.6 KB published 38d ago
treeos ext install browser-bridge
View changelog

Manifest

Provides

  • tools

Requires

  • services: hooks, websocket, metadata, modes
  • models: Node

Optional

  • extensions: approve, kb, study, api-keys
SHA256: 3126992daf627ee3a351d32c382abb40bcf38024dcfa16af40a7cfb32525c8b8

Dependents

1 package depend on this

PackageTypeRelationship
treeos-connect v1.0.3bundleincludes

Hooks

Listens To

  • beforeToolCall
  • enrichContext

Source Code

1/**
2 * Browser Bridge Core
3 *
4 * Manages WebSocket connections to Chrome extensions.
5 * Request/response correlation. Site scoping. Activity logging.
6 */
7
8import { v4 as uuidv4 } from "uuid";
9import log from "../../seed/log.js";
10import { createNote } from "../../seed/tree/notes.js";
11
12let _metadata = null;
13let _Node = null;
14
15export function configure({ metadata, Node }) {
16  _metadata = metadata;
17  _Node = Node;
18}
19
20// Active browser connections: userId -> socket
21const _connections = new Map();
22
23// Pending requests: requestId -> { resolve, reject, timer }
24const _pending = new Map();
25
26// Last known URL per user
27const _currentUrls = new Map();
28
29const REQUEST_TIMEOUT_MS = 15000;
30
31// ─────────────────────────────────────────────────────────────────────────
32// CONNECTION MANAGEMENT
33// ─────────────────────────────────────────────────────────────────────────
34
35export function registerConnection(userId, socket) {
36  // Disconnect previous browser connection for this user (if any)
37  const existing = _connections.get(userId);
38  if (existing && existing.id !== socket.id) {
39    existing.emit("browserDisconnected", { reason: "new connection" });
40  }
41  _connections.set(userId, socket);
42  log.info("BrowserBridge", `Browser connected for user ${userId}`);
43
44  socket.on("disconnect", () => {
45    if (_connections.get(userId)?.id === socket.id) {
46      _connections.delete(userId);
47      _currentUrls.delete(userId);
48      // Reject all pending requests for this user
49      for (const [id, pending] of _pending) {
50        if (pending.userId === userId) {
51          clearTimeout(pending.timer);
52          pending.reject(new Error("Browser disconnected"));
53          _pending.delete(id);
54        }
55      }
56      log.info("BrowserBridge", `Browser disconnected for user ${userId}`);
57    }
58  });
59}
60
61export function isConnected(userId) {
62  return _connections.has(userId);
63}
64
65export function getCurrentUrl(userId) {
66  return _currentUrls.get(userId) || null;
67}
68
69export function setCurrentUrl(userId, url) {
70  _currentUrls.set(userId, url);
71}
72
73// ─────────────────────────────────────────────────────────────────────────
74// REQUEST / RESPONSE
75// ─────────────────────────────────────────────────────────────────────────
76
77/**
78 * Send a request to the Chrome extension and wait for a response.
79 * Returns a Promise that resolves with the response data.
80 */
81export function sendRequest(userId, event, data = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
82  const socket = _connections.get(userId);
83  if (!socket) {
84    return Promise.reject(new Error("No browser connected. Install the TreeOS Chrome extension and connect it."));
85  }
86
87  const requestId = uuidv4();
88  return new Promise((resolve, reject) => {
89    const timer = setTimeout(() => {
90      _pending.delete(requestId);
91      reject(new Error("Browser request timed out (15s). The page may be unresponsive."));
92    }, timeoutMs);
93
94    _pending.set(requestId, { resolve, reject, timer, userId });
95    socket.emit(event, { ...data, requestId });
96  });
97}
98
99/**
100 * Resolve a pending request when the Chrome extension responds.
101 */
102export function resolveRequest(requestId, data) {
103  const pending = _pending.get(requestId);
104  if (!pending) return false;
105  clearTimeout(pending.timer);
106  _pending.delete(requestId);
107  pending.resolve(data);
108  return true;
109}
110
111// ─────────────────────────────────────────────────────────────────────────
112// SITE SCOPING
113// ─────────────────────────────────────────────────────────────────────────
114
115/**
116 * Check if the AI can access a URL from this node position.
117 * Walks ancestor metadata for browserBridge.autoApprove/alwaysAsk/blocked.
118 */
119export async function checkSiteAccess(nodeId, url) {
120  if (!url) return { allowed: true, needsApproval: true, blocked: false, reason: "no URL" };
121
122  let domain;
123  try {
124    domain = new URL(url).hostname;
125  } catch {
126    return { allowed: false, blocked: true, reason: "invalid URL" };
127  }
128
129  // Walk ancestor chain for site scoping config
130  const config = await collectSiteConfig(nodeId);
131
132  // Check blocked first
133  if (matchesDomain(domain, config.blocked)) {
134    return { allowed: false, blocked: true, reason: `${domain} is blocked at this position` };
135  }
136
137  // Check auto-approve
138  if (matchesDomain(domain, config.autoApprove)) {
139    return { allowed: true, needsApproval: false, blocked: false, reason: "auto-approved" };
140  }
141
142  // Check always-ask
143  if (matchesDomain(domain, config.alwaysAsk)) {
144    return { allowed: true, needsApproval: true, blocked: false, reason: "requires approval" };
145  }
146
147  // Default: allowed but needs approval
148  return { allowed: true, needsApproval: true, blocked: false, reason: "default: requires approval" };
149}
150
151async function collectSiteConfig(nodeId) {
152  const config = { autoApprove: [], alwaysAsk: [], blocked: [] };
153  if (!_Node) return config;
154
155  let current = await _Node.findById(nodeId).select("parent metadata").lean();
156  let depth = 0;
157
158  while (current && depth < 20) {
159    const meta = current.metadata instanceof Map
160      ? current.metadata.get("browser-bridge")
161      : current.metadata?.["browser-bridge"];
162
163    if (meta) {
164      if (Array.isArray(meta.autoApprove)) config.autoApprove.push(...meta.autoApprove);
165      if (Array.isArray(meta.alwaysAsk)) config.alwaysAsk.push(...meta.alwaysAsk);
166      if (Array.isArray(meta.blocked)) config.blocked.push(...meta.blocked);
167    }
168
169    if (!current.parent) break;
170    current = await _Node.findById(current.parent).select("parent metadata").lean();
171    depth++;
172  }
173
174  return config;
175}
176
177function matchesDomain(domain, patterns) {
178  if (!patterns || patterns.length === 0) return false;
179  const lower = domain.toLowerCase();
180  for (const pattern of patterns) {
181    const p = pattern.toLowerCase();
182    if (p === lower) return true;
183    if (p.startsWith("*.") && lower.endsWith(p.slice(1))) return true;
184    // Match with or without www
185    if (lower === "www." + p || p === "www." + lower) return true;
186  }
187  return false;
188}
189
190// ─────────────────────────────────────────────────────────────────────────
191// ACTIVITY LOGGING
192// ─────────────────────────────────────────────────────────────────────────
193
194/**
195 * Log a browser action as a note on the node.
196 */
197export async function logAction(nodeId, userId, action, url, result) {
198  try {
199    const summary = `[browser] ${action.type || action}${url ? " on " + url : ""}`;
200    const detail = typeof result === "string" ? result : (result?.success ? "succeeded" : "failed");
201    await createNote({
202      contentType: "text",
203      content: `${summary}: ${detail}`,
204      userId: userId || "SYSTEM",
205      nodeId,
206      wasAi: true,
207    });
208  } catch (err) {
209    log.debug("BrowserBridge", `Activity log failed: ${err.message}`);
210  }
211}
212
1/**
2 * Browser Bridge
3 *
4 * The AI sees and acts through the user's browser.
5 * Chrome extension connects via Socket.IO. Server-side tools bridge AI calls
6 * to browser actions. Confined scope. Site-scoped. Approve-gated. Logged.
7 */
8
9import log from "../../seed/log.js";
10import User from "../../seed/models/user.js";
11import {
12  configure,
13  registerConnection,
14  resolveRequest,
15  setCurrentUrl,
16  isConnected,
17  sendRequest,
18  checkSiteAccess,
19  getCurrentUrl,
20} from "./core.js";
21import getTools from "./tools.js";
22
23const WRITE_TOOLS = new Set([
24  "browser-click",
25  "browser-type",
26  "browser-comment",
27]);
28
29export async function init(core) {
30  configure({
31    metadata: core.metadata,
32    Node: core.models.Node,
33  });
34
35  // ── Socket.IO event handlers for Chrome extension ──────────────────
36
37  // Auth: Chrome extension sends API key after connecting
38  core.websocket.registerSocketHandler("browserAuth", async ({ socket, data }) => {
39    try {
40      const apiKey = data?.apiKey;
41      if (!apiKey && !(data.username && data.password)) {
42        socket.emit("browserAuthResult", { success: false, error: "API key or username + password required" });
43        return;
44      }
45
46      let userId = null;
47      const { username, password } = data;
48
49      // Method 1: API key (if api-keys extension data exists)
50      if (apiKey) {
51        try {
52          // User imported at top of file
53          const { getUserMeta } = await import("../../seed/tree/userMetadata.js");
54          const { default: bcryptMod } = await import("bcrypt");
55
56          const prefix = apiKey.slice(0, 8);
57          log.debug("BrowserBridge", `API key auth attempt, prefix: ${prefix}`);
58          const candidates = await User.find({
59            "metadata.apiKeys": {
60              $elemMatch: { keyPrefix: prefix, revoked: { $ne: true } },
61            },
62          }).select("_id username metadata");
63
64          log.debug("BrowserBridge", `Found ${candidates.length} candidates for prefix ${prefix}`);
65
66          for (const user of candidates) {
67            const keys = getUserMeta(user, "apiKeys");
68            if (!Array.isArray(keys)) continue;
69            for (const key of keys) {
70              if (key.revoked || key.keyPrefix !== prefix) continue;
71              const match = await bcryptMod.compare(apiKey, key.keyHash);
72              if (match) {
73                userId = String(user._id);
74                socket.username = user.username;
75                break;
76              }
77            }
78            if (userId) break;
79          }
80        } catch (err) {
81          log.warn("BrowserBridge", `API key auth error: ${err.message}`);
82        }
83      }
84
85      // Method 2: username + password (always available, no extension needed)
86      if (!userId && username && password) {
87        try {
88          // User imported at top of file
89          const { default: bcryptMod } = await import("bcrypt");
90          log.debug("BrowserBridge", `Password auth attempt for user: ${username}`);
91          const user = await User.findOne({ username }).select("_id username password");
92          if (user) {
93            const match = await bcryptMod.compare(password, user.password);
94            log.debug("BrowserBridge", `Password match for ${username}: ${match}`);
95            if (match) {
96              userId = String(user._id);
97              socket.username = user.username;
98            }
99          } else {
100            log.debug("BrowserBridge", `User not found: ${username}`);
101          }
102        } catch (err) {
103          log.warn("BrowserBridge", `Password auth error: ${err.message}`);
104        }
105      }
106
107      if (!userId) {
108        socket.emit("browserAuthResult", { success: false, error: "Invalid API key" });
109        return;
110      }
111
112      // Set userId on socket (doesn't affect authSessions since it was null on connect)
113      socket.userId = userId;
114      socket._browserBridge = true;
115
116      registerConnection(userId, socket);
117      socket.emit("browserAuthResult", { success: true });
118    } catch (err) {
119      socket.emit("browserAuthResult", { success: false, error: err.message });
120    }
121  });
122
123  // Response handlers: resolve pending requests from AI tool calls
124  const responseEvents = ["pageState", "actionResult", "screenshot", "networkLog", "tabsList"];
125  for (const event of responseEvents) {
126    core.websocket.registerSocketHandler(event, ({ data }) => {
127      if (data?.requestId) {
128        resolveRequest(data.requestId, data.data || data);
129      }
130    });
131  }
132
133  // Unprompted: user navigated to a new page
134  core.websocket.registerSocketHandler("pageNavigated", ({ socket, data }) => {
135    const userId = socket.userId;
136    if (!userId || !socket._browserBridge) return;
137    if (data?.url) {
138      setCurrentUrl(userId, data.url);
139      log.verbose("BrowserBridge", `${userId} navigated to ${data.url}`);
140    }
141  });
142
143  // ── beforeToolCall: site scoping guard ─────────────────────────────
144
145  core.hooks.register("beforeToolCall", async (hookData) => {
146    const { toolName, args, userId } = hookData;
147    if (!WRITE_TOOLS.has(toolName)) return;
148
149    const nodeId = args?.nodeId;
150    if (!nodeId) return;
151
152    const url = toolName === "browser-navigate" ? args?.url : getCurrentUrl(userId);
153    if (!url) return;
154
155    const access = await checkSiteAccess(nodeId, url);
156    if (access.blocked) {
157      hookData.cancelled = true;
158      hookData.reason = `Browser action blocked: ${access.reason}`;
159    }
160  }, "browser-bridge");
161
162  // ── afterScopeChange: auto-set browser-agent mode when allowed ──────
163  core.hooks.register("afterScopeChange", async ({ nodeId, allowed }) => {
164    if (!allowed || !Array.isArray(allowed)) return;
165    if (!allowed.includes("browser-bridge")) return;
166    try {
167      const { setExtMeta } = await import("../../seed/tree/extensionMetadata.js");
168      const node = await core.models.Node.findById(nodeId);
169      if (!node) return;
170      const modes = node.metadata instanceof Map ? node.metadata.get("modes") : node.metadata?.modes;
171      // Only set if no respond mode already set
172      if (!modes?.respond) {
173        await setExtMeta(node, "modes", { ...(modes || {}), respond: "tree:browser-agent" });
174        log.info("BrowserBridge", `Auto-set browser-agent mode on ${nodeId}`);
175      }
176    } catch {}
177  }, "browser-bridge");
178
179  // ── enrichContext: tell AI browser is available ─────────────────────
180
181  core.hooks.register("enrichContext", async ({ context, node, meta }) => {
182    const bbMeta = meta?.["browser-bridge"];
183    // Only inject if the extension is active at this position
184    // (spatial scoping already filters hooks, so if we're here, it's allowed)
185    context.browserBridge = {
186      available: true,
187      note: "BROWSER BRIDGE IS ACTIVE. You control the user's REAL browser. " +
188        "To interact with websites use BROWSER tools, NOT tree tools. " +
189        "browser-read = see the actual webpage content and elements. " +
190        "browser-click = click a real element on the website. " +
191        "browser-type = type into a real input on the website. " +
192        "browser-navigate = go to a real URL. " +
193        "browser-comment = post a comment/reply on social sites (handles the full flow automatically). " +
194        "Tree tools (create-node, create-note) only affect the tree, NOT websites. " +
195        "To post a comment or reply, use browser-comment with the text. " +
196        "Always call browser-read first to understand the page.",
197    };
198    if (bbMeta?.autoApprove?.length) {
199      context.browserBridge.autoApprovedSites = bbMeta.autoApprove;
200    }
201    if (bbMeta?.blocked?.length) {
202      context.browserBridge.blockedSites = bbMeta.blocked;
203    }
204  }, "browser-bridge");
205
206  // Register browser agent mode
207  const { default: agentMode } = await import("./modes/agent.js");
208  core.modes.registerMode("tree:browser-agent", agentMode, "browser-bridge");
209
210  log.info("BrowserBridge", "Loaded. Confined. The AI can see and act through the browser.");
211
212  return {
213    tools: getTools(),
214    modeTools: [
215      { modeKey: "tree:browser-agent", toolNames: ["browser-read", "browser-click", "browser-type", "browser-navigate", "browser-comment"] },
216      { modeKey: "tree:respond", toolNames: ["browser-read", "browser-click", "browser-type", "browser-navigate", "browser-comment"] },
217      { modeKey: "tree:librarian", toolNames: ["browser-read", "browser-click", "browser-type", "browser-navigate", "browser-comment"] },
218    ],
219    exports: {
220      isConnected,
221      sendRequest,
222      checkSiteAccess,
223      getCurrentUrl,
224    },
225  };
226}
227
1export default {
2  name: "browser-bridge",
3  version: "1.0.3",
4  scope: "confined",
5  builtFor: "treeos-connect",
6  description:
7    "The AI sees and acts through your browser. A Chrome extension connects via Socket.IO. " +
8    "The AI gets accessibility trees, clicks elements, types text, navigates pages, takes screenshots. " +
9    "Confined scope: inactive everywhere until explicitly allowed per branch. " +
10    "Write actions require operator approval unless auto-approved by site scoping. " +
11    "Read actions (page state, extract, screenshot) are always allowed. " +
12    "Every browser action is logged as a note. The most powerful and most dangerous extension " +
13    "in the ecosystem. All safety layers active by default.",
14
15  territory: "reading and interacting with web pages through the browser",
16  classifierHints: [
17    /\b(click|type|navigate|browse|open|visit|go to|read.*page|what.*page|this site|this page|webpage|website)\b/i,
18    /\b(post|comment|reply|submit|login|sign in|search.*web|fill.*form|enter.*field)\b/i,
19    /\b(browser|tab|screen|what do you see|what's on)\b/i,
20  ],
21
22  guidedMode: "tree:browser-agent",
23
24  needs: {
25    services: ["hooks", "websocket", "metadata", "modes"],
26    models: ["Node"],
27  },
28
29  optional: {
30    extensions: ["approve", "kb", "study", "api-keys"],
31  },
32
33  provides: {
34    models: {},
35    routes: false,
36    tools: true,
37    jobs: false,
38    orchestrator: false,
39    energyActions: {},
40    sessionTypes: {},
41    env: [],
42    cli: [],
43
44    hooks: {
45      fires: [],
46      listens: ["beforeToolCall", "enrichContext"],
47    },
48  },
49};
50
1export default {
2  name: "tree:browser-agent",
3  emoji: "🌐",
4  label: "Browser Agent",
5  bigMode: "tree",
6  hidden: true,
7
8  toolNames: [
9    "browser-read",
10    "browser-click",
11    "browser-type",
12    "browser-navigate",
13    "browser-comment",
14    "browser-fetch",
15  ],
16
17  maxMessagesBeforeLoop: 20,
18  preserveContextOnLoop: true,
19
20  buildSystemPrompt({ username }) {
21    return `You are a browser agent for ${username}. You control their real Chrome browser.
22
23YOUR TOOLS:
24- browser-read: See the current page. Returns URL, title, text content, and interactive elements with IDs (e1, e2, etc.)
25- browser-click: Click an element by its ID from browser-read
26- browser-type: Type text into an input field by its ID from browser-read
27- browser-navigate: Go to a URL
28
29WORKFLOW:
301. ALWAYS call browser-read first to see what's on the page
312. Find the element ID you need from the results
323. Use browser-click or browser-type with that ID
334. Call browser-read again to verify the result
34
35RULES:
36- You act, you don't just describe. When the user says "click X", you click it.
37- When the user says "reply with hi", use browser-comment with the text "hi". It handles the full flow.
38- Never say you can't interact with websites. You can. Use your tools.
39- If an element ID doesn't work, call browser-read again to get fresh IDs.
40- Report what you did briefly after acting.`.trim();
41  },
42};
43
1/**
2 * Browser Bridge Tools
3 *
4 * 7 MCP tools the AI can call to interact with the user's browser.
5 * Read tools: always allowed. Write tools: site-scoped, approve-gated.
6 */
7
8import { z } from "zod";
9import { sendRequest, isConnected, getCurrentUrl, checkSiteAccess, logAction } from "./core.js";
10
11function text(str) {
12  return { content: [{ type: "text", text: String(str) }] };
13}
14
15function json(data) {
16  const str = typeof data === "string" ? data : JSON.stringify(data, null, 2);
17  return text(truncate(str));
18}
19
20// Cap tool results to prevent token overload
21const MAX_RESULT_CHARS = 6000;
22
23function truncate(str) {
24  if (typeof str !== "string") str = JSON.stringify(str, null, 2);
25  if (str.length > MAX_RESULT_CHARS) {
26    return str.slice(0, MAX_RESULT_CHARS) + "\n\n[truncated, " + str.length + " total chars. Page is large. Use browser-extract for text content or ask about specific elements.]";
27  }
28  return str;
29}
30
31function requireBrowser(userId) {
32  if (!isConnected(userId)) {
33    throw new Error("No browser connected. The user needs to install the TreeOS Chrome extension and connect it.");
34  }
35}
36
37export default function getTools() {
38  return [
39    // ── READ TOOLS ──────────────────────────────────────────────────
40
41    {
42      name: "browser-read",
43      description:
44        "Read the current page in the user's browser. Returns the page URL, title, text content, " +
45        "and interactive elements with IDs (e1, e2, etc.) for clicking and typing. " +
46        "Call this first to understand what's on the page before taking any action.",
47      annotations: { readOnlyHint: true },
48      schema: {
49        userId: z.string().describe("Injected by server. Ignore."),
50      },
51      handler: async ({ userId }) => {
52        requireBrowser(userId);
53
54        // Get page state (accessibility tree + metadata)
55        const stateResult = await sendRequest(userId, "getPageState", {});
56        const state = stateResult?.data || stateResult;
57
58        // Get text content
59        let pageText = "";
60        try {
61          const extractResult = await sendRequest(userId, "executeAction", { action: { type: "extract" } });
62          const content = extractResult?.data?.text || extractResult?.text || extractResult?.data || extractResult;
63          pageText = typeof content === "string" ? content : JSON.stringify(content);
64        } catch {}
65
66        // Extract only useful interactive elements
67        // Skip: unnamed spans, unnamed links, sidebar/footer noise
68        const SKIP_ROLES = new Set(["presentation", "img", "complementary", "contentinfo", "navigation"]);
69        const interactiveElements = [];
70        function walkTree(nodes) {
71          if (!nodes) return;
72          const list = Array.isArray(nodes) ? nodes : [nodes];
73          for (const node of list) {
74            if (node.id && !SKIP_ROLES.has(node.role)) {
75              // Only include elements with names, or key roles (button, textbox, link with name)
76              const hasName = node.name && node.name.trim().length > 0;
77              const isAction = node.role === "button" || node.role === "textbox" || node.role === "searchbox" || node.role === "combobox";
78              if (hasName || isAction) {
79                interactiveElements.push({
80                  id: node.id,
81                  role: node.role,
82                  name: node.name ? node.name.slice(0, 60) : undefined,
83                });
84              }
85            }
86            if (node.children) walkTree(node.children);
87          }
88        }
89        walkTree(state?.tree);
90
91        // Cap at 40 elements max
92        const elements = interactiveElements.slice(0, 40);
93
94        // Put interactive elements FIRST (AI needs these to act), text SECOND (gets truncated first)
95        const combined = {
96          url: state?.url || "unknown",
97          title: state?.title || "unknown",
98          elements,
99          pageText: pageText.slice(0, 2000),
100        };
101
102        return json(combined);
103      },
104    },
105
106
107    // ── WRITE TOOLS ─────────────────────────────────────────────────
108
109    {
110      name: "browser-click",
111      description:
112        "Click an element in the user's browser. Use the element ID from browser-get-state (e.g. 'e5'). " +
113        "Always call browser-get-state first to see available elements and their IDs.",
114      annotations: { readOnlyHint: false, destructiveHint: true },
115      schema: {
116        elementId: z.string().describe("Element ID from the accessibility tree (e.g. 'e5')"),
117        userId: z.string().describe("Injected by server. Ignore."),
118      },
119      handler: async ({ elementId, nodeId, userId }) => {
120        requireBrowser(userId);
121        const result = await sendRequest(userId, "executeAction", {
122          action: { type: "click", elementId },
123        });
124        logAction(nodeId, userId, { type: "click", elementId }, getCurrentUrl(userId), result).catch(() => {});
125        return json(result);
126      },
127    },
128
129    {
130      name: "browser-type",
131      description:
132        "Type text into an input field in the user's browser. Use the element ID from browser-get-state. " +
133        "The element must be a text input, textarea, or contenteditable element.",
134      annotations: { readOnlyHint: false, destructiveHint: true },
135      schema: {
136        elementId: z.string().describe("Element ID of the input field (e.g. 'e12')"),
137        text: z.string().describe("Text to type into the field"),
138        userId: z.string().describe("Injected by server. Ignore."),
139      },
140      handler: async ({ elementId, text, nodeId, userId }) => {
141        requireBrowser(userId);
142        const result = await sendRequest(userId, "executeAction", {
143          action: { type: "type", elementId, text },
144        });
145        logAction(nodeId, userId, { type: "type", elementId }, getCurrentUrl(userId), result).catch(() => {});
146        return json(result);
147      },
148    },
149
150    {
151      name: "browser-navigate",
152      description:
153        "Navigate the user's browser to a URL. Use for opening documentation, websites, or web apps. " +
154        "The URL must be allowed by the site scoping configuration at this tree position.",
155      annotations: { readOnlyHint: false, destructiveHint: true },
156      schema: {
157        url: z.string().describe("The URL to navigate to"),
158        userId: z.string().describe("Injected by server. Ignore."),
159      },
160      handler: async ({ url, nodeId, userId }) => {
161        requireBrowser(userId);
162        // Check site access against the TARGET url specifically
163        const access = await checkSiteAccess(nodeId, url);
164        if (access.blocked) {
165          return text(`Blocked: ${access.reason}. This site is not allowed at this tree position.`);
166        }
167        const result = await sendRequest(userId, "executeAction", {
168          action: { type: "navigate", url },
169        });
170        logAction(nodeId, userId, { type: "navigate", url }, url, result).catch(() => {});
171        return json(result);
172      },
173    },
174
175    {
176      name: "browser-comment",
177      description:
178        "Post a comment or reply on the current page. Works on Reddit, forums, and social sites. " +
179        "Automatically finds the reply button, clicks it, types your text, and submits. " +
180        "One tool call does the full flow. No need to manually find buttons.",
181      annotations: { readOnlyHint: false, destructiveHint: true },
182      schema: {
183        commentText: z.string().describe("The text to post as a comment or reply"),
184        replyTo: z.string().optional().describe("Username to reply to (e.g. 'u/someone'). If omitted, posts to the main post."),
185        userId: z.string().describe("Injected by server. Ignore."),
186      },
187      handler: async ({ commentText, replyTo, userId }) => {
188        requireBrowser(userId);
189        const result = await sendRequest(userId, "executeAction", {
190          action: { type: "comment", text: commentText, replyTo: replyTo || null },
191        });
192        const res = result?.data || result;
193        if (res?.success && res?.submitted) {
194          return text(`Comment posted: "${commentText}"`);
195        } else if (res?.typed && !res?.submitted) {
196          return text(`Typed "${commentText}" but could not find submit button. Click it manually.`);
197        }
198        return text(`Failed to post comment: ${res?.error || "unknown error"}`);
199      },
200    },
201
202    {
203      name: "browser-fetch",
204      description:
205        "Fetch a URL and return the raw response. Works with JSON APIs, RSS feeds, or any URL. " +
206        "Server-side fetch, no browser needed. Use this for structured data instead of browser-read.",
207      annotations: { readOnlyHint: true },
208      schema: {
209        url: z.string().describe("URL to fetch"),
210        userId: z.string().describe("Injected by server. Ignore."),
211      },
212      handler: async ({ url, userId }) => {
213        if (!url) return text("URL required.");
214
215        try {
216          const controller = new AbortController();
217          const timeout = setTimeout(() => controller.abort(), 15000);
218
219          const response = await fetch(url, {
220            signal: controller.signal,
221            headers: {
222              "User-Agent": "TreeOS/1.0",
223              "Accept": "application/json, text/html, */*",
224            },
225          });
226          clearTimeout(timeout);
227
228          const contentType = response.headers.get("content-type") || "";
229          const body = await response.text();
230
231          // Cap at 16KB
232          const capped = body.length > 16384
233            ? body.slice(0, 16384) + "\n... (truncated)"
234            : body;
235
236          return text(`[${response.status}] ${contentType}\n\n${capped}`);
237        } catch (err) {
238          return text(`Fetch failed: ${err.message}`);
239        }
240      },
241    },
242
243  ];
244}
245
1# TreeOS Browser Bridge
2
3A Chrome extension that gives your TreeOS AI eyes and hands in the browser. The AI can read pages, click elements, type text, navigate URLs, and post comments. All through the same chat interface.
4
5## Setup
6
7### 1. Install the Chrome Extension
8
91. Open `chrome://extensions` in Chrome
102. Enable **Developer mode** (top right toggle)
113. Click **Load unpacked**
124. Select this folder (`treeos-browser-bridge/`)
135. The TreeOS Bridge icon appears in your toolbar
14
15### 2. Connect to Your Land
16
171. Click the TreeOS Bridge icon in Chrome
182. Enter your **Server URL** (e.g. `http://localhost:3000` or `https://your-land.com`)
193. Enter your **Username** and **Password** (or API key if you have one)
204. Click **Connect**
215. The status dot turns green when connected
22
23### 3. Enable Browser Bridge on a Tree Branch
24
25Browser bridge is **confined**. It's inactive everywhere by default. You must explicitly allow it at the tree positions where you want the AI to use the browser.
26
27**CLI:**
28```
29treeos cd MyTree
30treeos ext-allow browser-bridge
31```
32
33**Or at a specific branch:**
34```
35treeos cd MyTree/Web
36treeos ext-allow browser-bridge
37```
38
39The AI can only use browser tools at positions where you've allowed it. Everywhere else, the browser tools don't exist.
40
41### 4. Set the Browser Agent Mode (Optional)
42
43For best results, set the browser-agent mode on the node where you allowed browser-bridge:
44
45```
46treeos mode-set respond tree:browser-agent
47```
48
49This makes the AI act on browser requests instead of just describing them.
50
51## Usage
52
53Once connected and allowed, chat at that branch:
54
55```
56what's on this page
57```
58The AI reads the current page via the accessibility tree.
59
60```
61click the login button
62```
63The AI finds the element and clicks it.
64
65```
66reply to this post saying "interesting, thanks for sharing"
67```
68The AI uses the comment tool to find the reply button, type text, and submit.
69
70```
71navigate to docs.react.dev
72```
73The AI opens the URL in your browser.
74
75## Tools Available to the AI
76
77| Tool | Type | Description |
78|------|------|-------------|
79| `browser-read` | Read | See page URL, title, text content, and interactive elements |
80| `browser-click` | Write | Click an element by ID from browser-read |
81| `browser-type` | Write | Type text into an input field |
82| `browser-navigate` | Write | Go to a URL |
83| `browser-comment` | Write | Post a comment/reply (handles full flow automatically) |
84
85## Site Scoping (Optional)
86
87Control which websites the AI can interact with per tree position. Set in node metadata:
88
89```json
90{
91  "browserBridge": {
92    "autoApprove": ["docs.react.dev", "developer.mozilla.org"],
93    "alwaysAsk": ["*.bank.com"],
94    "blocked": ["facebook.com"]
95  }
96}
97```
98
99- **autoApprove**: Write actions skip approval on these domains
100- **alwaysAsk**: Always require approval (even if confirm is off)
101- **blocked**: AI cannot see or act on these domains
102
103## Safety Layers
104
105All active by default. Cannot be turned off.
106
1071. **Confined scope**: Off everywhere until `ext-allow`
1082. **Site scoping**: Per-node domain allow/block lists
1093. **Confirm actions**: Chrome extension toggle. When on, every write action shows a notification asking permission
1104. **Read-only in query mode**: `query` mode strips write tools automatically
1115. **Activity logging**: Every browser action is logged as a note on the node
112
113## Chrome Extension UI
114
115- **Popup**: Settings, connect/disconnect, status
116- **Side Panel**: Activity log (real-time), Page Tree viewer (accessibility tree visualization)
117- **Notifications**: Action confirmations when "Confirm actions" is enabled
118
119## Troubleshooting
120
121**"No browser connected"**: The Chrome extension isn't connected. Click the icon and check the status dot. Make sure server URL is correct.
122
123**AI says it can't interact with websites**: Make sure you ran `ext-allow browser-bridge` at the current node AND set `mode-set respond tree:browser-agent`.
124
125**Actions succeed but nothing happens visually**: The AI clicks via JavaScript DOM events, not mouse simulation. The page reacts but you won't see a cursor move.
126
127**Page Tree tab is blank**: Refresh the web page you're viewing, then click Page Tree again. The content script only injects on page load.
128
1// TreeOS Browser Bridge — Background Service Worker
2// Manages Socket.IO connection to TreeOS and bridges content script <-> server
3
4try {
5  importScripts('lib/socket.io.min.js');
6} catch (e) {
7  console.error('[TreeOS Bridge] Failed to load Socket.IO:', e);
8}
9
10let socket = null;
11let config = { serverUrl: '', apiKey: '', username: '', password: '', autoCapture: false, confirmActions: true };
12let connectionState = 'disconnected'; // disconnected | connecting | connected
13let reconnectTimer = null;
14let pendingConfirmations = new Map();
15
16// ── Config ────────────────────────────────────────────────────────
17
18async function loadConfig() {
19  const stored = await chrome.storage.local.get(['treeos_config']);
20  if (stored.treeos_config) {
21    config = { ...config, ...stored.treeos_config };
22  }
23  return config;
24}
25
26async function saveConfig(newConfig) {
27  config = { ...config, ...newConfig };
28  await chrome.storage.local.set({ treeos_config: config });
29  broadcastState();
30  return config;
31}
32
33// ── Socket.IO Connection ─────────────────────────────────────────
34
35function connect() {
36  if (!config.serverUrl) {
37    updateState('disconnected');
38    return;
39  }
40
41  if (socket?.connected) return;
42
43  updateState('connecting');
44
45  try {
46    // Ensure full URL with protocol for service worker context
47    let serverUrl = config.serverUrl;
48    if (!serverUrl.startsWith('http://') && !serverUrl.startsWith('https://')) {
49      serverUrl = 'http://' + serverUrl;
50    }
51
52    socket = io(serverUrl, {
53      path: '/socket.io',
54      transports: ['websocket'],
55      reconnection: true,
56      reconnectionDelay: 5000,
57      reconnectionAttempts: Infinity,
58      forceNew: true,
59    });
60
61    socket.on('connect', () => {
62      updateState('connected');
63      clearReconnectTimer();
64
65      // Authenticate with API key or username/password
66      socket.emit('browserAuth', {
67        apiKey: config.apiKey || null,
68        username: config.username || null,
69        password: config.password || null,
70        capabilities: ['page_state', 'execute_action', 'screenshot', 'network_log'],
71      });
72    });
73
74    socket.on('browserAuthResult', (data) => {
75      if (data.success) {
76        console.log('[TreeOS Bridge] Authenticated');
77      } else {
78        console.error('[TreeOS Bridge] Auth failed:', data.error);
79        disconnect();
80      }
81    });
82
83    // ── Server requests ──────────────────────────────────────────
84
85    socket.on('getPageState', async (msg) => {
86      const state = await getPageStateFromTab(msg.tabId);
87      socket.emit('pageState', { requestId: msg.requestId, data: state });
88      broadcastActivity('getPageState', { url: state?.url || 'current page' });
89    });
90
91    socket.on('executeAction', async (msg) => {
92      // If confirmation required, hold and ask user
93      if (config.confirmActions && !msg.confirmed) {
94        const confirmId = crypto.randomUUID();
95        pendingConfirmations.set(confirmId, msg);
96
97        chrome.runtime.sendMessage({
98          type: 'confirmAction',
99          confirmId,
100          action: msg.action,
101          description: describeAction(msg.action),
102        }).catch(() => {});
103
104        chrome.notifications?.create(confirmId, {
105          type: 'basic',
106          iconUrl: 'icons/icon128.png',
107          title: 'TreeOS Agent Action',
108          message: describeAction(msg.action),
109          buttons: [{ title: 'Allow' }, { title: 'Deny' }],
110          requireInteraction: true,
111        }).catch(() => {});
112
113        return;
114      }
115
116      const result = await executeActionInTab(msg.action, msg.tabId);
117      socket.emit('actionResult', { requestId: msg.requestId, data: result });
118      broadcastActivity('action', { type: msg.action.type, target: msg.action.elementId || msg.action.url || '', success: result.success });
119
120      // Auto-capture new state after action
121      if (result.success && config.autoCapture) {
122        await new Promise(r => setTimeout(r, 500));
123        const newState = await getPageStateFromTab(msg.tabId);
124        socket.emit('pageState', { requestId: msg.requestId + '_post', data: newState });
125      }
126    });
127
128    socket.on('screenshot', async (msg) => {
129      const dataUrl = await captureScreenshot(msg.tabId);
130      socket.emit('screenshot', { requestId: msg.requestId, data: dataUrl });
131      broadcastActivity('screenshot', {});
132    });
133
134    socket.on('getNetworkLog', async (msg) => {
135      const log = await getNetworkLogFromTab(msg.tabId);
136      socket.emit('networkLog', { requestId: msg.requestId, data: log });
137    });
138
139    socket.on('getTabs', async (msg) => {
140      const tabs = await chrome.tabs.query({ currentWindow: true });
141      const tabData = tabs.map(t => ({
142        id: t.id, url: t.url, title: t.title, active: t.active,
143      }));
144      socket.emit('tabsList', { requestId: msg.requestId, data: tabData });
145    });
146
147    socket.on('activateTab', async (msg) => {
148      await chrome.tabs.update(msg.tabId, { active: true });
149      socket.emit('tabActivated', { requestId: msg.requestId, tabId: msg.tabId });
150    });
151
152    socket.on('newTab', async (msg) => {
153      const tab = await chrome.tabs.create({ url: msg.url || 'about:blank' });
154      socket.emit('tabCreated', { requestId: msg.requestId, tabId: tab.id });
155    });
156
157    socket.on('ping', (msg) => {
158      socket.emit('pong', { requestId: msg.requestId });
159    });
160
161    // ── Connection lifecycle ─────────────────────────────────────
162
163    socket.on('disconnect', (reason) => {
164      updateState('disconnected');
165      if (reason !== 'io client disconnect') {
166        // Auto-reconnect handled by Socket.IO
167      }
168    });
169
170    socket.on('connect_error', (err) => {
171      console.error('[TreeOS Bridge] Connection error:', err.message);
172      updateState('disconnected');
173    });
174
175    socket.on('browserDisconnected', (data) => {
176      console.log('[TreeOS Bridge] Disconnected by server:', data?.reason);
177    });
178
179  } catch (err) {
180    console.error('[TreeOS Bridge] Connection failed:', err);
181    updateState('disconnected');
182    scheduleReconnect();
183  }
184}
185
186function disconnect() {
187  clearReconnectTimer();
188  if (socket) {
189    socket.disconnect();
190    socket = null;
191  }
192  updateState('disconnected');
193}
194
195function scheduleReconnect() {
196  clearReconnectTimer();
197  reconnectTimer = setTimeout(() => connect(), 5000);
198}
199
200function clearReconnectTimer() {
201  if (reconnectTimer) {
202    clearTimeout(reconnectTimer);
203    reconnectTimer = null;
204  }
205}
206
207// ── Content Script Communication ──────────────────────────────────
208
209async function getActiveTabId(preferredTabId) {
210  if (preferredTabId) return preferredTabId;
211  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
212  return tab?.id;
213}
214
215/**
216 * Ensure content script is injected in the tab.
217 * SPAs (x.com, reddit) destroy the content script context on navigation.
218 * Re-inject before every message to handle this.
219 */
220async function ensureContentScript(tabId) {
221  try {
222    await chrome.tabs.sendMessage(tabId, { type: 'ping' });
223  } catch {
224    // Content script not responding. Re-inject.
225    try {
226      await chrome.scripting.executeScript({
227        target: { tabId },
228        files: ['scripts/content.js'],
229      });
230      // Give it a moment to initialize
231      await new Promise(r => setTimeout(r, 200));
232    } catch (err) {
233      console.warn('[TreeOS Bridge] Cannot inject content script:', err.message);
234    }
235  }
236}
237
238async function getPageStateFromTab(tabId) {
239  const id = await getActiveTabId(tabId);
240  if (!id) return { error: 'No active tab' };
241
242  await ensureContentScript(id);
243
244  try {
245    const response = await chrome.tabs.sendMessage(id, {
246      type: 'getPageState',
247      includeNetwork: true,
248    });
249    return response;
250  } catch (err) {
251    // Content script failed (hostile site like x.com). Fall back to
252    // chrome.scripting.executeScript which runs in the isolated world.
253    try {
254      const [result] = await chrome.scripting.executeScript({
255        target: { tabId: id },
256        world: 'ISOLATED',
257        func: () => {
258          // Minimal page state capture without full tree builder
259          const getText = (el, max) => (el?.textContent || '').trim().slice(0, max);
260          const links = [...document.querySelectorAll('a[href]')].slice(0, 50).map((a, i) => ({
261            role: 'link', name: getText(a, 100), id: 'e' + (i + 1), href: a.href,
262          }));
263          const buttons = [...document.querySelectorAll('button')].slice(0, 30).map((b, i) => ({
264            role: 'button', name: getText(b, 100), id: 'b' + (i + 1),
265          }));
266          const inputs = [...document.querySelectorAll('input, textarea, [contenteditable="true"]')].slice(0, 20).map((el, i) => ({
267            role: el.tagName === 'TEXTAREA' ? 'textbox' : (el.type || 'input'),
268            name: el.placeholder || el.name || '',
269            id: 'i' + (i + 1),
270          }));
271          const bodyText = document.body?.innerText?.slice(0, 8000) || '';
272          return {
273            url: location.href,
274            title: document.title,
275            tree: [...links, ...buttons, ...inputs],
276            text: bodyText,
277            viewport: {
278              width: window.innerWidth,
279              height: window.innerHeight,
280              scrollY: window.scrollY,
281              scrollHeight: document.documentElement.scrollHeight,
282            },
283            timestamp: Date.now(),
284            fallback: true,
285          };
286        },
287      });
288      return result?.result || { error: 'Fallback capture failed' };
289    } catch (err2) {
290      return { error: `Page state failed: ${err2.message}` };
291    }
292  }
293}
294
295async function executeActionInTab(action, tabId) {
296  const id = await getActiveTabId(tabId);
297  if (!id) return { success: false, error: 'No active tab' };
298
299  // Navigate uses Chrome tab API directly. Bypasses hostile SPA routers (x.com, etc.)
300  // that intercept window.location changes inside the content script.
301  if (action.type === 'navigate' && action.url) {
302    try {
303      await chrome.tabs.update(id, { url: action.url });
304      // Wait for page to load
305      await new Promise(resolve => {
306        const listener = (tabId2, changeInfo) => {
307          if (tabId2 === id && changeInfo.status === 'complete') {
308            chrome.tabs.onUpdated.removeListener(listener);
309            resolve();
310          }
311        };
312        chrome.tabs.onUpdated.addListener(listener);
313        // Timeout after 10s
314        setTimeout(() => {
315          chrome.tabs.onUpdated.removeListener(listener);
316          resolve();
317        }, 10000);
318      });
319      return { success: true, action: 'navigated', url: action.url };
320    } catch (err) {
321      return { success: false, error: `Navigate failed: ${err.message}` };
322    }
323  }
324
325  await ensureContentScript(id);
326
327  try {
328    const response = await chrome.tabs.sendMessage(id, {
329      type: 'executeAction',
330      action,
331      recapture: true,
332    });
333    // If content script returned failure, try the fallback path
334    if (response && response.success === false) throw new Error(response.error || 'action failed');
335    return response;
336  } catch (err) {
337    // Content script failed or returned error. Fall back to chrome.scripting for hostile sites.
338    try {
339      const [result] = await chrome.scripting.executeScript({
340        target: { tabId: id },
341        world: 'ISOLATED',
342        args: [action],
343        func: async (action) => {
344          function findEl(id) {
345            if (!id) return null;
346            // Try by our assigned IDs from the fallback page state
347            const prefix = id.charAt(0);
348            const idx = parseInt(id.slice(1)) - 1;
349            if (prefix === 'e') return document.querySelectorAll('a[href]')[idx];
350            if (prefix === 'b') return document.querySelectorAll('button')[idx];
351            if (prefix === 'i') return document.querySelectorAll('input, textarea, [contenteditable="true"]')[idx];
352            return null;
353          }
354
355          switch (action.type) {
356            case 'click': {
357              const el = findEl(action.elementId);
358              if (!el) return { success: false, error: 'Element not found: ' + action.elementId };
359              el.scrollIntoView({ behavior: 'instant', block: 'center' });
360              el.focus();
361              el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
362              return { success: true, action: 'clicked', elementId: action.elementId };
363            }
364
365            case 'type': {
366              // Find contenteditable or textarea
367              let el = findEl(action.elementId);
368              if (!el) {
369                // Try to find any visible contenteditable or textarea
370                const all = document.querySelectorAll('[contenteditable="true"], textarea, [role="textbox"]');
371                for (const candidate of all) {
372                  const r = candidate.getBoundingClientRect();
373                  if (r.height > 20 && r.width > 50) { el = candidate; break; }
374                }
375              }
376              if (!el) return { success: false, error: 'No text input found' };
377
378              el.scrollIntoView({ behavior: 'instant', block: 'center' });
379              el.focus();
380
381              // Clear and type
382              if (el.contentEditable === 'true' || el.getAttribute('role') === 'textbox') {
383                el.textContent = '';
384                // Use DataTransfer for paste simulation (bypasses React interception)
385                const dt = new DataTransfer();
386                dt.setData('text/plain', action.text);
387                const pasteEvent = new ClipboardEvent('paste', {
388                  bubbles: true, cancelable: true, clipboardData: dt,
389                });
390                el.dispatchEvent(pasteEvent);
391                // Fallback: direct insert
392                if (!el.textContent.includes(action.text)) {
393                  el.textContent = action.text;
394                  el.dispatchEvent(new Event('input', { bubbles: true }));
395                }
396              } else {
397                el.value = action.text;
398                el.dispatchEvent(new Event('input', { bubbles: true }));
399                el.dispatchEvent(new Event('change', { bubbles: true }));
400              }
401              return { success: true, action: 'typed', text: action.text };
402            }
403
404            case 'comment': {
405              const text = action.text;
406              if (!text) return { success: false, error: 'text required' };
407
408              // Step 1: Click a reply button to open the compose box
409              // Reddit, forums, etc. hide the textbox until you click reply
410              let textbox = null;
411
412              // Look for existing open compose box first
413              let all = document.querySelectorAll('[contenteditable="true"], textarea, [role="textbox"]');
414              for (const el of all) {
415                const r = el.getBoundingClientRect();
416                if (r.height > 20 && r.width > 50) { textbox = el; break; }
417              }
418
419              // No open box? Click a reply button
420              if (!textbox) {
421                // New Reddit: shreddit-comment-action-row buttons
422                const actionRow = document.querySelector('shreddit-comment-action-row');
423                if (actionRow) {
424                  const btns = actionRow.querySelectorAll('button');
425                  if (btns.length >= 1) btns[0].click();
426                }
427
428                // Old Reddit / generic: find a "reply" link or button
429                if (!actionRow) {
430                  const replyLinks = document.querySelectorAll('a, button');
431                  for (const el of replyLinks) {
432                    const t = (el.textContent || '').trim().toLowerCase();
433                    if (t === 'reply' || t === 'comment') {
434                      el.click();
435                      break;
436                    }
437                  }
438                }
439
440                // Wait for compose box to appear
441                await new Promise(r => setTimeout(r, 1500));
442
443                // Try again
444                all = document.querySelectorAll('[contenteditable="true"], textarea, [role="textbox"]');
445                for (const el of all) {
446                  const r = el.getBoundingClientRect();
447                  if (r.height > 20 && r.width > 50) { textbox = el; break; }
448                }
449                // Last resort: any visible input
450                if (!textbox && all.length) textbox = all[all.length - 1];
451              }
452
453              if (!textbox) return { success: false, error: 'No compose box found after clicking reply' };
454
455              textbox.focus();
456              await new Promise(r => setTimeout(r, 300));
457
458              // Method 1: execCommand insertText (works on many sites)
459              let typed = false;
460              try {
461                document.execCommand('selectAll', false, null);
462                typed = document.execCommand('insertText', false, text);
463              } catch {}
464
465              // Method 2: Paste simulation via ClipboardEvent
466              if (!typed || !textbox.textContent?.includes(text)) {
467                const dt = new DataTransfer();
468                dt.setData('text/plain', text);
469                textbox.dispatchEvent(new ClipboardEvent('paste', {
470                  bubbles: true, cancelable: true, clipboardData: dt,
471                }));
472              }
473
474              // Method 3: Direct content set + input event
475              if (!textbox.textContent?.includes(text)) {
476                if (textbox.contentEditable === 'true') {
477                  textbox.innerHTML = '<p>' + text + '</p>';
478                } else {
479                  textbox.value = text;
480                }
481                textbox.dispatchEvent(new Event('input', { bubbles: true }));
482                textbox.dispatchEvent(new Event('change', { bubbles: true }));
483              }
484
485              // Wait for React/framework to process
486              await new Promise(r => setTimeout(r, 1000));
487
488              // Verify text landed
489              const hasText = textbox.textContent?.includes(text) || textbox.value?.includes?.(text);
490              if (!hasText) {
491                return { success: false, error: 'Text did not register in compose box', typed: false };
492              }
493
494              // Find and click post/submit button
495              const buttons = document.querySelectorAll('button');
496              const submitWords = ['post', 'tweet', 'reply', 'send', 'submit', 'comment', 'save'];
497              let submitBtn = null;
498
499              // Try data-testid first (X.com uses tweetButton, tweetButtonInline)
500              submitBtn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]');
501
502              // Try finding a button containing a span with submit text
503              if (!submitBtn) {
504                const spans = document.querySelectorAll('button span');
505                for (const span of spans) {
506                  const t = span.textContent.trim().toLowerCase();
507                  if (submitWords.some(w => t === w)) {
508                    const btn = span.closest('button');
509                    if (btn && btn.offsetParent !== null) { submitBtn = btn; break; }
510                  }
511                }
512              }
513
514              // Fall back to button text/aria-label search
515              if (!submitBtn) {
516                for (const btn of buttons) {
517                  const t = (btn.textContent || btn.getAttribute('aria-label') || '').trim().toLowerCase();
518                  if (submitWords.some(w => t.includes(w))) {
519                    if (btn.offsetParent !== null) { submitBtn = btn; break; }
520                  }
521                }
522              }
523
524              if (submitBtn) {
525                // Wait for React to process the input
526                return new Promise(resolve => {
527                  setTimeout(() => {
528                    submitBtn.click();
529                    resolve({ success: true, action: 'commented', submitted: true, text });
530                  }, 500);
531                });
532              }
533
534              return { success: true, action: 'typed', submitted: false, text, error: 'Typed but could not find submit button' };
535            }
536
537            case 'extract': {
538              const el = action.elementId ? findEl(action.elementId) : document.body;
539              return { success: true, text: (el?.innerText || '').slice(0, 8000) };
540            }
541
542            default:
543              return { success: false, error: 'Unsupported fallback action: ' + action.type };
544          }
545        },
546      });
547      return result?.result || { success: false, error: 'Fallback action failed' };
548    } catch (err2) {
549      return { success: false, error: `Action failed: ${err2.message}` };
550    }
551  }
552}
553
554async function captureScreenshot(tabId) {
555  const id = await getActiveTabId(tabId);
556  if (id) await chrome.tabs.update(id, { active: true });
557
558  try {
559    const dataUrl = await chrome.tabs.captureVisibleTab(null, {
560      format: 'png',
561      quality: 80,
562    });
563    return dataUrl;
564  } catch (err) {
565    return { error: err.message };
566  }
567}
568
569async function getNetworkLogFromTab(tabId) {
570  const id = await getActiveTabId(tabId);
571  if (!id) return { error: 'No active tab' };
572
573  try {
574    return await chrome.tabs.sendMessage(id, { type: 'getNetworkLog' });
575  } catch (err) {
576    return { error: err.message };
577  }
578}
579
580// ── Action Descriptions (for confirmation UI) ─────────────────────
581
582function describeAction(action) {
583  switch (action.type) {
584    case 'click': return `Click element ${action.elementId}`;
585    case 'type': return `Type "${action.text}" into ${action.elementId}`;
586    case 'navigate': return `Navigate to ${action.url}`;
587    case 'select': return `Select "${action.value}" in ${action.elementId}`;
588    case 'scroll': return `Scroll ${action.direction || 'down'}`;
589    case 'keypress': return `Press ${action.key}`;
590    case 'back': return 'Go back';
591    case 'forward': return 'Go forward';
592    case 'extract': return 'Extract page text';
593    default: return `${action.type} action`;
594  }
595}
596
597// ── Activity Log ──────────────────────────────────────────────
598
599let activityLog = [];
600const MAX_ACTIVITY = 50;
601
602function broadcastActivity(action, details) {
603  const entry = { action, details, time: new Date().toISOString() };
604  activityLog.push(entry);
605  if (activityLog.length > MAX_ACTIVITY) activityLog.shift();
606  chrome.runtime.sendMessage({ type: 'activity', entry, log: activityLog }).catch(() => {});
607}
608
609// ── State Broadcasting ────────────────────────────────────────────
610
611function updateState(state) {
612  connectionState = state;
613  broadcastState();
614}
615
616function broadcastState() {
617  chrome.runtime.sendMessage({
618    type: 'stateUpdate',
619    connectionState,
620    config: { ...config, apiKey: config.apiKey ? '••••' : '' },
621  }).catch(() => {});
622}
623
624// ── Internal Message Handler (from popup / side panel) ────────────
625
626chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
627  (async () => {
628    switch (msg.type) {
629      case 'getState': {
630        sendResponse({
631          connectionState,
632          config: { ...config, apiKey: config.apiKey ? '••••' : '' },
633        });
634        break;
635      }
636
637      case 'getActivityLog': {
638        sendResponse({ log: activityLog });
639        break;
640      }
641
642      case 'saveConfig': {
643        const updated = await saveConfig(msg.config);
644        sendResponse({ success: true, config: updated });
645        break;
646      }
647
648      case 'connect': {
649        await loadConfig();
650        connect();
651        sendResponse({ success: true });
652        break;
653      }
654
655      case 'disconnect': {
656        disconnect();
657        sendResponse({ success: true });
658        break;
659      }
660
661      case 'confirmActionResponse': {
662        const pending = pendingConfirmations.get(msg.confirmId);
663        if (pending) {
664          pendingConfirmations.delete(msg.confirmId);
665          if (msg.allowed) {
666            // Re-dispatch with confirmed flag
667            const result = await executeActionInTab(pending.action, pending.tabId);
668            socket?.emit('actionResult', { requestId: pending.requestId, data: result });
669          } else {
670            socket?.emit('actionResult', {
671              requestId: pending.requestId,
672              data: { success: false, error: 'User denied action' },
673            });
674          }
675        }
676        sendResponse({ success: true });
677        break;
678      }
679
680      case 'manualCapture': {
681        console.log('[TreeOS Bridge] manualCapture requested');
682        try {
683          const state = await getPageStateFromTab();
684          console.log('[TreeOS Bridge] manualCapture result:', state?.error || `tree: ${!!state?.tree}, url: ${state?.url}`);
685          if (socket?.connected) {
686            socket.emit('pageState', { requestId: 'manual', data: state });
687          }
688          sendResponse({ success: true, sent: !!socket?.connected, state });
689        } catch (err) {
690          console.error('[TreeOS Bridge] manualCapture error:', err);
691          sendResponse({ success: false, state: { error: err.message } });
692        }
693        break;
694      }
695
696      case 'manualScreenshot': {
697        const dataUrl = await captureScreenshot();
698        if (socket?.connected) {
699          socket.emit('screenshot', { requestId: 'manual', data: dataUrl });
700          sendResponse({ success: true, sent: true });
701        } else {
702          sendResponse({ success: true, sent: false });
703        }
704        break;
705      }
706
707      default:
708        sendResponse({ error: 'Unknown message type' });
709    }
710  })();
711  return true;
712});
713
714// ── Notification Button Handler ───────────────────────────────────
715
716chrome.notifications?.onButtonClicked?.addListener((notifId, buttonIndex) => {
717  chrome.runtime.sendMessage({
718    type: 'confirmActionResponse',
719    confirmId: notifId,
720    allowed: buttonIndex === 0,
721  }).catch(() => {});
722  chrome.notifications.clear(notifId);
723});
724
725// ── Tab Navigation Listener (auto-report URL changes) ─────────────
726
727chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
728  if (changeInfo.status === 'complete' && connectionState === 'connected') {
729    chrome.tabs.get(tabId).then(tab => {
730      if (tab.active) {
731        socket?.emit('pageNavigated', {
732          tabId,
733          url: tab.url,
734          title: tab.title,
735        });
736      }
737    }).catch(() => {});
738  }
739});
740
741// ── Side Panel Behavior ───────────────────────────────────────────
742
743chrome.sidePanel?.setPanelBehavior?.({ openPanelOnActionClick: false }).catch(() => {});
744
745// ── Boot ──────────────────────────────────────────────────────────
746
747loadConfig().then(() => {
748  if (config.serverUrl && config.apiKey) {
749    connect();
750  }
751});
752
1/*!
2 * Socket.IO v4.8.1
3 * (c) 2014-2024 Guillermo Rauch
4 * Released under the MIT License.
5 */
6!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).io=n()}(this,(function(){"use strict";function t(t,n){(null==n||n>t.length)&&(n=t.length);for(var i=0,r=Array(n);i<n;i++)r[i]=t[i];return r}function n(t,n){for(var i=0;i<n.length;i++){var r=n[i];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(t,f(r.key),r)}}function i(t,i,r){return i&&n(t.prototype,i),r&&n(t,r),Object.defineProperty(t,"prototype",{writable:!1}),t}function r(n,i){var r="undefined"!=typeof Symbol&&n[Symbol.iterator]||n["@@iterator"];if(!r){if(Array.isArray(n)||(r=function(n,i){if(n){if("string"==typeof n)return t(n,i);var r={}.toString.call(n).slice(8,-1);return"Object"===r&&n.constructor&&(r=n.constructor.name),"Map"===r||"Set"===r?Array.from(n):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?t(n,i):void 0}}(n))||i&&n&&"number"==typeof n.length){r&&(n=r);var e=0,o=function(){};return{s:o,n:function(){return e>=n.length?{done:!0}:{done:!1,value:n[e++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,h=!1;return{s:function(){r=r.call(n)},n:function(){var t=r.next();return u=t.done,t},e:function(t){h=!0,s=t},f:function(){try{u||null==r.return||r.return()}finally{if(h)throw s}}}}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var n=1;n<arguments.length;n++){var i=arguments[n];for(var r in i)({}).hasOwnProperty.call(i,r)&&(t[r]=i[r])}return t},e.apply(null,arguments)}function o(t){return o=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(t){return t.__proto__||Object.getPrototypeOf(t)},o(t)}function s(t,n){t.prototype=Object.create(n.prototype),t.prototype.constructor=t,h(t,n)}function u(){try{var t=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){})))}catch(t){}return(u=function(){return!!t})()}function h(t,n){return h=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,n){return t.__proto__=n,t},h(t,n)}function f(t){var n=function(t,n){if("object"!=typeof t||!t)return t;var i=t[Symbol.toPrimitive];if(void 0!==i){var r=i.call(t,n||"default");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===n?String:Number)(t)}(t,"string");return"symbol"==typeof n?n:n+""}function c(t){return c="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},c(t)}function a(t){var n="function"==typeof Map?new Map:void 0;return a=function(t){if(null===t||!function(t){try{return-1!==Function.toString.call(t).indexOf("[native code]")}catch(n){return"function"==typeof t}}(t))return t;if("function"!=typeof t)throw new TypeError("Super expression must either be null or a function");if(void 0!==n){if(n.has(t))return n.get(t);n.set(t,i)}function i(){return function(t,n,i){if(u())return Reflect.construct.apply(null,arguments);var r=[null];r.push.apply(r,n);var e=new(t.bind.apply(t,r));return i&&h(e,i.prototype),e}(t,arguments,o(this).constructor)}return i.prototype=Object.create(t.prototype,{constructor:{value:i,enumerable:!1,writable:!0,configurable:!0}}),h(i,t)},a(t)}var v=Object.create(null);v.open="0",v.close="1",v.ping="2",v.pong="3",v.message="4",v.upgrade="5",v.noop="6";var l=Object.create(null);Object.keys(v).forEach((function(t){l[v[t]]=t}));var p,d={type:"error",data:"parser error"},y="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===Object.prototype.toString.call(Blob),b="function"==typeof ArrayBuffer,w=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t&&t.buffer instanceof ArrayBuffer},g=function(t,n,i){var r=t.type,e=t.data;return y&&e instanceof Blob?n?i(e):m(e,i):b&&(e instanceof ArrayBuffer||w(e))?n?i(e):m(new Blob([e]),i):i(v[r]+(e||""))},m=function(t,n){var i=new FileReader;return i.onload=function(){var t=i.result.split(",")[1];n("b"+(t||""))},i.readAsDataURL(t)};function k(t){return t instanceof Uint8Array?t:t instanceof ArrayBuffer?new Uint8Array(t):new Uint8Array(t.buffer,t.byteOffset,t.byteLength)}for(var A="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",j="undefined"==typeof Uint8Array?[]:new Uint8Array(256),E=0;E<64;E++)j[A.charCodeAt(E)]=E;var O,B="function"==typeof ArrayBuffer,S=function(t,n){if("string"!=typeof t)return{type:"message",data:C(t,n)};var i=t.charAt(0);return"b"===i?{type:"message",data:N(t.substring(1),n)}:l[i]?t.length>1?{type:l[i],data:t.substring(1)}:{type:l[i]}:d},N=function(t,n){if(B){var i=function(t){var n,i,r,e,o,s=.75*t.length,u=t.length,h=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var f=new ArrayBuffer(s),c=new Uint8Array(f);for(n=0;n<u;n+=4)i=j[t.charCodeAt(n)],r=j[t.charCodeAt(n+1)],e=j[t.charCodeAt(n+2)],o=j[t.charCodeAt(n+3)],c[h++]=i<<2|r>>4,c[h++]=(15&r)<<4|e>>2,c[h++]=(3&e)<<6|63&o;return f}(t);return C(i,n)}return{base64:!0,data:t}},C=function(t,n){return"blob"===n?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},T=String.fromCharCode(30);function U(){return new TransformStream({transform:function(t,n){!function(t,n){y&&t.data instanceof Blob?t.data.arrayBuffer().then(k).then(n):b&&(t.data instanceof ArrayBuffer||w(t.data))?n(k(t.data)):g(t,!1,(function(t){p||(p=new TextEncoder),n(p.encode(t))}))}(t,(function(i){var r,e=i.length;if(e<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,e);else if(e<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,e)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(r[0]|=128),n.enqueue(r),n.enqueue(i)}))}})}function M(t){return t.reduce((function(t,n){return t+n.length}),0)}function x(t,n){if(t[0].length===n)return t.shift();for(var i=new Uint8Array(n),r=0,e=0;e<n;e++)i[e]=t[0][r++],r===t[0].length&&(t.shift(),r=0);return t.length&&r<t[0].length&&(t[0]=t[0].slice(r)),i}function I(t){if(t)return function(t){for(var n in I.prototype)t[n]=I.prototype[n];return t}(t)}I.prototype.on=I.prototype.addEventListener=function(t,n){return this.t=this.t||{},(this.t["$"+t]=this.t["$"+t]||[]).push(n),this},I.prototype.once=function(t,n){function i(){this.off(t,i),n.apply(this,arguments)}return i.fn=n,this.on(t,i),this},I.prototype.off=I.prototype.removeListener=I.prototype.removeAllListeners=I.prototype.removeEventListener=function(t,n){if(this.t=this.t||{},0==arguments.length)return this.t={},this;var i,r=this.t["$"+t];if(!r)return this;if(1==arguments.length)return delete this.t["$"+t],this;for(var e=0;e<r.length;e++)if((i=r[e])===n||i.fn===n){r.splice(e,1);break}return 0===r.length&&delete this.t["$"+t],this},I.prototype.emit=function(t){this.t=this.t||{};for(var n=new Array(arguments.length-1),i=this.t["$"+t],r=1;r<arguments.length;r++)n[r-1]=arguments[r];if(i){r=0;for(var e=(i=i.slice(0)).length;r<e;++r)i[r].apply(this,n)}return this},I.prototype.emitReserved=I.prototype.emit,I.prototype.listeners=function(t){return this.t=this.t||{},this.t["$"+t]||[]},I.prototype.hasListeners=function(t){return!!this.listeners(t).length};var R="function"==typeof Promise&&"function"==typeof Promise.resolve?function(t){return Promise.resolve().then(t)}:function(t,n){return n(t,0)},L="undefined"!=typeof self?self:"undefined"!=typeof window?window:Function("return this")();function _(t){for(var n=arguments.length,i=new Array(n>1?n-1:0),r=1;r<n;r++)i[r-1]=arguments[r];return i.reduce((function(n,i){return t.hasOwnProperty(i)&&(n[i]=t[i]),n}),{})}var D=L.setTimeout,P=L.clearTimeout;function $(t,n){n.useNativeTimers?(t.setTimeoutFn=D.bind(L),t.clearTimeoutFn=P.bind(L)):(t.setTimeoutFn=L.setTimeout.bind(L),t.clearTimeoutFn=L.clearTimeout.bind(L))}function F(){return Date.now().toString(36).substring(3)+Math.random().toString(36).substring(2,5)}var V=function(t){function n(n,i,r){var e;return(e=t.call(this,n)||this).description=i,e.context=r,e.type="TransportError",e}return s(n,t),n}(a(Error)),q=function(t){function n(n){var i;return(i=t.call(this)||this).writable=!1,$(i,n),i.opts=n,i.query=n.query,i.socket=n.socket,i.supportsBinary=!n.forceBase64,i}s(n,t);var i=n.prototype;return i.onError=function(n,i,r){return t.prototype.emitReserved.call(this,"error",new V(n,i,r)),this},i.open=function(){return this.readyState="opening",this.doOpen(),this},i.close=function(){return"opening"!==this.readyState&&"open"!==this.readyState||(this.doClose(),this.onClose()),this},i.send=function(t){"open"===this.readyState&&this.write(t)},i.onOpen=function(){this.readyState="open",this.writable=!0,t.prototype.emitReserved.call(this,"open")},i.onData=function(t){var n=S(t,this.socket.binaryType);this.onPacket(n)},i.onPacket=function(n){t.prototype.emitReserved.call(this,"packet",n)},i.onClose=function(n){this.readyState="closed",t.prototype.emitReserved.call(this,"close",n)},i.pause=function(t){},i.createUri=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this.i()+this.o()+this.opts.path+this.u(n)},i.i=function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"},i.o=function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""},i.u=function(t){var n=function(t){var n="";for(var i in t)t.hasOwnProperty(i)&&(n.length&&(n+="&"),n+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return n}(t);return n.length?"?"+n:""},n}(I),X=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).h=!1,n}s(n,t);var r=n.prototype;return r.doOpen=function(){this.v()},r.pause=function(t){var n=this;this.readyState="pausing";var i=function(){n.readyState="paused",t()};if(this.h||!this.writable){var r=0;this.h&&(r++,this.once("pollComplete",(function(){--r||i()}))),this.writable||(r++,this.once("drain",(function(){--r||i()})))}else i()},r.v=function(){this.h=!0,this.doPoll(),this.emitReserved("poll")},r.onData=function(t){var n=this;(function(t,n){for(var i=t.split(T),r=[],e=0;e<i.length;e++){var o=S(i[e],n);if(r.push(o),"error"===o.type)break}return r})(t,this.socket.binaryType).forEach((function(t){if("opening"===n.readyState&&"open"===t.type&&n.onOpen(),"close"===t.type)return n.onClose({description:"transport closed by the server"}),!1;n.onPacket(t)})),"closed"!==this.readyState&&(this.h=!1,this.emitReserved("pollComplete"),"open"===this.readyState&&this.v())},r.doClose=function(){var t=this,n=function(){t.write([{type:"close"}])};"open"===this.readyState?n():this.once("open",n)},r.write=function(t){var n=this;this.writable=!1,function(t,n){var i=t.length,r=new Array(i),e=0;t.forEach((function(t,o){g(t,!1,(function(t){r[o]=t,++e===i&&n(r.join(T))}))}))}(t,(function(t){n.doWrite(t,(function(){n.writable=!0,n.emitReserved("drain")}))}))},r.uri=function(){var t=this.opts.secure?"https":"http",n=this.query||{};return!1!==this.opts.timestampRequests&&(n[this.opts.timestampParam]=F()),this.supportsBinary||n.sid||(n.b64=1),this.createUri(t,n)},i(n,[{key:"name",get:function(){return"polling"}}])}(q),H=!1;try{H="undefined"!=typeof XMLHttpRequest&&"withCredentials"in new XMLHttpRequest}catch(t){}var z=H;function J(){}var K=function(t){function n(n){var i;if(i=t.call(this,n)||this,"undefined"!=typeof location){var r="https:"===location.protocol,e=location.port;e||(e=r?"443":"80"),i.xd="undefined"!=typeof location&&n.hostname!==location.hostname||e!==n.port}return i}s(n,t);var i=n.prototype;return i.doWrite=function(t,n){var i=this,r=this.request({method:"POST",data:t});r.on("success",n),r.on("error",(function(t,n){i.onError("xhr post error",t,n)}))},i.doPoll=function(){var t=this,n=this.request();n.on("data",this.onData.bind(this)),n.on("error",(function(n,i){t.onError("xhr poll error",n,i)})),this.pollXhr=n},n}(X),Y=function(t){function n(n,i,r){var e;return(e=t.call(this)||this).createRequest=n,$(e,r),e.l=r,e.p=r.method||"GET",e.m=i,e.k=void 0!==r.data?r.data:null,e.A(),e}s(n,t);var i=n.prototype;return i.A=function(){var t,i=this,r=_(this.l,"agent","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","autoUnref");r.xdomain=!!this.l.xd;var e=this.j=this.createRequest(r);try{e.open(this.p,this.m,!0);try{if(this.l.extraHeaders)for(var o in e.setDisableHeaderCheck&&e.setDisableHeaderCheck(!0),this.l.extraHeaders)this.l.extraHeaders.hasOwnProperty(o)&&e.setRequestHeader(o,this.l.extraHeaders[o])}catch(t){}if("POST"===this.p)try{e.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch(t){}try{e.setRequestHeader("Accept","*/*")}catch(t){}null===(t=this.l.cookieJar)||void 0===t||t.addCookies(e),"withCredentials"in e&&(e.withCredentials=this.l.withCredentials),this.l.requestTimeout&&(e.timeout=this.l.requestTimeout),e.onreadystatechange=function(){var t;3===e.readyState&&(null===(t=i.l.cookieJar)||void 0===t||t.parseCookies(e.getResponseHeader("set-cookie"))),4===e.readyState&&(200===e.status||1223===e.status?i.O():i.setTimeoutFn((function(){i.B("number"==typeof e.status?e.status:0)}),0))},e.send(this.k)}catch(t){return void this.setTimeoutFn((function(){i.B(t)}),0)}"undefined"!=typeof document&&(this.S=n.requestsCount++,n.requests[this.S]=this)},i.B=function(t){this.emitReserved("error",t,this.j),this.N(!0)},i.N=function(t){if(void 0!==this.j&&null!==this.j){if(this.j.onreadystatechange=J,t)try{this.j.abort()}catch(t){}"undefined"!=typeof document&&delete n.requests[this.S],this.j=null}},i.O=function(){var t=this.j.responseText;null!==t&&(this.emitReserved("data",t),this.emitReserved("success"),this.N())},i.abort=function(){this.N()},n}(I);if(Y.requestsCount=0,Y.requests={},"undefined"!=typeof document)if("function"==typeof attachEvent)attachEvent("onunload",G);else if("function"==typeof addEventListener){addEventListener("onpagehide"in L?"pagehide":"unload",G,!1)}function G(){for(var t in Y.requests)Y.requests.hasOwnProperty(t)&&Y.requests[t].abort()}var Q,W=(Q=tt({xdomain:!1}))&&null!==Q.responseType,Z=function(t){function n(n){var i;i=t.call(this,n)||this;var r=n&&n.forceBase64;return i.supportsBinary=W&&!r,i}return s(n,t),n.prototype.request=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return e(t,{xd:this.xd},this.opts),new Y(tt,this.uri(),t)},n}(K);function tt(t){var n=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!n||z))return new XMLHttpRequest}catch(t){}if(!n)try{return new(L[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}var nt="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),it=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this.uri(),n=this.opts.protocols,i=nt?{}:_(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,n,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()},r.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws.C.unref(),t.onOpen()},this.ws.onclose=function(n){return t.onClose({description:"websocket connection closed",context:n})},this.ws.onmessage=function(n){return t.onData(n.data)},this.ws.onerror=function(n){return t.onError("websocket error",n)}},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;g(i,n.supportsBinary,(function(t){try{n.doWrite(i,t)}catch(t){}e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r<t.length;r++)i()},r.doClose=function(){void 0!==this.ws&&(this.ws.onerror=function(){},this.ws.close(),this.ws=null)},r.uri=function(){var t=this.opts.secure?"wss":"ws",n=this.query||{};return this.opts.timestampRequests&&(n[this.opts.timestampParam]=F()),this.supportsBinary||(n.b64=1),this.createUri(t,n)},i(n,[{key:"name",get:function(){return"websocket"}}])}(q),rt=L.WebSocket||L.MozWebSocket,et=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var i=n.prototype;return i.createSocket=function(t,n,i){return nt?new rt(t,n,i):n?new rt(t,n):new rt(t)},i.doWrite=function(t,n){this.ws.send(n)},n}(it),ot=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this;try{this.T=new WebTransport(this.createUri("https"),this.opts.transportOptions[this.name])}catch(t){return this.emitReserved("error",t)}this.T.closed.then((function(){t.onClose()})).catch((function(n){t.onError("webtransport error",n)})),this.T.ready.then((function(){t.T.createBidirectionalStream().then((function(n){var i=function(t,n){O||(O=new TextDecoder);var i=[],r=0,e=-1,o=!1;return new TransformStream({transform:function(s,u){for(i.push(s);;){if(0===r){if(M(i)<1)break;var h=x(i,1);o=!(128&~h[0]),e=127&h[0],r=e<126?3:126===e?1:2}else if(1===r){if(M(i)<2)break;var f=x(i,2);e=new DataView(f.buffer,f.byteOffset,f.length).getUint16(0),r=3}else if(2===r){if(M(i)<8)break;var c=x(i,8),a=new DataView(c.buffer,c.byteOffset,c.length),v=a.getUint32(0);if(v>Math.pow(2,21)-1){u.enqueue(d);break}e=v*Math.pow(2,32)+a.getUint32(4),r=3}else{if(M(i)<e)break;var l=x(i,e);u.enqueue(S(o?l:O.decode(l),n)),r=0}if(0===e||e>t){u.enqueue(d);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=n.readable.pipeThrough(i).getReader(),e=U();e.readable.pipeTo(n.writable),t.U=e.writable.getWriter();!function n(){r.read().then((function(i){var r=i.done,e=i.value;r||(t.onPacket(e),n())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.U.write(o).then((function(){return t.onOpen()}))}))}))},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;n.U.write(i).then((function(){e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r<t.length;r++)i()},r.doClose=function(){var t;null===(t=this.T)||void 0===t||t.close()},i(n,[{key:"name",get:function(){return"webtransport"}}])}(q),st={websocket:et,webtransport:ot,polling:Z},ut=/^(?:(?![^:@\/?#]+:[^:@\/]*@)(http|https|ws|wss):\/\/)?((?:(([^:@\/?#]*)(?::([^:@\/?#]*))?)?@)?((?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}|[^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/,ht=["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","anchor"];function ft(t){if(t.length>8e3)throw"URI too long";var n=t,i=t.indexOf("["),r=t.indexOf("]");-1!=i&&-1!=r&&(t=t.substring(0,i)+t.substring(i,r).replace(/:/g,";")+t.substring(r,t.length));for(var e,o,s=ut.exec(t||""),u={},h=14;h--;)u[ht[h]]=s[h]||"";return-1!=i&&-1!=r&&(u.source=n,u.host=u.host.substring(1,u.host.length-1).replace(/;/g,":"),u.authority=u.authority.replace("[","").replace("]","").replace(/;/g,":"),u.ipv6uri=!0),u.pathNames=function(t,n){var i=/\/{2,9}/g,r=n.replace(i,"/").split("/");"/"!=n.slice(0,1)&&0!==n.length||r.splice(0,1);"/"==n.slice(-1)&&r.splice(r.length-1,1);return r}(0,u.path),u.queryKey=(e=u.query,o={},e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,n,i){n&&(o[n]=i)})),o),u}var ct="function"==typeof addEventListener&&"function"==typeof removeEventListener,at=[];ct&&addEventListener("offline",(function(){at.forEach((function(t){return t()}))}),!1);var vt=function(t){function n(n,i){var r;if((r=t.call(this)||this).binaryType="arraybuffer",r.writeBuffer=[],r.M=0,r.I=-1,r.R=-1,r.L=-1,r._=1/0,n&&"object"===c(n)&&(i=n,n=null),n){var o=ft(n);i.hostname=o.host,i.secure="https"===o.protocol||"wss"===o.protocol,i.port=o.port,o.query&&(i.query=o.query)}else i.host&&(i.hostname=ft(i.host).host);return $(r,i),r.secure=null!=i.secure?i.secure:"undefined"!=typeof location&&"https:"===location.protocol,i.hostname&&!i.port&&(i.port=r.secure?"443":"80"),r.hostname=i.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=i.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=[],r.D={},i.transports.forEach((function(t){var n=t.prototype.name;r.transports.push(n),r.D[n]=t})),r.opts=e({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},i),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var n={},i=t.split("&"),r=0,e=i.length;r<e;r++){var o=i[r].split("=");n[decodeURIComponent(o[0])]=decodeURIComponent(o[1])}return n}(r.opts.query)),ct&&(r.opts.closeOnBeforeunload&&(r.P=function(){r.transport&&(r.transport.removeAllListeners(),r.transport.close())},addEventListener("beforeunload",r.P,!1)),"localhost"!==r.hostname&&(r.$=function(){r.F("transport close",{description:"network connection lost"})},at.push(r.$))),r.opts.withCredentials&&(r.V=void 0),r.q(),r}s(n,t);var i=n.prototype;return i.createTransport=function(t){var n=e({},this.opts.query);n.EIO=4,n.transport=t,this.id&&(n.sid=this.id);var i=e({},this.opts,{query:n,socket:this,hostname:this.hostname,secure:this.secure,port:this.port},this.opts.transportOptions[t]);return new this.D[t](i)},i.q=function(){var t=this;if(0!==this.transports.length){var i=this.opts.rememberUpgrade&&n.priorWebsocketSuccess&&-1!==this.transports.indexOf("websocket")?"websocket":this.transports[0];this.readyState="opening";var r=this.createTransport(i);r.open(),this.setTransport(r)}else this.setTimeoutFn((function(){t.emitReserved("error","No transports available")}),0)},i.setTransport=function(t){var n=this;this.transport&&this.transport.removeAllListeners(),this.transport=t,t.on("drain",this.X.bind(this)).on("packet",this.H.bind(this)).on("error",this.B.bind(this)).on("close",(function(t){return n.F("transport close",t)}))},i.onOpen=function(){this.readyState="open",n.priorWebsocketSuccess="websocket"===this.transport.name,this.emitReserved("open"),this.flush()},i.H=function(t){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState)switch(this.emitReserved("packet",t),this.emitReserved("heartbeat"),t.type){case"open":this.onHandshake(JSON.parse(t.data));break;case"ping":this.J("pong"),this.emitReserved("ping"),this.emitReserved("pong"),this.K();break;case"error":var n=new Error("server error");n.code=t.data,this.B(n);break;case"message":this.emitReserved("data",t.data),this.emitReserved("message",t.data)}},i.onHandshake=function(t){this.emitReserved("handshake",t),this.id=t.sid,this.transport.query.sid=t.sid,this.I=t.pingInterval,this.R=t.pingTimeout,this.L=t.maxPayload,this.onOpen(),"closed"!==this.readyState&&this.K()},i.K=function(){var t=this;this.clearTimeoutFn(this.Y);var n=this.I+this.R;this._=Date.now()+n,this.Y=this.setTimeoutFn((function(){t.F("ping timeout")}),n),this.opts.autoUnref&&this.Y.unref()},i.X=function(){this.writeBuffer.splice(0,this.M),this.M=0,0===this.writeBuffer.length?this.emitReserved("drain"):this.flush()},i.flush=function(){if("closed"!==this.readyState&&this.transport.writable&&!this.upgrading&&this.writeBuffer.length){var t=this.G();this.transport.send(t),this.M=t.length,this.emitReserved("flush")}},i.G=function(){if(!(this.L&&"polling"===this.transport.name&&this.writeBuffer.length>1))return this.writeBuffer;for(var t,n=1,i=0;i<this.writeBuffer.length;i++){var r=this.writeBuffer[i].data;if(r&&(n+="string"==typeof(t=r)?function(t){for(var n=0,i=0,r=0,e=t.length;r<e;r++)(n=t.charCodeAt(r))<128?i+=1:n<2048?i+=2:n<55296||n>=57344?i+=3:(r++,i+=4);return i}(t):Math.ceil(1.33*(t.byteLength||t.size))),i>0&&n>this.L)return this.writeBuffer.slice(0,i);n+=2}return this.writeBuffer},i.W=function(){var t=this;if(!this._)return!0;var n=Date.now()>this._;return n&&(this._=0,R((function(){t.F("ping timeout")}),this.setTimeoutFn)),n},i.write=function(t,n,i){return this.J("message",t,n,i),this},i.send=function(t,n,i){return this.J("message",t,n,i),this},i.J=function(t,n,i,r){if("function"==typeof n&&(r=n,n=void 0),"function"==typeof i&&(r=i,i=null),"closing"!==this.readyState&&"closed"!==this.readyState){(i=i||{}).compress=!1!==i.compress;var e={type:t,data:n,options:i};this.emitReserved("packetCreate",e),this.writeBuffer.push(e),r&&this.once("flush",r),this.flush()}},i.close=function(){var t=this,n=function(){t.F("forced close"),t.transport.close()},i=function i(){t.off("upgrade",i),t.off("upgradeError",i),n()},r=function(){t.once("upgrade",i),t.once("upgradeError",i)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():n()})):this.upgrading?r():n()),this},i.B=function(t){if(n.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.q();this.emitReserved("error",t),this.F("transport error",t)},i.F=function(t,n){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.Y),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),ct&&(this.P&&removeEventListener("beforeunload",this.P,!1),this.$)){var i=at.indexOf(this.$);-1!==i&&at.splice(i,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,n),this.writeBuffer=[],this.M=0}},n}(I);vt.protocol=4;var lt=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).Z=[],n}s(n,t);var i=n.prototype;return i.onOpen=function(){if(t.prototype.onOpen.call(this),"open"===this.readyState&&this.opts.upgrade)for(var n=0;n<this.Z.length;n++)this.tt(this.Z[n])},i.tt=function(t){var n=this,i=this.createTransport(t),r=!1;vt.priorWebsocketSuccess=!1;var e=function(){r||(i.send([{type:"ping",data:"probe"}]),i.once("packet",(function(t){if(!r)if("pong"===t.type&&"probe"===t.data){if(n.upgrading=!0,n.emitReserved("upgrading",i),!i)return;vt.priorWebsocketSuccess="websocket"===i.name,n.transport.pause((function(){r||"closed"!==n.readyState&&(c(),n.setTransport(i),i.send([{type:"upgrade"}]),n.emitReserved("upgrade",i),i=null,n.upgrading=!1,n.flush())}))}else{var e=new Error("probe error");e.transport=i.name,n.emitReserved("upgradeError",e)}})))};function o(){r||(r=!0,c(),i.close(),i=null)}var s=function(t){var r=new Error("probe error: "+t);r.transport=i.name,o(),n.emitReserved("upgradeError",r)};function u(){s("transport closed")}function h(){s("socket closed")}function f(t){i&&t.name!==i.name&&o()}var c=function(){i.removeListener("open",e),i.removeListener("error",s),i.removeListener("close",u),n.off("close",h),n.off("upgrading",f)};i.once("open",e),i.once("error",s),i.once("close",u),this.once("close",h),this.once("upgrading",f),-1!==this.Z.indexOf("webtransport")&&"webtransport"!==t?this.setTimeoutFn((function(){r||i.open()}),200):i.open()},i.onHandshake=function(n){this.Z=this.nt(n.upgrades),t.prototype.onHandshake.call(this,n)},i.nt=function(t){for(var n=[],i=0;i<t.length;i++)~this.transports.indexOf(t[i])&&n.push(t[i]);return n},n}(vt),pt=function(t){function n(n){var i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r="object"===c(n)?n:i;return(!r.transports||r.transports&&"string"==typeof r.transports[0])&&(r.transports=(r.transports||["polling","websocket","webtransport"]).map((function(t){return st[t]})).filter((function(t){return!!t}))),t.call(this,n,r)||this}return s(n,t),n}(lt);pt.protocol;var dt="function"==typeof ArrayBuffer,yt=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer},bt=Object.prototype.toString,wt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===bt.call(Blob),gt="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===bt.call(File);function mt(t){return dt&&(t instanceof ArrayBuffer||yt(t))||wt&&t instanceof Blob||gt&&t instanceof File}function kt(t,n){if(!t||"object"!==c(t))return!1;if(Array.isArray(t)){for(var i=0,r=t.length;i<r;i++)if(kt(t[i]))return!0;return!1}if(mt(t))return!0;if(t.toJSON&&"function"==typeof t.toJSON&&1===arguments.length)return kt(t.toJSON(),!0);for(var e in t)if(Object.prototype.hasOwnProperty.call(t,e)&&kt(t[e]))return!0;return!1}function At(t){var n=[],i=t.data,r=t;return r.data=jt(i,n),r.attachments=n.length,{packet:r,buffers:n}}function jt(t,n){if(!t)return t;if(mt(t)){var i={_placeholder:!0,num:n.length};return n.push(t),i}if(Array.isArray(t)){for(var r=new Array(t.length),e=0;e<t.length;e++)r[e]=jt(t[e],n);return r}if("object"===c(t)&&!(t instanceof Date)){var o={};for(var s in t)Object.prototype.hasOwnProperty.call(t,s)&&(o[s]=jt(t[s],n));return o}return t}function Et(t,n){return t.data=Ot(t.data,n),delete t.attachments,t}function Ot(t,n){if(!t)return t;if(t&&!0===t._placeholder){if("number"==typeof t.num&&t.num>=0&&t.num<n.length)return n[t.num];throw new Error("illegal attachments")}if(Array.isArray(t))for(var i=0;i<t.length;i++)t[i]=Ot(t[i],n);else if("object"===c(t))for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&(t[r]=Ot(t[r],n));return t}var Bt,St=["connect","connect_error","disconnect","disconnecting","newListener","removeListener"];!function(t){t[t.CONNECT=0]="CONNECT",t[t.DISCONNECT=1]="DISCONNECT",t[t.EVENT=2]="EVENT",t[t.ACK=3]="ACK",t[t.CONNECT_ERROR=4]="CONNECT_ERROR",t[t.BINARY_EVENT=5]="BINARY_EVENT",t[t.BINARY_ACK=6]="BINARY_ACK"}(Bt||(Bt={}));var Nt=function(){function t(t){this.replacer=t}var n=t.prototype;return n.encode=function(t){return t.type!==Bt.EVENT&&t.type!==Bt.ACK||!kt(t)?[this.encodeAsString(t)]:this.encodeAsBinary({type:t.type===Bt.EVENT?Bt.BINARY_EVENT:Bt.BINARY_ACK,nsp:t.nsp,data:t.data,id:t.id})},n.encodeAsString=function(t){var n=""+t.type;return t.type!==Bt.BINARY_EVENT&&t.type!==Bt.BINARY_ACK||(n+=t.attachments+"-"),t.nsp&&"/"!==t.nsp&&(n+=t.nsp+","),null!=t.id&&(n+=t.id),null!=t.data&&(n+=JSON.stringify(t.data,this.replacer)),n},n.encodeAsBinary=function(t){var n=At(t),i=this.encodeAsString(n.packet),r=n.buffers;return r.unshift(i),r},t}(),Ct=function(t){function n(n){var i;return(i=t.call(this)||this).reviver=n,i}s(n,t);var i=n.prototype;return i.add=function(n){var i;if("string"==typeof n){if(this.reconstructor)throw new Error("got plaintext data when reconstructing a packet");var r=(i=this.decodeString(n)).type===Bt.BINARY_EVENT;r||i.type===Bt.BINARY_ACK?(i.type=r?Bt.EVENT:Bt.ACK,this.reconstructor=new Tt(i),0===i.attachments&&t.prototype.emitReserved.call(this,"decoded",i)):t.prototype.emitReserved.call(this,"decoded",i)}else{if(!mt(n)&&!n.base64)throw new Error("Unknown type: "+n);if(!this.reconstructor)throw new Error("got binary data when not reconstructing a packet");(i=this.reconstructor.takeBinaryData(n))&&(this.reconstructor=null,t.prototype.emitReserved.call(this,"decoded",i))}},i.decodeString=function(t){var i=0,r={type:Number(t.charAt(0))};if(void 0===Bt[r.type])throw new Error("unknown packet type "+r.type);if(r.type===Bt.BINARY_EVENT||r.type===Bt.BINARY_ACK){for(var e=i+1;"-"!==t.charAt(++i)&&i!=t.length;);var o=t.substring(e,i);if(o!=Number(o)||"-"!==t.charAt(i))throw new Error("Illegal attachments");r.attachments=Number(o)}if("/"===t.charAt(i+1)){for(var s=i+1;++i;){if(","===t.charAt(i))break;if(i===t.length)break}r.nsp=t.substring(s,i)}else r.nsp="/";var u=t.charAt(i+1);if(""!==u&&Number(u)==u){for(var h=i+1;++i;){var f=t.charAt(i);if(null==f||Number(f)!=f){--i;break}if(i===t.length)break}r.id=Number(t.substring(h,i+1))}if(t.charAt(++i)){var c=this.tryParse(t.substr(i));if(!n.isPayloadValid(r.type,c))throw new Error("invalid payload");r.data=c}return r},i.tryParse=function(t){try{return JSON.parse(t,this.reviver)}catch(t){return!1}},n.isPayloadValid=function(t,n){switch(t){case Bt.CONNECT:return Mt(n);case Bt.DISCONNECT:return void 0===n;case Bt.CONNECT_ERROR:return"string"==typeof n||Mt(n);case Bt.EVENT:case Bt.BINARY_EVENT:return Array.isArray(n)&&("number"==typeof n[0]||"string"==typeof n[0]&&-1===St.indexOf(n[0]));case Bt.ACK:case Bt.BINARY_ACK:return Array.isArray(n)}},i.destroy=function(){this.reconstructor&&(this.reconstructor.finishedReconstruction(),this.reconstructor=null)},n}(I),Tt=function(){function t(t){this.packet=t,this.buffers=[],this.reconPack=t}var n=t.prototype;return n.takeBinaryData=function(t){if(this.buffers.push(t),this.buffers.length===this.reconPack.attachments){var n=Et(this.reconPack,this.buffers);return this.finishedReconstruction(),n}return null},n.finishedReconstruction=function(){this.reconPack=null,this.buffers=[]},t}();var Ut=Number.isInteger||function(t){return"number"==typeof t&&isFinite(t)&&Math.floor(t)===t};function Mt(t){return"[object Object]"===Object.prototype.toString.call(t)}var xt=Object.freeze({__proto__:null,protocol:5,get PacketType(){return Bt},Encoder:Nt,Decoder:Ct,isPacketValid:function(t){return"string"==typeof t.nsp&&(void 0===(n=t.id)||Ut(n))&&function(t,n){switch(t){case Bt.CONNECT:return void 0===n||Mt(n);case Bt.DISCONNECT:return void 0===n;case Bt.EVENT:return Array.isArray(n)&&("number"==typeof n[0]||"string"==typeof n[0]&&-1===St.indexOf(n[0]));case Bt.ACK:return Array.isArray(n);case Bt.CONNECT_ERROR:return"string"==typeof n||Mt(n);default:return!1}}(t.type,t.data);var n}});function It(t,n,i){return t.on(n,i),function(){t.off(n,i)}}var Rt=Object.freeze({connect:1,connect_error:1,disconnect:1,disconnecting:1,newListener:1,removeListener:1}),Lt=function(t){function n(n,i,r){var o;return(o=t.call(this)||this).connected=!1,o.recovered=!1,o.receiveBuffer=[],o.sendBuffer=[],o.it=[],o.rt=0,o.ids=0,o.acks={},o.flags={},o.io=n,o.nsp=i,r&&r.auth&&(o.auth=r.auth),o.l=e({},r),o.io.et&&o.open(),o}s(n,t);var o=n.prototype;return o.subEvents=function(){if(!this.subs){var t=this.io;this.subs=[It(t,"open",this.onopen.bind(this)),It(t,"packet",this.onpacket.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this))]}},o.connect=function(){return this.connected||(this.subEvents(),this.io.ot||this.io.open(),"open"===this.io.st&&this.onopen()),this},o.open=function(){return this.connect()},o.send=function(){for(var t=arguments.length,n=new Array(t),i=0;i<t;i++)n[i]=arguments[i];return n.unshift("message"),this.emit.apply(this,n),this},o.emit=function(t){var n,i,r;if(Rt.hasOwnProperty(t))throw new Error('"'+t.toString()+'" is a reserved event name');for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s<e;s++)o[s-1]=arguments[s];if(o.unshift(t),this.l.retries&&!this.flags.fromQueue&&!this.flags.volatile)return this.ut(o),this;var u={type:Bt.EVENT,data:o,options:{}};if(u.options.compress=!1!==this.flags.compress,"function"==typeof o[o.length-1]){var h=this.ids++,f=o.pop();this.ht(h,f),u.id=h}var c=null===(i=null===(n=this.io.engine)||void 0===n?void 0:n.transport)||void 0===i?void 0:i.writable,a=this.connected&&!(null===(r=this.io.engine)||void 0===r?void 0:r.W());return this.flags.volatile&&!c||(a?(this.notifyOutgoingListeners(u),this.packet(u)):this.sendBuffer.push(u)),this.flags={},this},o.ht=function(t,n){var i,r=this,e=null!==(i=this.flags.timeout)&&void 0!==i?i:this.l.ackTimeout;if(void 0!==e){var o=this.io.setTimeoutFn((function(){delete r.acks[t];for(var i=0;i<r.sendBuffer.length;i++)r.sendBuffer[i].id===t&&r.sendBuffer.splice(i,1);n.call(r,new Error("operation has timed out"))}),e),s=function(){r.io.clearTimeoutFn(o);for(var t=arguments.length,i=new Array(t),e=0;e<t;e++)i[e]=arguments[e];n.apply(r,i)};s.withError=!0,this.acks[t]=s}else this.acks[t]=n},o.emitWithAck=function(t){for(var n=this,i=arguments.length,r=new Array(i>1?i-1:0),e=1;e<i;e++)r[e-1]=arguments[e];return new Promise((function(i,e){var o=function(t,n){return t?e(t):i(n)};o.withError=!0,r.push(o),n.emit.apply(n,[t].concat(r))}))},o.ut=function(t){var n,i=this;"function"==typeof t[t.length-1]&&(n=t.pop());var r={id:this.rt++,tryCount:0,pending:!1,args:t,flags:e({fromQueue:!0},this.flags)};t.push((function(t){if(r===i.it[0]){if(null!==t)r.tryCount>i.l.retries&&(i.it.shift(),n&&n(t));else if(i.it.shift(),n){for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s<e;s++)o[s-1]=arguments[s];n.apply(void 0,[null].concat(o))}return r.pending=!1,i.ft()}})),this.it.push(r),this.ft()},o.ft=function(){var t=arguments.length>0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this.it.length){var n=this.it[0];n.pending&&!t||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}},o.packet=function(t){t.nsp=this.nsp,this.io.ct(t)},o.onopen=function(){var t=this;"function"==typeof this.auth?this.auth((function(n){t.vt(n)})):this.vt(this.auth)},o.vt=function(t){this.packet({type:Bt.CONNECT,data:this.lt?e({pid:this.lt,offset:this.dt},t):t})},o.onerror=function(t){this.connected||this.emitReserved("connect_error",t)},o.onclose=function(t,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,n),this.yt()},o.yt=function(){var t=this;Object.keys(this.acks).forEach((function(n){if(!t.sendBuffer.some((function(t){return String(t.id)===n}))){var i=t.acks[n];delete t.acks[n],i.withError&&i.call(t,new Error("socket has been disconnected"))}}))},o.onpacket=function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var n=new Error(t.data.message);n.data=t.data.data,this.emitReserved("connect_error",n)}},o.onevent=function(t){var n=t.data||[];null!=t.id&&n.push(this.ack(t.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))},o.emitEvent=function(n){if(this.bt&&this.bt.length){var i,e=r(this.bt.slice());try{for(e.s();!(i=e.n()).done;){i.value.apply(this,n)}}catch(t){e.e(t)}finally{e.f()}}t.prototype.emit.apply(this,n),this.lt&&n.length&&"string"==typeof n[n.length-1]&&(this.dt=n[n.length-1])},o.ack=function(t){var n=this,i=!1;return function(){if(!i){i=!0;for(var r=arguments.length,e=new Array(r),o=0;o<r;o++)e[o]=arguments[o];n.packet({type:Bt.ACK,id:t,data:e})}}},o.onack=function(t){var n=this.acks[t.id];"function"==typeof n&&(delete this.acks[t.id],n.withError&&t.data.unshift(null),n.apply(this,t.data))},o.onconnect=function(t,n){this.id=t,this.recovered=n&&this.lt===n,this.lt=n,this.connected=!0,this.emitBuffered(),this.emitReserved("connect"),this.ft(!0)},o.emitBuffered=function(){var t=this;this.receiveBuffer.forEach((function(n){return t.emitEvent(n)})),this.receiveBuffer=[],this.sendBuffer.forEach((function(n){t.notifyOutgoingListeners(n),t.packet(n)})),this.sendBuffer=[]},o.ondisconnect=function(){this.destroy(),this.onclose("io server disconnect")},o.destroy=function(){this.subs&&(this.subs.forEach((function(t){return t()})),this.subs=void 0),this.io.wt(this)},o.disconnect=function(){return this.connected&&this.packet({type:Bt.DISCONNECT}),this.destroy(),this.connected&&this.onclose("io client disconnect"),this},o.close=function(){return this.disconnect()},o.compress=function(t){return this.flags.compress=t,this},o.timeout=function(t){return this.flags.timeout=t,this},o.onAny=function(t){return this.bt=this.bt||[],this.bt.push(t),this},o.prependAny=function(t){return this.bt=this.bt||[],this.bt.unshift(t),this},o.offAny=function(t){if(!this.bt)return this;if(t){for(var n=this.bt,i=0;i<n.length;i++)if(t===n[i])return n.splice(i,1),this}else this.bt=[];return this},o.listenersAny=function(){return this.bt||[]},o.onAnyOutgoing=function(t){return this.gt=this.gt||[],this.gt.push(t),this},o.prependAnyOutgoing=function(t){return this.gt=this.gt||[],this.gt.unshift(t),this},o.offAnyOutgoing=function(t){if(!this.gt)return this;if(t){for(var n=this.gt,i=0;i<n.length;i++)if(t===n[i])return n.splice(i,1),this}else this.gt=[];return this},o.listenersAnyOutgoing=function(){return this.gt||[]},o.notifyOutgoingListeners=function(t){if(this.gt&&this.gt.length){var n,i=r(this.gt.slice());try{for(i.s();!(n=i.n()).done;){n.value.apply(this,t.data)}}catch(t){i.e(t)}finally{i.f()}}},i(n,[{key:"disconnected",get:function(){return!this.connected}},{key:"active",get:function(){return!!this.subs}},{key:"volatile",get:function(){return this.flags.volatile=!0,this}}])}(I);function _t(t){t=t||{},this.ms=t.min||100,this.max=t.max||1e4,this.factor=t.factor||2,this.jitter=t.jitter>0&&t.jitter<=1?t.jitter:0,this.attempts=0}_t.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var n=Math.random(),i=Math.floor(n*this.jitter*t);t=1&Math.floor(10*n)?t+i:t-i}return 0|Math.min(t,this.max)},_t.prototype.reset=function(){this.attempts=0},_t.prototype.setMin=function(t){this.ms=t},_t.prototype.setMax=function(t){this.max=t},_t.prototype.setJitter=function(t){this.jitter=t};var Dt=function(t){function n(n,i){var r,e;(r=t.call(this)||this).nsps={},r.subs=[],n&&"object"===c(n)&&(i=n,n=void 0),(i=i||{}).path=i.path||"/socket.io",r.opts=i,$(r,i),r.reconnection(!1!==i.reconnection),r.reconnectionAttempts(i.reconnectionAttempts||1/0),r.reconnectionDelay(i.reconnectionDelay||1e3),r.reconnectionDelayMax(i.reconnectionDelayMax||5e3),r.randomizationFactor(null!==(e=i.randomizationFactor)&&void 0!==e?e:.5),r.backoff=new _t({min:r.reconnectionDelay(),max:r.reconnectionDelayMax(),jitter:r.randomizationFactor()}),r.timeout(null==i.timeout?2e4:i.timeout),r.st="closed",r.uri=n;var o=i.parser||xt;return r.encoder=new o.Encoder,r.decoder=new o.Decoder,r.et=!1!==i.autoConnect,r.et&&r.open(),r}s(n,t);var i=n.prototype;return i.reconnection=function(t){return arguments.length?(this.kt=!!t,t||(this.skipReconnect=!0),this):this.kt},i.reconnectionAttempts=function(t){return void 0===t?this.At:(this.At=t,this)},i.reconnectionDelay=function(t){var n;return void 0===t?this.jt:(this.jt=t,null===(n=this.backoff)||void 0===n||n.setMin(t),this)},i.randomizationFactor=function(t){var n;return void 0===t?this.Et:(this.Et=t,null===(n=this.backoff)||void 0===n||n.setJitter(t),this)},i.reconnectionDelayMax=function(t){var n;return void 0===t?this.Ot:(this.Ot=t,null===(n=this.backoff)||void 0===n||n.setMax(t),this)},i.timeout=function(t){return arguments.length?(this.Bt=t,this):this.Bt},i.maybeReconnectOnOpen=function(){!this.ot&&this.kt&&0===this.backoff.attempts&&this.reconnect()},i.open=function(t){var n=this;if(~this.st.indexOf("open"))return this;this.engine=new pt(this.uri,this.opts);var i=this.engine,r=this;this.st="opening",this.skipReconnect=!1;var e=It(i,"open",(function(){r.onopen(),t&&t()})),o=function(i){n.cleanup(),n.st="closed",n.emitReserved("error",i),t?t(i):n.maybeReconnectOnOpen()},s=It(i,"error",o);if(!1!==this.Bt){var u=this.Bt,h=this.setTimeoutFn((function(){e(),o(new Error("timeout")),i.close()}),u);this.opts.autoUnref&&h.unref(),this.subs.push((function(){n.clearTimeoutFn(h)}))}return this.subs.push(e),this.subs.push(s),this},i.connect=function(t){return this.open(t)},i.onopen=function(){this.cleanup(),this.st="open",this.emitReserved("open");var t=this.engine;this.subs.push(It(t,"ping",this.onping.bind(this)),It(t,"data",this.ondata.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this)),It(this.decoder,"decoded",this.ondecoded.bind(this)))},i.onping=function(){this.emitReserved("ping")},i.ondata=function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}},i.ondecoded=function(t){var n=this;R((function(){n.emitReserved("packet",t)}),this.setTimeoutFn)},i.onerror=function(t){this.emitReserved("error",t)},i.socket=function(t,n){var i=this.nsps[t];return i?this.et&&!i.active&&i.connect():(i=new Lt(this,t,n),this.nsps[t]=i),i},i.wt=function(t){for(var n=0,i=Object.keys(this.nsps);n<i.length;n++){var r=i[n];if(this.nsps[r].active)return}this.St()},i.ct=function(t){for(var n=this.encoder.encode(t),i=0;i<n.length;i++)this.engine.write(n[i],t.options)},i.cleanup=function(){this.subs.forEach((function(t){return t()})),this.subs.length=0,this.decoder.destroy()},i.St=function(){this.skipReconnect=!0,this.ot=!1,this.onclose("forced close")},i.disconnect=function(){return this.St()},i.onclose=function(t,n){var i;this.cleanup(),null===(i=this.engine)||void 0===i||i.close(),this.backoff.reset(),this.st="closed",this.emitReserved("close",t,n),this.kt&&!this.skipReconnect&&this.reconnect()},i.reconnect=function(){var t=this;if(this.ot||this.skipReconnect)return this;var n=this;if(this.backoff.attempts>=this.At)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ot=!1;else{var i=this.backoff.duration();this.ot=!0;var r=this.setTimeoutFn((function(){n.skipReconnect||(t.emitReserved("reconnect_attempt",n.backoff.attempts),n.skipReconnect||n.open((function(i){i?(n.ot=!1,n.reconnect(),t.emitReserved("reconnect_error",i)):n.onreconnect()})))}),i);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}},i.onreconnect=function(){var t=this.backoff.attempts;this.ot=!1,this.backoff.reset(),this.emitReserved("reconnect",t)},n}(I),Pt={};function $t(t,n){"object"===c(t)&&(n=t,t=void 0);var i,r=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,r=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var e=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+e+":"+r.port+n,r.href=r.protocol+"://"+e+(i&&i.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),e=r.source,o=r.id,s=r.path,u=Pt[o]&&s in Pt[o].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?i=new Dt(e,n):(Pt[o]||(Pt[o]=new Dt(e,n)),i=Pt[o]),r.query&&!n.query&&(n.query=r.queryKey),i.socket(r.path,n)}return e($t,{Manager:Dt,Socket:Lt,io:$t,connect:$t}),$t}));
7//# sourceMappingURL=socket.io.min.js.map
8
1{
2  "manifest_version": 3,
3  "name": "TreeOS Browser Bridge",
4  "version": "0.1.0",
5  "description": "Gives your TreeOS AI eyes and hands in the browser",
6  "permissions": [
7    "activeTab",
8    "tabs",
9    "scripting",
10    "storage",
11    "sidePanel"
12  ],
13  "host_permissions": ["<all_urls>"],
14  "background": {
15    "service_worker": "background.js"
16  },
17  "content_scripts": [
18    {
19      "matches": ["<all_urls>"],
20      "js": ["scripts/content.js"],
21      "run_at": "document_idle"
22    }
23  ],
24  "side_panel": {
25    "default_path": "sidepanel.html"
26  },
27  "action": {
28    "default_popup": "popup.html",
29    "default_icon": {
30      "16": "icons/icon16.png",
31      "48": "icons/icon48.png",
32      "128": "icons/icon128.png"
33    }
34  },
35  "icons": {
36    "16": "icons/icon16.png",
37    "48": "icons/icon48.png",
38    "128": "icons/icon128.png"
39  }
40}
41
1const $ = id => document.getElementById(id);
2
3const statusDot = $('statusDot');
4const statusBar = $('statusBar');
5const serverUrl = $('serverUrl');
6const apiKey = $('apiKey');
7const username = $('username');
8const loginPassword = $('loginPassword');
9const confirmActions = $('confirmActions');
10const autoCapture = $('autoCapture');
11const connectBtn = $('connectBtn');
12const disconnectBtn = $('disconnectBtn');
13const connectedActions = $('connectedActions');
14const confirmations = $('confirmations');
15
16function updateUI(state) {
17  const { connectionState, config } = state;
18
19  statusDot.className = `status-dot ${connectionState}`;
20  statusBar.textContent = connectionState === 'connected'
21    ? `Connected to ${config.serverUrl}`
22    : connectionState === 'connecting'
23    ? 'Connecting...'
24    : 'Not connected';
25
26  connectBtn.style.display = connectionState === 'connected' ? 'none' : '';
27  disconnectBtn.style.display = connectionState === 'connected' ? '' : 'none';
28  connectedActions.style.display = connectionState === 'connected' ? '' : 'none';
29
30  if (config.serverUrl && !serverUrl.value) serverUrl.value = config.serverUrl;
31  confirmActions.checked = config.confirmActions !== false;
32  autoCapture.checked = !!config.autoCapture;
33}
34
35// Load initial state
36chrome.runtime.sendMessage({ type: 'getState' }, (response) => {
37  if (response) updateUI(response);
38});
39
40// Load saved config
41chrome.storage.local.get(['treeos_config'], (result) => {
42  if (result.treeos_config) {
43    serverUrl.value = result.treeos_config.serverUrl || '';
44    apiKey.value = result.treeos_config.apiKey || '';
45    confirmActions.checked = result.treeos_config.confirmActions !== false;
46    autoCapture.checked = !!result.treeos_config.autoCapture;
47  }
48});
49
50// Save config
51$('saveBtn').addEventListener('click', () => {
52  chrome.runtime.sendMessage({
53    type: 'saveConfig',
54    config: {
55      serverUrl: serverUrl.value.replace(/\/+$/, ''),
56      apiKey: apiKey.value,
57      username: username.value,
58      password: loginPassword.value,
59      confirmActions: confirmActions.checked,
60      autoCapture: autoCapture.checked,
61    },
62  }, (resp) => {
63    statusBar.textContent = 'Settings saved';
64    setTimeout(() => chrome.runtime.sendMessage({ type: 'getState' }, updateUI), 1000);
65  });
66});
67
68// Connect
69connectBtn.addEventListener('click', () => {
70  // Save first, then connect
71  chrome.runtime.sendMessage({
72    type: 'saveConfig',
73    config: {
74      serverUrl: serverUrl.value.replace(/\/+$/, ''),
75      apiKey: apiKey.value,
76      username: username.value,
77      password: loginPassword.value,
78      confirmActions: confirmActions.checked,
79      autoCapture: autoCapture.checked,
80    },
81  }, () => {
82    chrome.runtime.sendMessage({ type: 'connect' });
83  });
84});
85
86// Disconnect
87disconnectBtn.addEventListener('click', () => {
88  chrome.runtime.sendMessage({ type: 'disconnect' });
89});
90
91// Manual capture
92$('captureBtn').addEventListener('click', () => {
93  chrome.runtime.sendMessage({ type: 'manualCapture' }, (resp) => {
94    statusBar.textContent = resp?.sent ? 'Page state sent' : 'Captured (not connected)';
95  });
96});
97
98// Screenshot
99$('screenshotBtn').addEventListener('click', () => {
100  chrome.runtime.sendMessage({ type: 'manualScreenshot' }, (resp) => {
101    statusBar.textContent = resp?.sent ? 'Screenshot sent' : 'Captured (not connected)';
102  });
103});
104
105// Open side panel
106$('openPanelBtn').addEventListener('click', async () => {
107  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
108  if (tab) {
109    chrome.sidePanel.open({ tabId: tab.id });
110    window.close();
111  }
112});
113
114// Listen for state updates
115chrome.runtime.onMessage.addListener((msg) => {
116  if (msg.type === 'stateUpdate') {
117    updateUI(msg);
118  }
119
120  if (msg.type === 'activity') {
121    const e = msg.entry;
122    if (e.action === 'getPageState') statusBar.textContent = 'AI read page state';
123    else if (e.action === 'screenshot') statusBar.textContent = 'AI took screenshot';
124    else if (e.action === 'action') statusBar.textContent = `AI: ${e.details.type} ${e.details.target || ''} ${e.details.success ? '✓' : '✗'}`;
125  }
126
127  if (msg.type === 'confirmAction') {
128    const card = document.createElement('div');
129    card.className = 'confirm-card';
130    card.innerHTML = `
131      <p>${msg.description}</p>
132      <div class="confirm-buttons">
133        <button class="btn-primary allow-btn">Allow</button>
134        <button class="btn-danger deny-btn">Deny</button>
135      </div>
136    `;
137    card.querySelector('.allow-btn').onclick = () => {
138      chrome.runtime.sendMessage({ type: 'confirmActionResponse', confirmId: msg.confirmId, allowed: true });
139      card.remove();
140    };
141    card.querySelector('.deny-btn').onclick = () => {
142      chrome.runtime.sendMessage({ type: 'confirmActionResponse', confirmId: msg.confirmId, allowed: false });
143      card.remove();
144    };
145    confirmations.appendChild(card);
146  }
147});
148
1// TreeOS Browser Bridge — Content Script
2// Runs in every page. Captures accessibility tree, executes agent actions.
3
4(() => {
5  const INTERACTIVE_ROLES = new Set([
6    'button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio',
7    'combobox', 'listbox', 'option', 'menuitem', 'tab', 'switch',
8    'slider', 'spinbutton', 'scrollbar', 'menu', 'menubar',
9    'tree', 'treeitem', 'gridcell', 'row', 'columnheader', 'rowheader'
10  ]);
11
12  const INTERACTIVE_TAGS = new Set([
13    'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'DETAILS', 'SUMMARY'
14  ]);
15
16  const SKIP_TAGS = new Set([
17    'SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG', 'PATH', 'META', 'LINK', 'BR', 'HR'
18  ]);
19
20  // Element registry — maps short IDs to DOM elements for action execution
21  let elementRegistry = new Map();
22  let nextId = 1;
23
24  function resetRegistry() {
25    elementRegistry.clear();
26    nextId = 1;
27  }
28
29  function registerId(el) {
30    const id = `e${nextId++}`;
31    elementRegistry.set(id, el);
32    return id;
33  }
34
35  // ── Accessibility Tree Builder ──────────────────────────────────
36
37  function getRole(el) {
38    // Explicit ARIA role
39    const ariaRole = el.getAttribute('role');
40    if (ariaRole) return ariaRole;
41
42    // Implicit role from tag
43    const tag = el.tagName;
44    const type = el.getAttribute('type');
45
46    const implicitRoles = {
47      'A': el.hasAttribute('href') ? 'link' : null,
48      'BUTTON': 'button',
49      'INPUT': {
50        'checkbox': 'checkbox', 'radio': 'radio', 'range': 'slider',
51        'search': 'searchbox', 'submit': 'button', 'reset': 'button',
52        'text': 'textbox', 'email': 'textbox', 'password': 'textbox',
53        'tel': 'textbox', 'url': 'textbox', 'number': 'spinbutton',
54      }[type] || 'textbox',
55      'SELECT': 'combobox',
56      'TEXTAREA': 'textbox',
57      'H1': 'heading', 'H2': 'heading', 'H3': 'heading',
58      'H4': 'heading', 'H5': 'heading', 'H6': 'heading',
59      'NAV': 'navigation',
60      'MAIN': 'main',
61      'HEADER': 'banner',
62      'FOOTER': 'contentinfo',
63      'ASIDE': 'complementary',
64      'FORM': 'form',
65      'TABLE': 'table',
66      'UL': 'list', 'OL': 'list',
67      'LI': 'listitem',
68      'IMG': 'img',
69      'DETAILS': 'group',
70      'SUMMARY': 'button',
71      'DIALOG': 'dialog',
72    };
73
74    const role = implicitRoles[tag];
75    if (typeof role === 'string') return role;
76    return null;
77  }
78
79  function getAccessibleName(el) {
80    // aria-label takes priority
81    const ariaLabel = el.getAttribute('aria-label');
82    if (ariaLabel) return ariaLabel.trim();
83
84    // aria-labelledby
85    const labelledBy = el.getAttribute('aria-labelledby');
86    if (labelledBy) {
87      const names = labelledBy.split(/\s+/).map(id => {
88        const ref = document.getElementById(id);
89        return ref ? ref.textContent.trim() : '';
90      }).filter(Boolean);
91      if (names.length) return names.join(' ');
92    }
93
94    // Associated label (for inputs)
95    if (el.id) {
96      const label = document.querySelector(`label[for="${el.id}"]`);
97      if (label) return label.textContent.trim();
98    }
99
100    // Closest wrapping label
101    const parentLabel = el.closest('label');
102    if (parentLabel && parentLabel !== el) {
103      // Get label text excluding the input's own text
104      const clone = parentLabel.cloneNode(true);
105      clone.querySelectorAll('input,select,textarea').forEach(c => c.remove());
106      const text = clone.textContent.trim();
107      if (text) return text;
108    }
109
110    // Title attribute
111    const title = el.getAttribute('title');
112    if (title) return title.trim();
113
114    // Alt text for images
115    if (el.tagName === 'IMG') return el.getAttribute('alt')?.trim() || '';
116
117    // Placeholder for inputs
118    const placeholder = el.getAttribute('placeholder');
119    if (placeholder) return placeholder.trim();
120
121    // Value for inputs
122    if (el.tagName === 'INPUT' && el.value) return el.value.trim();
123
124    // Direct text content (only for leaf-ish elements)
125    const directText = getDirectText(el);
126    if (directText) return directText;
127
128    return '';
129  }
130
131  function getDirectText(el) {
132    // Get just this element's direct text, not deeply nested text
133    let text = '';
134    for (const child of el.childNodes) {
135      if (child.nodeType === Node.TEXT_NODE) {
136        text += child.textContent;
137      }
138    }
139    return text.trim().slice(0, 200);
140  }
141
142  function isVisible(el) {
143    if (!el.offsetParent && el.tagName !== 'BODY' && el.tagName !== 'HTML') {
144      // Could be position:fixed or visibility trick
145      const style = getComputedStyle(el);
146      if (style.display === 'none') return false;
147      if (style.visibility === 'hidden') return false;
148      if (style.position !== 'fixed' && style.position !== 'sticky') return false;
149    }
150    const rect = el.getBoundingClientRect();
151    if (rect.width === 0 && rect.height === 0) return false;
152    return true;
153  }
154
155  function isInteractive(el) {
156    if (INTERACTIVE_TAGS.has(el.tagName)) return true;
157    if (el.getAttribute('role') && INTERACTIVE_ROLES.has(el.getAttribute('role'))) return true;
158    if (el.getAttribute('onclick') || el.getAttribute('tabindex')) return true;
159    if (el.contentEditable === 'true') return true;
160
161    // Check for click listeners heuristic — elements with cursor:pointer
162    const style = getComputedStyle(el);
163    if (style.cursor === 'pointer') return true;
164
165    return false;
166  }
167
168  function getElementState(el) {
169    const state = {};
170    if (el.disabled) state.disabled = true;
171    if (el.checked) state.checked = true;
172    if (el.getAttribute('aria-expanded')) state.expanded = el.getAttribute('aria-expanded') === 'true';
173    if (el.getAttribute('aria-selected')) state.selected = el.getAttribute('aria-selected') === 'true';
174    if (el.getAttribute('aria-pressed')) state.pressed = el.getAttribute('aria-pressed') === 'true';
175    if (el.getAttribute('aria-hidden') === 'true') state.hidden = true;
176    if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
177      state.value = el.value || '';
178    }
179    if (el.tagName === 'SELECT') {
180      state.value = el.value || '';
181      state.options = Array.from(el.options).map(o => ({ value: o.value, text: o.text, selected: o.selected }));
182    }
183    return Object.keys(state).length ? state : null;
184  }
185
186  function buildTree(root, depth = 0, maxDepth = 25) {
187    if (depth > maxDepth) return null;
188
189    const nodes = [];
190
191    for (const child of root.children || []) {
192      if (!child.tagName) continue;
193      if (SKIP_TAGS.has(child.tagName)) continue;
194      if (!isVisible(child)) continue;
195
196      const role = getRole(child);
197      const interactive = isInteractive(child);
198      const name = getAccessibleName(child);
199
200      let node = null;
201
202      if (interactive || role) {
203        node = { role: role || child.tagName.toLowerCase() };
204        if (name) node.name = name;
205        if (interactive) node.id = registerId(child);
206
207        const state = getElementState(child);
208        if (state) node.state = state;
209
210        // Get link href
211        if (child.tagName === 'A' && child.href) {
212          node.href = child.href;
213        }
214      }
215
216      // Recurse into children
217      const childNodes = buildTree(child, depth + 1, maxDepth);
218
219      if (node) {
220        if (childNodes.length) node.children = childNodes;
221        nodes.push(node);
222      } else if (childNodes.length) {
223        // Flatten — skip this container, promote children
224        nodes.push(...childNodes);
225      } else if (name && (!child.children || child.children.length === 0)) {
226        // Leaf text node worth including
227        const textContent = child.textContent.trim();
228        if (textContent.length > 1 && textContent.length < 500) {
229          nodes.push({ role: 'text', name: textContent.slice(0, 200) });
230        }
231      }
232    }
233
234    return nodes;
235  }
236
237  function capturePageState() {
238    resetRegistry();
239
240    const tree = buildTree(document.body);
241
242    // Gather some metadata
243    const state = {
244      url: location.href,
245      title: document.title,
246      tree: tree,
247      viewport: {
248        width: window.innerWidth,
249        height: window.innerHeight,
250        scrollY: window.scrollY,
251        scrollHeight: document.documentElement.scrollHeight,
252      },
253      timestamp: Date.now(),
254    };
255
256    return state;
257  }
258
259  // ── Action Executor ─────────────────────────────────────────────
260
261  function simulateClick(el) {
262    el.scrollIntoView({ behavior: 'instant', block: 'center' });
263    el.focus();
264    el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
265  }
266
267  function simulateType(el, text, clear = true) {
268    el.scrollIntoView({ behavior: 'instant', block: 'center' });
269    el.focus();
270
271    // Contenteditable elements (rich text editors like Reddit, Slack, etc.)
272    if (el.contentEditable === 'true' || el.getAttribute('role') === 'textbox') {
273      if (clear) el.textContent = '';
274      el.textContent = text;
275      el.dispatchEvent(new Event('input', { bubbles: true }));
276      // Also try execCommand for React-based editors
277      try {
278        document.execCommand('selectAll', false, null);
279        document.execCommand('insertText', false, text);
280      } catch {}
281      return;
282    }
283
284    // Regular inputs and textareas
285    if (clear) {
286      el.value = '';
287      el.dispatchEvent(new Event('input', { bubbles: true }));
288    }
289
290    el.value = text;
291    el.dispatchEvent(new Event('input', { bubbles: true }));
292    el.dispatchEvent(new Event('change', { bubbles: true }));
293  }
294
295  function simulateKeyPress(el, key) {
296    el.focus();
297    const opts = { key, bubbles: true, cancelable: true };
298    el.dispatchEvent(new KeyboardEvent('keydown', opts));
299    el.dispatchEvent(new KeyboardEvent('keypress', opts));
300    el.dispatchEvent(new KeyboardEvent('keyup', opts));
301  }
302
303  async function executeAction(action) {
304    try {
305      switch (action.type) {
306        case 'click': {
307          const el = elementRegistry.get(action.elementId);
308          if (!el) return { success: false, error: `Element ${action.elementId} not found` };
309          simulateClick(el);
310          return { success: true, action: 'clicked', elementId: action.elementId };
311        }
312
313        case 'type': {
314          const el = elementRegistry.get(action.elementId);
315          if (!el) return { success: false, error: `Element ${action.elementId} not found` };
316          simulateType(el, action.text, action.clear !== false);
317          return { success: true, action: 'typed', elementId: action.elementId };
318        }
319
320        case 'keypress': {
321          const el = action.elementId
322            ? elementRegistry.get(action.elementId)
323            : document.activeElement;
324          if (!el) return { success: false, error: 'No target element' };
325          simulateKeyPress(el, action.key);
326          return { success: true, action: 'keypress', key: action.key };
327        }
328
329        case 'select': {
330          const el = elementRegistry.get(action.elementId);
331          if (!el) return { success: false, error: `Element ${action.elementId} not found` };
332          el.value = action.value;
333          el.dispatchEvent(new Event('change', { bubbles: true }));
334          return { success: true, action: 'selected', value: action.value };
335        }
336
337        case 'scroll': {
338          const amount = action.amount || 500;
339          const direction = action.direction || 'down';
340          const y = direction === 'up' ? -amount : amount;
341          window.scrollBy({ top: y, behavior: 'smooth' });
342          return { success: true, action: 'scrolled', direction, amount };
343        }
344
345        case 'navigate': {
346          window.location.href = action.url;
347          return { success: true, action: 'navigated', url: action.url };
348        }
349
350        case 'back': {
351          history.back();
352          return { success: true, action: 'back' };
353        }
354
355        case 'forward': {
356          history.forward();
357          return { success: true, action: 'forward' };
358        }
359
360        case 'wait': {
361          await new Promise(r => setTimeout(r, action.ms || 1000));
362          return { success: true, action: 'waited', ms: action.ms || 1000 };
363        }
364
365        case 'extract': {
366          // Extract text content from the page or a specific element
367          if (action.elementId) {
368            const el = elementRegistry.get(action.elementId);
369            if (!el) return { success: false, error: `Element ${action.elementId} not found` };
370            return { success: true, text: el.textContent.trim() };
371          }
372          // Extract all visible text
373          const text = document.body.innerText.slice(0, 10000);
374          return { success: true, text };
375        }
376
377        case 'screenshot': {
378          // Content scripts can't take screenshots — delegate to background
379          return { success: false, error: 'screenshot must be handled by background script' };
380        }
381
382        case 'comment': {
383          const text = action.text;
384          const replyTo = action.replyTo;
385          if (!text) return { success: false, error: 'text is required for comment action' };
386
387          let textbox = null;
388
389          // ── FIND OR OPEN A COMMENT BOX ──
390
391          if (replyTo) {
392            // Reply to specific user: find their comment, click its reply button
393            const cleanName = replyTo.replace(/^u\//, '').toLowerCase();
394            const links = document.querySelectorAll('a');
395            let scope = null;
396            for (const link of links) {
397              if ((link.href || '').toLowerCase().includes('/user/' + cleanName)) {
398                scope = link.closest('shreddit-comment, article, .comment');
399                if (scope) break;
400              }
401            }
402            if (scope) {
403              const row = scope.querySelector('shreddit-comment-action-row');
404              if (row) {
405                const btns = row.querySelectorAll('button');
406                if (btns.length >= 1) simulateClick(btns[0]);
407                await new Promise(r => setTimeout(r, 1500));
408              }
409              textbox = scope.querySelector('[contenteditable="true"], textarea');
410            }
411          }
412
413          if (!textbox) {
414            // Check for already open textbox (user clicked reply manually, or previous attempt)
415            const all = document.querySelectorAll('[contenteditable="true"], textarea');
416            for (const el of all) {
417              const r = el.getBoundingClientRect();
418              if (r.height > 30 && r.width > 100) { textbox = el; break; }
419            }
420          }
421
422          if (!textbox) {
423            // Nothing open. Click the first action row's first button (reply on first comment or post)
424            const row = document.querySelector('shreddit-comment-action-row');
425            if (row) {
426              const btns = row.querySelectorAll('button');
427              if (btns.length >= 1) {
428                simulateClick(btns[0]);
429                await new Promise(r => setTimeout(r, 1500));
430              }
431            }
432            // Find what appeared
433            const all = document.querySelectorAll('[contenteditable="true"], textarea');
434            for (const el of all) {
435              const r = el.getBoundingClientRect();
436              if (r.height > 30 && r.width > 100) { textbox = el; break; }
437            }
438            if (!textbox && all.length) textbox = all[all.length - 1];
439          }
440
441          if (!textbox) return { success: false, error: 'Could not find or open a comment box.' };
442
443          // ── TYPE TEXT ──
444
445          textbox.scrollIntoView({ behavior: 'instant', block: 'center' });
446          textbox.focus();
447          await new Promise(r => setTimeout(r, 300));
448
449          let typed = false;
450          try {
451            const sel = window.getSelection();
452            const range = document.createRange();
453            range.selectNodeContents(textbox);
454            sel.removeAllRanges();
455            sel.addRange(range);
456            typed = document.execCommand('insertText', false, text);
457          } catch {}
458
459          if (!typed) {
460            if (textbox.contentEditable === 'true') {
461              textbox.innerHTML = '<p>' + text + '</p>';
462            } else {
463              textbox.value = text;
464            }
465            textbox.dispatchEvent(new Event('input', { bubbles: true }));
466          }
467
468          await new Promise(r => setTimeout(r, 1000));
469          textbox.dispatchEvent(new Event('input', { bubbles: true }));
470          await new Promise(r => setTimeout(r, 500));
471
472          // ── VERIFY TEXT LANDED ──
473          const hasText = (textbox.textContent || '').includes(text) || (textbox.value || '').includes(text);
474          if (!hasText) {
475            // Text didn't register. Try direct content set as last resort.
476            if (textbox.contentEditable === 'true') {
477              textbox.innerHTML = '<p>' + text + '</p>';
478            } else {
479              textbox.value = text;
480            }
481            textbox.dispatchEvent(new Event('input', { bubbles: true }));
482            await new Promise(r => setTimeout(r, 1000));
483
484            // Check again
485            const hasTextRetry = (textbox.textContent || '').includes(text) || (textbox.value || '').includes(text);
486            if (!hasTextRetry) {
487              return { success: false, typed: false, submitted: false, error: 'Text did not register in comment box.' };
488            }
489          }
490
491          // ── CLICK SUBMIT ──
492          // Find the submit button near the textbox (not just any button on the page)
493
494          let submitBtn = null;
495          const submitWords = ['comment', 'reply', 'post', 'submit', 'send', 'save'];
496
497          // Search near the textbox first (parent, siblings, nearby containers)
498          let searchScope = textbox.parentElement;
499          for (let i = 0; i < 5 && searchScope && !submitBtn; i++) {
500            const spans = searchScope.querySelectorAll('button span[slot="content"], button span');
501            for (const span of spans) {
502              const t = span.textContent.trim().toLowerCase();
503              if (submitWords.includes(t)) {
504                submitBtn = span.closest('button');
505                break;
506              }
507            }
508            searchScope = searchScope.parentElement;
509          }
510
511          // Fallback: any button with type=submit
512          if (!submitBtn) submitBtn = document.querySelector('button[type="submit"]');
513
514          if (!submitBtn) return { success: true, typed: true, submitted: false, error: 'Typed but could not find submit button.' };
515
516          simulateClick(submitBtn);
517          return { success: true, typed: true, submitted: true, text };
518        }
519
520        default:
521          return { success: false, error: `Unknown action type: ${action.type}` };
522      }
523    } catch (err) {
524      return { success: false, error: err.message };
525    }
526  }
527
528  // ── Network Interception (optional) ─────────────────────────────
529
530  const interceptedRequests = [];
531  const MAX_INTERCEPTED = 50;
532
533  function installNetworkInterceptor() {
534    // Intercept fetch
535    const originalFetch = window.fetch;
536    window.fetch = async function (...args) {
537      const req = args[0];
538      const url = typeof req === 'string' ? req : req?.url;
539      const method = args[1]?.method || (typeof req === 'object' ? req.method : 'GET');
540
541      const entry = {
542        type: 'fetch',
543        url, method,
544        timestamp: Date.now(),
545      };
546
547      try {
548        const response = await originalFetch.apply(this, args);
549        entry.status = response.status;
550        interceptedRequests.push(entry);
551        if (interceptedRequests.length > MAX_INTERCEPTED) interceptedRequests.shift();
552        return response;
553      } catch (err) {
554        entry.error = err.message;
555        interceptedRequests.push(entry);
556        if (interceptedRequests.length > MAX_INTERCEPTED) interceptedRequests.shift();
557        throw err;
558      }
559    };
560
561    // Intercept XMLHttpRequest
562    const originalOpen = XMLHttpRequest.prototype.open;
563    const originalSend = XMLHttpRequest.prototype.send;
564
565    XMLHttpRequest.prototype.open = function (method, url) {
566      this._intercepted = { method, url, timestamp: Date.now(), type: 'xhr' };
567      return originalOpen.apply(this, arguments);
568    };
569
570    XMLHttpRequest.prototype.send = function () {
571      if (this._intercepted) {
572        this.addEventListener('load', () => {
573          this._intercepted.status = this.status;
574          interceptedRequests.push(this._intercepted);
575          if (interceptedRequests.length > MAX_INTERCEPTED) interceptedRequests.shift();
576        });
577      }
578      return originalSend.apply(this, arguments);
579    };
580  }
581
582  // ── Message Handler ─────────────────────────────────────────────
583
584  chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
585    (async () => {
586      switch (msg.type) {
587        case 'ping': {
588          sendResponse({ alive: true });
589          break;
590        }
591        case 'getPageState': {
592          const state = capturePageState();
593          // Include intercepted network calls if requested
594          if (msg.includeNetwork) {
595            state.networkRequests = [...interceptedRequests];
596          }
597          sendResponse(state);
598          break;
599        }
600
601        case 'executeAction': {
602          // If page state is stale, recapture before executing
603          if (msg.recapture) capturePageState();
604          const result = await executeAction(msg.action);
605          sendResponse(result);
606          break;
607        }
608
609        case 'getNetworkLog': {
610          sendResponse({ requests: [...interceptedRequests] });
611          break;
612        }
613
614        case 'installInterceptor': {
615          installNetworkInterceptor();
616          sendResponse({ success: true });
617          break;
618        }
619
620        case 'ping': {
621          sendResponse({ alive: true, url: location.href });
622          break;
623        }
624
625        default:
626          sendResponse({ error: 'Unknown message type' });
627      }
628    })();
629    return true; // Keep channel open for async response
630  });
631
632  // Auto-install network interceptor
633  installNetworkInterceptor();
634})();
635
1const statusDot = document.getElementById('statusDot');
2const logView = document.getElementById('logView');
3const treeView = document.getElementById('treeView');
4const tabLog = document.getElementById('tabLog');
5const tabTree = document.getElementById('tabTree');
6let currentView = 'log';
7let logStarted = false;
8
9// Tab switching
10tabLog.addEventListener('click', () => {
11  currentView = 'log';
12  logView.style.display = 'block';
13  treeView.style.display = 'none';
14  tabLog.classList.add('active');
15  tabTree.classList.remove('active');
16});
17
18tabTree.addEventListener('click', async () => {
19  currentView = 'tree';
20  logView.style.display = 'none';
21  treeView.style.display = 'block';
22  tabLog.classList.remove('active');
23  tabTree.classList.add('active');
24  treeView.innerHTML = '<div class="empty-state"><div>Loading...</div></div>';
25  chrome.runtime.sendMessage({ type: 'manualCapture' }, (resp) => {
26    const state = resp?.state;
27    if (state?.error) {
28      treeView.innerHTML = `<div class="empty-state"><div>Error</div><div style="font-size:11px">${escHtml(state.error)}</div></div>`;
29      return;
30    }
31    const tree = state?.tree;
32    if (tree) {
33      treeView.innerHTML = '';
34      if (state.url) {
35        const header = document.createElement('div');
36        header.style.cssText = 'padding:4px 0 8px;font-size:11px;color:#888;border-bottom:1px solid #ffffff10;margin-bottom:8px;';
37        header.textContent = state.url;
38        treeView.appendChild(header);
39      }
40      renderTree(Array.isArray(tree) ? tree : [tree]);
41    } else {
42      treeView.innerHTML = '<div class="empty-state"><div>Could not load page tree</div><div style="font-size:11px">Try refreshing the tab, then click Page Tree again</div></div>';
43    }
44  });
45});
46
47// Manual actions
48document.getElementById('sendState').addEventListener('click', () => {
49  chrome.runtime.sendMessage({ type: 'manualCapture' }, () => {
50    addLog('state', 'Page state sent');
51  });
52});
53
54document.getElementById('sendScreenshot').addEventListener('click', () => {
55  chrome.runtime.sendMessage({ type: 'manualScreenshot' }, () => {
56    addLog('state', 'Screenshot sent');
57  });
58});
59
60// Logging
61function addLog(type, content) {
62  if (!logStarted) {
63    logView.innerHTML = '';
64    logStarted = true;
65  }
66  const entry = document.createElement('div');
67  entry.className = `log-entry ${type}`;
68  const now = new Date().toLocaleTimeString();
69  entry.innerHTML = `<div class="log-time">${now}</div><div class="log-content">${content}</div>`;
70  logView.appendChild(entry);
71  logView.scrollTop = logView.scrollHeight;
72}
73
74function addConfirmation(confirmId, description) {
75  if (!logStarted) { logView.innerHTML = ''; logStarted = true; }
76  const entry = document.createElement('div');
77  entry.className = 'log-entry confirm';
78  entry.id = `confirm-${confirmId}`;
79  const now = new Date().toLocaleTimeString();
80  entry.innerHTML = `
81    <div class="log-time">${now}</div>
82    <div class="log-content">Agent wants to: <code>${description}</code></div>
83    <div class="confirm-inline">
84      <button class="allow">Allow</button>
85      <button class="deny">Deny</button>
86    </div>
87  `;
88  entry.querySelector('.allow').onclick = () => {
89    chrome.runtime.sendMessage({ type: 'confirmActionResponse', confirmId, allowed: true });
90    entry.querySelector('.confirm-inline').innerHTML = '<span style="color:#40c060">Allowed</span>';
91  };
92  entry.querySelector('.deny').onclick = () => {
93    chrome.runtime.sendMessage({ type: 'confirmActionResponse', confirmId, allowed: false });
94    entry.querySelector('.confirm-inline').innerHTML = '<span style="color:#c04040">Denied</span>';
95  };
96  logView.appendChild(entry);
97  logView.scrollTop = logView.scrollHeight;
98}
99
100// Tree rendering
101function renderTree(nodes, depth) {
102  if (depth === undefined) { depth = 0; treeView.innerHTML = ''; }
103  for (const node of nodes) {
104    const div = document.createElement('div');
105    div.className = `tree-node${node.id ? ' interactive' : ''}`;
106    div.style.paddingLeft = `${depth * 16}px`;
107    let html = `<span class="role">${node.role}</span>`;
108    if (node.name) html += ` <span class="name">"${escHtml(node.name.slice(0, 60))}"</span>`;
109    if (node.id) html += ` <span class="id">[${node.id}]</span>`;
110    div.innerHTML = html;
111    treeView.appendChild(div);
112    if (node.children) renderTree(node.children, depth + 1);
113  }
114}
115
116function escHtml(s) {
117  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
118}
119
120// Clear activity
121document.getElementById('clearLog').addEventListener('click', () => {
122  logView.innerHTML = '';
123  logStarted = false;
124});
125
126// Listen for updates
127chrome.runtime.onMessage.addListener((msg) => {
128  if (msg.type === 'stateUpdate') {
129    statusDot.className = `status-dot ${msg.connectionState}`;
130    if (msg.connectionState === 'connected') {
131      addLog('state', `Connected to <code>${msg.config.serverUrl}</code>`);
132    } else if (msg.connectionState === 'disconnected') {
133      addLog('error', 'Disconnected');
134    }
135  }
136
137  if (msg.type === 'confirmAction') {
138    addConfirmation(msg.confirmId, msg.description);
139  }
140
141  if (msg.type === 'activity') {
142    const e = msg.entry;
143    const type = e.action === 'action' ? 'action' : 'state';
144    let desc = '';
145    if (e.action === 'getPageState') desc = 'Read page state';
146    else if (e.action === 'screenshot') desc = 'Took screenshot';
147    else if (e.action === 'action') desc = `<code>${e.details.type}</code> ${e.details.target ? 'on <code>' + escHtml(String(e.details.target)) + '</code>' : ''} ${e.details.success ? '\u2713' : '\u2717'}`;
148    else desc = e.action;
149    addLog(type, desc);
150  }
151});
152
153// Initial state + replay activity log
154chrome.runtime.sendMessage({ type: 'getState' }, (resp) => {
155  if (resp) {
156    statusDot.className = `status-dot ${resp.connectionState}`;
157    if (resp.connectionState === 'connected') {
158      addLog('state', `Connected to <code>${resp.config?.serverUrl || 'server'}</code>`);
159    }
160  }
161});
162
163chrome.runtime.sendMessage({ type: 'getActivityLog' }, (resp) => {
164  if (resp?.log?.length) {
165    resp.log.forEach(e => {
166      const type = e.action === 'action' ? 'action' : 'state';
167      let desc = '';
168      if (e.action === 'getPageState') desc = 'Read page state';
169      else if (e.action === 'screenshot') desc = 'Took screenshot';
170      else if (e.action === 'action') desc = `<code>${e.details.type}</code> ${e.details.target ? 'on <code>' + escHtml(String(e.details.target)) + '</code>' : ''} ${e.details.success ? '\u2713' : '\u2717'}`;
171      else desc = e.action;
172      addLog(type, desc);
173    });
174  }
175});
176

Versions

Version Published Downloads
1.0.3 38d ago 0
1.0.2 46d ago 0
1.0.1 47d ago 0
1.0.0 47d ago 0
0 stars
0 flags
React from the CLI: treeos ext star browser-bridge

Comments

Loading comments...

Post comments from the CLI: treeos ext comment browser-bridge "your comment"
Max 3 comments per extension. One star and one flag per user.