@tiens.nguyen/gu-cli 1.0.833 → 1.0.834

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,483 @@
1
+ // gu browser — the Chrome side of the agent's browser tool (task #363, GitHub #144).
2
+ //
3
+ // The extension connects to gu's local bridge (browser-bridge.mjs, ws://127.0.0.1:4127) and runs
4
+ // the commands the agent sends. Input goes through chrome.debugger (the DevTools protocol), so a
5
+ // click is a real mouse press and release at a point, and typing is real key input, the way a
6
+ // person would do it, not el.click().
7
+ //
8
+ // gu only works in ITS OWN tabs: the ones it opened (kept in a tab group named "gu") and the
9
+ // popups those open. The user's other tabs are never read, captured or controlled.
10
+
11
+ const DEFAULT_PORT = 4127;
12
+ const VERSION = chrome.runtime.getManifest().version;
13
+
14
+ let ws = null;
15
+ let connecting = false; // set before the first await: startup fires connect() several times at once
16
+ let pingTimer = null;
17
+ const attached = new Set();
18
+ const consoleBuf = new Map(); // tabId → [{level, text}] since the last "console" read
19
+ const dialogs = new Map(); // tabId → [text] of alerts/confirms gu accepted
20
+ const freshErrors = new Map(); // tabId → errors not yet reported after a command
21
+
22
+ // ── connection ──────────────────────────────────────────────────────────────────────────────
23
+ async function bridgePort() {
24
+ try {
25
+ const { port } = await chrome.storage.local.get("port");
26
+ return Number(port) || DEFAULT_PORT;
27
+ } catch {
28
+ return DEFAULT_PORT;
29
+ }
30
+ }
31
+
32
+ async function connect() {
33
+ if (connecting || (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN))) return;
34
+ connecting = true;
35
+ let sock;
36
+ try {
37
+ sock = new WebSocket(`ws://127.0.0.1:${await bridgePort()}/extension`);
38
+ } catch {
39
+ return;
40
+ } finally {
41
+ connecting = false;
42
+ }
43
+ ws = sock;
44
+ sock.onopen = () => {
45
+ sock.send(JSON.stringify({ type: "hello", version: VERSION }));
46
+ setBadge(true);
47
+ clearInterval(pingTimer);
48
+ // An open socket that carries a message at least every 30 s keeps the service worker awake.
49
+ pingTimer = setInterval(() => sock.readyState === WebSocket.OPEN && sock.send('{"type":"ping"}'), 20000);
50
+ };
51
+ sock.onmessage = (ev) => onCommand(sock, ev.data);
52
+ sock.onclose = () => {
53
+ if (ws !== sock) return; // an older socket the bridge replaced
54
+ ws = null;
55
+ clearInterval(pingTimer);
56
+ setBadge(false);
57
+ setTimeout(connect, 3000);
58
+ };
59
+ sock.onerror = () => {};
60
+ }
61
+
62
+ function setBadge(on) {
63
+ chrome.action.setBadgeText({ text: on ? "on" : "" }).catch(() => {});
64
+ chrome.action.setBadgeBackgroundColor({ color: "#6d5bd0" }).catch(() => {});
65
+ }
66
+
67
+ // The service worker sleeps when nothing happens; the alarm wakes it to retry the bridge.
68
+ chrome.alarms.create("gu-connect", { periodInMinutes: 0.5 });
69
+ chrome.alarms.onAlarm.addListener(() => connect());
70
+ chrome.runtime.onStartup.addListener(() => connect());
71
+ chrome.runtime.onInstalled.addListener(() => connect());
72
+ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
73
+ if (msg?.type === "status") {
74
+ reply({ connected: Boolean(ws && ws.readyState === WebSocket.OPEN), version: VERSION });
75
+ } else if (msg?.type === "reconnect") {
76
+ connect().then(() => reply({ ok: true }));
77
+ return true;
78
+ }
79
+ return false;
80
+ });
81
+ connect();
82
+
83
+ async function onCommand(sock, data) {
84
+ let msg;
85
+ try {
86
+ msg = JSON.parse(data);
87
+ } catch {
88
+ return;
89
+ }
90
+ if (!msg || msg.id === undefined) return;
91
+ let reply;
92
+ try {
93
+ reply = { id: msg.id, ok: true, result: await run(msg) };
94
+ } catch (e) {
95
+ reply = { id: msg.id, ok: false, error: String(e?.message || e) };
96
+ }
97
+ if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify(reply));
98
+ }
99
+
100
+ // ── gu's tabs ───────────────────────────────────────────────────────────────────────────────
101
+ async function state() {
102
+ const s = await chrome.storage.session.get(["tabs", "current", "group"]);
103
+ return { tabs: s.tabs || [], current: s.current ?? null, group: s.group ?? null };
104
+ }
105
+
106
+ async function saveState(patch) {
107
+ await chrome.storage.session.set(patch);
108
+ }
109
+
110
+ async function liveTabs() {
111
+ const s = await state();
112
+ const live = [];
113
+ for (const id of s.tabs) {
114
+ try {
115
+ live.push(await chrome.tabs.get(id));
116
+ } catch {
117
+ /* closed */
118
+ }
119
+ }
120
+ if (live.length !== s.tabs.length) await saveState({ tabs: live.map((t) => t.id) });
121
+ return live;
122
+ }
123
+
124
+ async function addToGroup(tabId) {
125
+ const s = await state();
126
+ let group = s.group;
127
+ try {
128
+ group = await chrome.tabs.group(group != null ? { tabIds: [tabId], groupId: group } : { tabIds: [tabId] });
129
+ } catch {
130
+ group = await chrome.tabs.group({ tabIds: [tabId] }); // the old group was closed
131
+ }
132
+ await chrome.tabGroups.update(group, { title: "gu", color: "purple" }).catch(() => {});
133
+ const tabs = s.tabs.includes(tabId) ? s.tabs : [...s.tabs, tabId];
134
+ await saveState({ tabs, group });
135
+ }
136
+
137
+ // A page gu opened that opens another (target=_blank, window.open) — that one is gu's too.
138
+ chrome.tabs.onCreated.addListener(async (tab) => {
139
+ const s = await state();
140
+ if (tab.openerTabId != null && s.tabs.includes(tab.openerTabId)) {
141
+ await addToGroup(tab.id).catch(() => {});
142
+ await saveState({ current: tab.id });
143
+ }
144
+ });
145
+
146
+ async function currentTab() {
147
+ const tabs = await liveTabs();
148
+ if (!tabs.length) throw new Error('gu has no tab open yet — use action "open" with a url first');
149
+ const s = await state();
150
+ return tabs.find((t) => t.id === s.current) || tabs[tabs.length - 1];
151
+ }
152
+
153
+ async function waitLoaded(tabId, ms = 20000) {
154
+ const end = Date.now() + ms;
155
+ await sleep(150);
156
+ while (Date.now() < end) {
157
+ const t = await chrome.tabs.get(tabId).catch(() => null);
158
+ if (!t) throw new Error("the tab was closed");
159
+ if (t.status === "complete") return t;
160
+ await sleep(150);
161
+ }
162
+ return chrome.tabs.get(tabId);
163
+ }
164
+
165
+ // ── DevTools protocol ───────────────────────────────────────────────────────────────────────
166
+ async function attach(tabId) {
167
+ if (attached.has(tabId)) return;
168
+ try {
169
+ await chrome.debugger.attach({ tabId }, "1.3");
170
+ } catch (e) {
171
+ if (!/already attached/i.test(String(e?.message))) {
172
+ throw new Error(`cannot control this tab (${e?.message || e}) — Chrome pages (chrome://, the Web Store) cannot be automated`);
173
+ }
174
+ }
175
+ attached.add(tabId);
176
+ await cdp(tabId, "Runtime.enable");
177
+ await cdp(tabId, "Page.enable");
178
+ await cdp(tabId, "Log.enable").catch(() => {});
179
+ }
180
+
181
+ function cdp(tabId, method, params = {}) {
182
+ return chrome.debugger.sendCommand({ tabId }, method, params);
183
+ }
184
+
185
+ chrome.debugger.onDetach.addListener((src) => attached.delete(src.tabId));
186
+ chrome.tabs.onRemoved.addListener((tabId) => {
187
+ attached.delete(tabId);
188
+ consoleBuf.delete(tabId);
189
+ freshErrors.delete(tabId);
190
+ dialogs.delete(tabId);
191
+ });
192
+
193
+ function pushConsole(tabId, level, text) {
194
+ const buf = consoleBuf.get(tabId) || [];
195
+ buf.push({ level, text: String(text).slice(0, 500) });
196
+ if (buf.length > 100) buf.splice(0, buf.length - 100);
197
+ consoleBuf.set(tabId, buf);
198
+ if (level === "error") freshErrors.set(tabId, [...(freshErrors.get(tabId) || []), String(text).slice(0, 300)].slice(-5));
199
+ }
200
+
201
+ chrome.debugger.onEvent.addListener((src, method, params) => {
202
+ const tabId = src.tabId;
203
+ if (method === "Runtime.consoleAPICalled") {
204
+ const text = (params.args || []).map((a) => (a.value !== undefined ? String(a.value) : a.description || a.type)).join(" ");
205
+ pushConsole(tabId, params.type === "warning" ? "warn" : params.type, text);
206
+ } else if (method === "Runtime.exceptionThrown") {
207
+ const d = params.exceptionDetails || {};
208
+ pushConsole(tabId, "error", `Uncaught ${d.exception?.description || d.text || "exception"}`);
209
+ } else if (method === "Log.entryAdded") {
210
+ const e = params.entry || {};
211
+ if (e.level === "error" || e.level === "warning") pushConsole(tabId, e.level === "warning" ? "warn" : "error", `${e.text}${e.url ? ` (${e.url})` : ""}`);
212
+ } else if (method === "Page.javascriptDialogOpening") {
213
+ // An alert/confirm would freeze the page and every later command; accept it and report it.
214
+ const list = dialogs.get(tabId) || [];
215
+ list.push(`${params.type}: ${params.message}`);
216
+ dialogs.set(tabId, list);
217
+ cdp(tabId, "Page.handleJavaScriptDialog", { accept: true, promptText: params.defaultPrompt || "" }).catch(() => {});
218
+ }
219
+ });
220
+
221
+ async function evaluate(tabId, expression) {
222
+ const r = await cdp(tabId, "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true, userGesture: true });
223
+ if (r.exceptionDetails) {
224
+ const d = r.exceptionDetails;
225
+ throw new Error(`page script error: ${d.exception?.description || d.text}`);
226
+ }
227
+ return r.result?.value;
228
+ }
229
+
230
+ // ── reading the page ────────────────────────────────────────────────────────────────────────
231
+ // Runs IN the page. Numbers the visible interactive elements (data-gu-ref) so the agent can say
232
+ // "click 7"; the numbers are good until the next read.
233
+ function readPage(opts) {
234
+ const sel = 'a[href],button,input:not([type=hidden]),select,textarea,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=option],[role=switch],[role=combobox],[role=textbox],[contenteditable=""],[contenteditable=true],[onclick],[tabindex]:not([tabindex="-1"])';
235
+ const vw = innerWidth;
236
+ const vh = innerHeight;
237
+ for (const e of document.querySelectorAll("[data-gu-ref]")) e.removeAttribute("data-gu-ref");
238
+ const clean = (s) => String(s || "").replace(/\s+/g, " ").trim();
239
+ const lines = [];
240
+ let n = 0;
241
+ let below = 0;
242
+ for (const el of document.querySelectorAll(sel)) {
243
+ const r = el.getBoundingClientRect();
244
+ if (r.width < 1 || r.height < 1) continue;
245
+ const cs = getComputedStyle(el);
246
+ if (cs.visibility === "hidden" || cs.display === "none" || Number(cs.opacity) === 0) continue;
247
+ if (!(r.bottom > 0 && r.right > 0 && r.top < vh && r.left < vw)) {
248
+ below++;
249
+ continue;
250
+ }
251
+ if (lines.length >= (opts.max || 150)) {
252
+ below++;
253
+ continue;
254
+ }
255
+ const ref = ++n;
256
+ el.setAttribute("data-gu-ref", String(ref));
257
+ const tag = el.tagName.toLowerCase();
258
+ const type = tag === "input" ? (el.getAttribute("type") || "text").toLowerCase() : "";
259
+ const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "input" ? (["checkbox", "radio", "submit", "button"].includes(type) ? type : `textbox(${type})`) : tag);
260
+ const label = el.labels && el.labels[0] ? el.labels[0].innerText : "";
261
+ const name = clean(el.getAttribute("aria-label") || label || (tag === "input" || tag === "textarea" || tag === "select" ? "" : el.innerText) || el.getAttribute("placeholder") || el.getAttribute("title") || el.getAttribute("alt") || el.getAttribute("name") || "").slice(0, 80);
262
+ const bits = [];
263
+ if ((tag === "input" && !["checkbox", "radio", "submit", "button"].includes(type)) || tag === "textarea") bits.push(`value=${JSON.stringify(String(el.value).slice(0, 60))}`);
264
+ if (tag === "select") bits.push(`selected=${JSON.stringify(clean(el.options[el.selectedIndex]?.text))}`);
265
+ if (type === "checkbox" || type === "radio") bits.push(el.checked ? "checked" : "unchecked");
266
+ if (el.disabled || el.getAttribute("aria-disabled") === "true") bits.push("disabled");
267
+ if (tag === "a") bits.push(`→ ${clean(el.getAttribute("href")).slice(0, 60)}`);
268
+ if (el === document.activeElement) bits.push("focused");
269
+ lines.push(`[${ref}] ${role} ${JSON.stringify(name)}${bits.length ? ` ${bits.join(" ")}` : ""} @${Math.round(r.left + r.width / 2)},${Math.round(r.top + r.height / 2)}`);
270
+ }
271
+ const de = document.documentElement;
272
+ return {
273
+ url: location.href,
274
+ title: document.title,
275
+ viewport: `${vw}x${vh}`,
276
+ scroll: `${Math.round(scrollY)} of ${Math.max(0, de.scrollHeight - vh)}`,
277
+ elements: lines,
278
+ offscreen: below,
279
+ text: String((document.body && document.body.innerText) || "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim().slice(0, opts.maxText || 6000),
280
+ };
281
+ }
282
+
283
+ async function refPoint(tabId, ref) {
284
+ const p = await evaluate(tabId, `(() => {
285
+ const el = document.querySelector('[data-gu-ref="${Number(ref)}"]');
286
+ if (!el) return null;
287
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
288
+ const r = el.getBoundingClientRect();
289
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
290
+ })()`);
291
+ if (!p) throw new Error(`no element [${ref}] on the page now — read the page again for fresh numbers`);
292
+ return p;
293
+ }
294
+
295
+ // What is really under the point — an overlay or a cookie banner takes the click, not the button.
296
+ async function hitAt(tabId, x, y) {
297
+ return evaluate(tabId, `(() => {
298
+ const el = document.elementFromPoint(${x}, ${y});
299
+ if (!el) return "nothing";
300
+ const t = (el.innerText || el.value || el.getAttribute("aria-label") || "").replace(/\\s+/g, " ").trim().slice(0, 50);
301
+ return el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + (t ? ' "' + t + '"' : "");
302
+ })()`);
303
+ }
304
+
305
+ async function point(tabId, cmd) {
306
+ if (cmd.ref !== undefined && cmd.ref !== null && cmd.ref !== "") return refPoint(tabId, cmd.ref);
307
+ if (Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) return { x: cmd.x, y: cmd.y };
308
+ throw new Error("say where: ref (a number from read) or x and y");
309
+ }
310
+
311
+ async function mouse(tabId, x, y, button = "left", clicks = 1) {
312
+ await cdp(tabId, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
313
+ for (let i = 1; i <= clicks; i++) {
314
+ await cdp(tabId, "Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount: i });
315
+ await cdp(tabId, "Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount: i });
316
+ }
317
+ }
318
+
319
+ // ── keys ────────────────────────────────────────────────────────────────────────────────────
320
+ const KEYS = {
321
+ enter: ["Enter", "Enter", 13, "\r"], tab: ["Tab", "Tab", 9], escape: ["Escape", "Escape", 27], esc: ["Escape", "Escape", 27],
322
+ backspace: ["Backspace", "Backspace", 8], delete: ["Delete", "Delete", 46], space: [" ", "Space", 32, " "],
323
+ arrowup: ["ArrowUp", "ArrowUp", 38], arrowdown: ["ArrowDown", "ArrowDown", 40], arrowleft: ["ArrowLeft", "ArrowLeft", 37],
324
+ arrowright: ["ArrowRight", "ArrowRight", 39], up: ["ArrowUp", "ArrowUp", 38], down: ["ArrowDown", "ArrowDown", 40],
325
+ left: ["ArrowLeft", "ArrowLeft", 37], right: ["ArrowRight", "ArrowRight", 39], home: ["Home", "Home", 36], end: ["End", "End", 35],
326
+ pageup: ["PageUp", "PageUp", 33], pagedown: ["PageDown", "PageDown", 34],
327
+ };
328
+ const MODS = { alt: 1, option: 1, ctrl: 2, control: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
329
+
330
+ async function pressKey(tabId, combo) {
331
+ const parts = String(combo).split("+").map((s) => s.trim()).filter(Boolean);
332
+ let mods = 0;
333
+ for (const p of parts.slice(0, -1)) mods |= MODS[p.toLowerCase()] || 0;
334
+ const k = parts[parts.length - 1] || "";
335
+ // Select-all and friends are editor COMMANDS in Chrome: a bare ctrl+a does not select on a Mac.
336
+ const cmdName = (mods & 6) && /^[acvxz]$/i.test(k) ? { a: "selectAll", c: "copy", v: "paste", x: "cut", z: "undo" }[k.toLowerCase()] : null;
337
+ let key, code, keyCode, text;
338
+ if (KEYS[k.toLowerCase()]) [key, code, keyCode, text] = KEYS[k.toLowerCase()];
339
+ else if (k.length === 1) {
340
+ key = mods & 8 ? k.toUpperCase() : k;
341
+ code = /[a-z]/i.test(k) ? `Key${k.toUpperCase()}` : /\d/.test(k) ? `Digit${k}` : "";
342
+ keyCode = k.toUpperCase().charCodeAt(0);
343
+ text = key;
344
+ } else throw new Error(`unknown key "${k}" — use Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown, Space or one character, with ctrl+ / shift+ / alt+ / meta+`);
345
+ if (mods & 7) text = undefined; // a shortcut types nothing
346
+ const base = { key, code, windowsVirtualKeyCode: keyCode, modifiers: mods };
347
+ await cdp(tabId, "Input.dispatchKeyEvent", { ...base, type: text ? "keyDown" : "rawKeyDown", text, ...(cmdName ? { commands: [cmdName] } : {}) });
348
+ await cdp(tabId, "Input.dispatchKeyEvent", { ...base, type: "keyUp" });
349
+ }
350
+
351
+ // ── the commands ────────────────────────────────────────────────────────────────────────────
352
+ function sleep(ms) {
353
+ return new Promise((r) => setTimeout(r, ms));
354
+ }
355
+
356
+ async function settle(tabId) {
357
+ await sleep(350);
358
+ const t = await chrome.tabs.get(tabId).catch(() => null);
359
+ if (t && t.status === "loading") await waitLoaded(tabId, 15000);
360
+ }
361
+
362
+ async function after(tabId, extra = {}) {
363
+ const t = await chrome.tabs.get(tabId).catch(() => null);
364
+ const errs = freshErrors.get(tabId) || [];
365
+ const dl = dialogs.get(tabId) || [];
366
+ freshErrors.delete(tabId);
367
+ dialogs.delete(tabId);
368
+ return { tab: tabId, url: t?.url || "", title: t?.title || "", errors: errs, dialogs: dl, ...extra };
369
+ }
370
+
371
+ async function run(cmd) {
372
+ const a = String(cmd.action || "").toLowerCase();
373
+ if (a === "status") return { version: VERSION };
374
+ if (a === "open") {
375
+ const url = String(cmd.url || "");
376
+ if (!/^(https?|file):\/\//i.test(url) && !/^about:blank$/i.test(url)) throw new Error("open needs a full http(s):// or file:// url");
377
+ const tabs = await liveTabs();
378
+ const s = await state();
379
+ let tab = cmd.new_tab ? null : tabs.find((t) => t.id === s.current) || tabs[tabs.length - 1];
380
+ if (tab) tab = await chrome.tabs.update(tab.id, { url, active: true });
381
+ else {
382
+ tab = await chrome.tabs.create({ url, active: true });
383
+ await addToGroup(tab.id);
384
+ }
385
+ await saveState({ current: tab.id });
386
+ await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {});
387
+ await attach(tab.id);
388
+ await waitLoaded(tab.id);
389
+ return after(tab.id);
390
+ }
391
+ if (a === "tabs") {
392
+ const s = await state();
393
+ return { tabs: (await liveTabs()).map((t) => ({ id: t.id, title: t.title, url: t.url, current: t.id === s.current })) };
394
+ }
395
+ if (a === "switch") {
396
+ const id = Number(cmd.tab);
397
+ const tab = (await liveTabs()).find((t) => t.id === id);
398
+ if (!tab) throw new Error(`tab ${cmd.tab} is not one of gu's tabs — see action "tabs"`);
399
+ await chrome.tabs.update(id, { active: true });
400
+ await saveState({ current: id });
401
+ return after(id);
402
+ }
403
+ const tab = await currentTab();
404
+ const id = tab.id;
405
+ if (a === "close") {
406
+ await chrome.tabs.remove(id);
407
+ return { closed: id };
408
+ }
409
+ await attach(id);
410
+ switch (a) {
411
+ case "read":
412
+ return { ...(await evaluate(id, `(${readPage})(${JSON.stringify({ maxText: cmd.max_text || 6000 })})`)), ...(await after(id)) };
413
+ case "screenshot": {
414
+ // Make it the visible tab: a background tab is not painted.
415
+ await chrome.tabs.update(id, { active: true });
416
+ const m = await cdp(id, "Page.getLayoutMetrics");
417
+ const vp = m.cssVisualViewport || m.visualViewport || {};
418
+ const shot = await cdp(id, "Page.captureScreenshot", { format: "jpeg", quality: 80 });
419
+ return { ...(await after(id)), image: shot.data, mime: "image/jpeg", width: Math.round(vp.clientWidth), height: Math.round(vp.clientHeight) };
420
+ }
421
+ case "click":
422
+ case "double_click":
423
+ case "right_click":
424
+ case "hover": {
425
+ const p = await point(id, cmd);
426
+ const hit = await hitAt(id, p.x, p.y);
427
+ if (a === "hover") await cdp(id, "Input.dispatchMouseEvent", { type: "mouseMoved", x: p.x, y: p.y });
428
+ else await mouse(id, p.x, p.y, a === "right_click" ? "right" : "left", a === "double_click" ? 2 : 1);
429
+ await settle(id);
430
+ return after(id, { at: `${Math.round(p.x)},${Math.round(p.y)}`, hit });
431
+ }
432
+ case "type": {
433
+ if (cmd.ref !== undefined && cmd.ref !== null && cmd.ref !== "") {
434
+ const p = await refPoint(id, cmd.ref);
435
+ await mouse(id, p.x, p.y);
436
+ if (cmd.clear) await pressKey(id, "ctrl+a");
437
+ }
438
+ await cdp(id, "Input.insertText", { text: String(cmd.text ?? "") });
439
+ if (cmd.submit) await pressKey(id, "Enter");
440
+ await settle(id);
441
+ return after(id);
442
+ }
443
+ case "key":
444
+ for (const k of String(cmd.text || "").split(/\s+/).filter(Boolean)) await pressKey(id, k);
445
+ await settle(id);
446
+ return after(id);
447
+ case "scroll": {
448
+ const m = await cdp(id, "Page.getLayoutMetrics");
449
+ const vp = m.cssVisualViewport || {};
450
+ const x = Number.isFinite(cmd.x) ? cmd.x : vp.clientWidth / 2;
451
+ const y = Number.isFinite(cmd.y) ? cmd.y : vp.clientHeight / 2;
452
+ const amount = Number(cmd.amount) || Math.round((vp.clientHeight || 800) * 0.8);
453
+ const dir = String(cmd.direction || "down").toLowerCase();
454
+ const dx = dir === "left" ? -amount : dir === "right" ? amount : 0;
455
+ const dy = dir === "up" ? -amount : dir === "down" ? amount : 0;
456
+ await cdp(id, "Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: dx, deltaY: dy });
457
+ await sleep(300);
458
+ return after(id, { scroll: await evaluate(id, "Math.round(scrollY) + ' of ' + Math.max(0, document.documentElement.scrollHeight - innerHeight)") });
459
+ }
460
+ case "back":
461
+ await evaluate(id, "history.back()");
462
+ await settle(id);
463
+ return after(id);
464
+ case "reload":
465
+ await cdp(id, "Page.reload", { ignoreCache: true });
466
+ await settle(id);
467
+ return after(id);
468
+ case "wait":
469
+ await sleep(Math.max(0, Math.min(Number(cmd.seconds) || 1, 30)) * 1000);
470
+ return after(id);
471
+ case "js": {
472
+ const value = await evaluate(id, String(cmd.text || ""));
473
+ return after(id, { value: value === undefined ? "undefined" : JSON.stringify(value)?.slice(0, 8000) });
474
+ }
475
+ case "console": {
476
+ const all = consoleBuf.get(id) || [];
477
+ consoleBuf.set(id, []);
478
+ return after(id, { console: all });
479
+ }
480
+ default:
481
+ throw new Error(`unknown action "${cmd.action}"`);
482
+ }
483
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "gu browser",
4
+ "version": "1.0.0",
5
+ "description": "Lets the gu coding agent open, see and operate web pages in its own tabs (tab group \"gu\") with a real mouse and keyboard.",
6
+ "minimum_chrome_version": "116",
7
+ "icons": {
8
+ "16": "icons/icon16.png",
9
+ "48": "icons/icon48.png",
10
+ "128": "icons/icon128.png"
11
+ },
12
+ "permissions": [
13
+ "debugger",
14
+ "tabs",
15
+ "tabGroups",
16
+ "alarms",
17
+ "storage"
18
+ ],
19
+ "host_permissions": [
20
+ "<all_urls>"
21
+ ],
22
+ "background": {
23
+ "service_worker": "background.js",
24
+ "type": "module"
25
+ },
26
+ "action": {
27
+ "default_title": "gu browser",
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
+ }
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <style>
6
+ body { font: 13px -apple-system, system-ui, sans-serif; margin: 0; padding: 14px 16px; width: 260px; color: #1d1b26; background: #fff; }
7
+ h1 { font-size: 14px; margin: 0 0 8px; }
8
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #b9b6c4; margin-right: 6px; }
9
+ .on .dot { background: #2f9e62; }
10
+ p { margin: 8px 0 0; color: #5d5a6b; line-height: 1.4; }
11
+ button { margin-top: 10px; font: inherit; padding: 4px 10px; }
12
+ @media (prefers-color-scheme: dark) { body { background: #1d1b26; color: #eceaf3; } p { color: #a9a6b8; } }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <h1>gu browser</h1>
17
+ <div id="status"><span class="dot"></span><span id="label">checking…</span></div>
18
+ <p id="help"></p>
19
+ <button id="retry">Connect now</button>
20
+ <script src="popup.js"></script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,22 @@
1
+ // Shows whether the extension is connected to gu's local bridge, and retries on request.
2
+ const label = document.getElementById("label");
3
+ const help = document.getElementById("help");
4
+
5
+ function show(s) {
6
+ const on = Boolean(s && s.connected);
7
+ document.getElementById("status").className = on ? "on" : "";
8
+ label.textContent = on ? `connected to gu (v${s.version})` : "not connected";
9
+ help.textContent = on
10
+ ? "gu's agent can open and use pages in the \"gu\" tab group. Your other tabs are never touched."
11
+ : "gu connects when its agent needs the browser. In the gu terminal, /browser shows the status.";
12
+ }
13
+
14
+ function check() {
15
+ chrome.runtime.sendMessage({ type: "status" }, show);
16
+ }
17
+
18
+ document.getElementById("retry").onclick = () => {
19
+ label.textContent = "connecting…";
20
+ chrome.runtime.sendMessage({ type: "reconnect" }, () => setTimeout(check, 600));
21
+ };
22
+ check();