@tiens.nguyen/gu-cli 1.0.833 → 1.0.835

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/gu-repl.mjs CHANGED
@@ -139,7 +139,8 @@ import {
139
139
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
140
140
  import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
141
141
  import { clearLogsNoticeText } from "./clear-logs.mjs";
142
- import { commandLogFile, killShell, logTail, readShells, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
142
+ import { allowIn, allowedIn, bridgeCommand, browserReport, bridgeStatus, extensionDir, findDevInstalls, helpInstall, installExtensionFiles, installSteps, localLeftovers, openExtensionsPage, removeLocalFiles, STORE_URL, stopBridge } from "./browser-command.mjs";
143
+ import { commandLogFile, killShell, logTail, markAnnounced, readShells, recapQuestion, shellsToRecap, statusText as shellStatusText, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
143
144
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
144
145
  import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
145
146
  import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
@@ -953,6 +954,7 @@ const COMMANDS = [
953
954
  { name: "/open-presets", usage: "/open-presets [name]", desc: "your whole setup now, keys included, read-only in your editor — to copy to another computer; with a name, edit that preset" },
954
955
  { name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
955
956
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
957
+ { name: "/browser", usage: "/browser [allow|forget|install|cleanup]", desc: "the gu Chrome extension the agent uses to open and click web pages (UI tests) — is it connected, and how to install it" },
956
958
  { name: "/shells", desc: "long commands the agent left running in the background — status, runtime, command, output; stop one" },
957
959
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
958
960
  { name: "/clear-logs", desc: "show what gu's logs, RAG indexes and old API versions take on disk, then ask what to clear — the logs first" },
@@ -9503,6 +9505,33 @@ function refreshShells() {
9503
9505
  shellEntries = readShells().map((rec) => ({ rec, st: shellState(rec) }));
9504
9506
  const after = shellsNotice(shellEntries, { viewed: shellsViewed, since: SHELLS_SINCE })?.text ?? "";
9505
9507
  if (after !== before) paintBar();
9508
+ wakeForShells();
9509
+ }
9510
+
9511
+ /**
9512
+ * WHEN A BACKGROUND SHELL FINISHES, THE AGENT HEARS ABOUT IT (#362) — Claude Code tells its model
9513
+ * "background command completed" and it recaps or carries on. A shell that ends DURING a turn is
9514
+ * told on the next step (the agent's step_callback); one that ends after the turn starts a short
9515
+ * recap turn here — only at the empty main prompt (never into a question you are answering or a
9516
+ * line you are typing), only for this folder's shells, and never for one you stopped yourself.
9517
+ * GU_SHELL_RECAP=0 turns it off.
9518
+ */
9519
+ let mainPromptWaiting = false;
9520
+ const SHELL_DONE_PREFIX = "\u0000shell-done:";
9521
+ function wakeForShells() {
9522
+ if (process.env.GU_SHELL_RECAP === "0") return;
9523
+ if (!mainPromptWaiting || following || listPickerActive || approvalActive || (rl?.line ?? "") !== "") return;
9524
+ const due = shellsToRecap(shellEntries, { cwd: process.cwd(), since: SHELLS_SINCE });
9525
+ if (!due.length) return;
9526
+ const ids = due.map((r) => String(r.id));
9527
+ try {
9528
+ if (!markAnnounced(ids)) return; // another terminal got there first
9529
+ } catch {
9530
+ return;
9531
+ }
9532
+ for (const id of ids) shellsViewed.add(id);
9533
+ mainPromptWaiting = false;
9534
+ submitSyntheticLine(SHELL_DONE_PREFIX + ids.join(","));
9506
9535
  }
9507
9536
 
9508
9537
  /** The Shell details block for one shell, printed into the transcript. */
@@ -9534,6 +9563,89 @@ function printShellDetails(rec) {
9534
9563
  * details, then refresh / stop it / close. During a turn it only prints the details — a picker
9535
9564
  * would fight the turn for the keyboard, as showOtherAgents explains.
9536
9565
  */
9566
+ // /browser (#363): the extension's connection, else how to install it.
9567
+ async function showBrowser() {
9568
+ const st = await bridgeStatus().catch(() => ({ running: false, connected: false }));
9569
+ const lines = browserReport(st, { allowed: allowedIn(resolve(process.cwd())) });
9570
+ console.log("");
9571
+ for (const [i, l] of lines.entries()) console.log(GUTTER + (i === 0 && st.connected ? accent(l) : l.startsWith("⚠") ? yellow(l) : dim(l)));
9572
+ console.log("");
9573
+ paintBar();
9574
+ }
9575
+
9576
+ // /browser install (#150): the extension, for the agent to use YOUR Chrome. Optional — gu's own
9577
+ // window needs nothing. The store page once published; until then the developer way.
9578
+ async function browserInstall() {
9579
+ let folder = extensionDir();
9580
+ if (!STORE_URL) {
9581
+ try {
9582
+ folder = installExtensionFiles();
9583
+ } catch (e) {
9584
+ console.log(GUTTER + yellow(`could not copy the extension to ${folder}: ${e.message}`));
9585
+ }
9586
+ }
9587
+ await bridgeStatus().catch(() => null); // running, so a fresh install has something to connect to
9588
+ console.log("");
9589
+ for (const l of installSteps(folder)) console.log(GUTTER + dim(l));
9590
+ if (STORE_URL ? openExtensionsPage() : helpInstall(folder)) console.log(GUTTER + dim(STORE_URL ? "(Opened Chrome.)" : "(Opened chrome://extensions and copied the folder to your clipboard.)"));
9591
+ console.log("");
9592
+ paintBar();
9593
+ }
9594
+
9595
+ // /browser cleanup (#145, #147): remove the developer (unpacked) install so the Chrome Web Store
9596
+ // one can take its place. Chrome lets no other program uninstall an extension, so: the connected
9597
+ // developer copy removes itself (chrome.management.uninstallSelf), other profiles get a Remove
9598
+ // click from you, and then gu's own files go.
9599
+ async function browserCleanup() {
9600
+ if (listPickerActive || approvalActive) return;
9601
+ const out = (l = "") => console.log(l ? GUTTER + l : "");
9602
+ const st = await bridgeStatus({ start: false, waitMs: 0 }).catch(() => ({ connected: false }));
9603
+ const devConnected = st.connected && st.installType === "development";
9604
+ const installs = findDevInstalls();
9605
+ const files = localLeftovers();
9606
+ out();
9607
+ if (!installs.length && !devConnected && !files.length) {
9608
+ out(accent("✓ Nothing of the gu extension's developer install is left."));
9609
+ out();
9610
+ paintBar();
9611
+ return;
9612
+ }
9613
+ out("The gu extension's developer install — what this removes:");
9614
+ for (const f of installs) out(dim(` • ${f.browser} — ${f.profile}: gu browser, unpacked (${f.id})`));
9615
+ if (devConnected && !installs.some((f) => f.id === st.id)) out(dim(` • the connected developer copy (${st.id})`));
9616
+ for (const f of files) out(dim(` • ${f.replace(homedir(), "~")}`));
9617
+ if (st.connected && st.installType === "normal") out(dim(" (The Chrome Web Store copy is connected — it stays.)"));
9618
+ out();
9619
+ const go = await pickFromList(["Clean up", "Cancel"], 0);
9620
+ if (go !== 0) {
9621
+ out(dim("(cancelled)"));
9622
+ out();
9623
+ paintBar();
9624
+ return;
9625
+ }
9626
+ if (devConnected) {
9627
+ const r = await bridgeCommand({ action: "uninstall_self" });
9628
+ out(r.ok ? accent(`✓ Removed from Chrome: the connected developer copy (${st.id}).`) : yellow(`could not remove the connected copy: ${r.error}`));
9629
+ await new Promise((res) => setTimeout(res, 1500)); // Chrome writes its profile a moment later
9630
+ }
9631
+ const left = findDevInstalls().filter((f) => !(devConnected && f.id === st.id));
9632
+ if (left.length) {
9633
+ out("Chrome does not let gu remove an extension in another profile. In each of these,");
9634
+ out("open chrome://extensions, find \"gu browser\" and click Remove:");
9635
+ for (const f of left) out(dim(` • ${f.browser} — ${f.profile}`));
9636
+ if (openExtensionsPage()) out(dim("(Opened chrome://extensions.)"));
9637
+ await pickFromList(["Done — I removed it", "Skip — leave it in that browser"], 0);
9638
+ }
9639
+ stopBridge();
9640
+ await new Promise((res) => setTimeout(res, 300));
9641
+ const gone = removeLocalFiles();
9642
+ if (gone.length) out(accent(`✓ Deleted ${gone.length} of gu's file(s) in ~/.gonext.`));
9643
+ const still = [...findDevInstalls().filter((f) => !(devConnected && f.id === st.id)).map((f) => `${f.browser} — ${f.profile}`), ...localLeftovers()];
9644
+ out(still.length ? yellow(`Still there: ${still.join(", ")}`) : accent("✓ The developer install is gone. Install the Chrome Web Store copy, then /browser."));
9645
+ out();
9646
+ paintBar();
9647
+ }
9648
+
9537
9649
  async function showShells() {
9538
9650
  if (listPickerActive || approvalActive) return;
9539
9651
  const width = Math.max(20, process.stdout.columns || 80);
@@ -10494,6 +10606,9 @@ const S3SETUP_PREFIX = "__S3SETUP__::";
10494
10606
  // "Always" writes an allow rule to ~/.gonext/mcp.json — the agent re-reads that file before every
10495
10607
  // call, so it counts from the next call of this same turn, and for every turn after.
10496
10608
  const MCPTOOL_PREFIX = "__MCPTOOL__::";
10609
+ // The agent's first use of the browser in a turn (#150): "<folder>\t<what it is about to do>".
10610
+ // "Always" puts the folder on ~/.gonext/browser-allow.json, which the agent reads before asking.
10611
+ const BROWSER_PREFIX = "__BROWSER__::";
10497
10612
  async function approvalPrompt(command) {
10498
10613
  // Wipe the live "thinking" status block BEFORE any picker draws, and stop the ticker from
10499
10614
  // redrawing it (the tick early-returns while a picker is active) — otherwise the block
@@ -10551,6 +10666,25 @@ async function approvalPrompt(command) {
10551
10666
  );
10552
10667
  return { allow: idx === 0, dontAsk: false };
10553
10668
  }
10669
+ if (command.startsWith(BROWSER_PREFIX)) {
10670
+ const [folder = "", what = ""] = command.slice(BROWSER_PREFIX.length).split("\t");
10671
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
10672
+ process.stdout.write(
10673
+ "\n" + yellow("🌐 Allow gu to use a web browser?") + dim(" (no answer in ~3 min = No)") + "\n" +
10674
+ dim(` It wants to: ${what || "open a page"}`) + "\n" +
10675
+ dim(" In your Chrome if the gu extension is there, else in gu's own Chrome window.") + "\n"
10676
+ );
10677
+ const idx = await pickFromList(["Yes, this time", "Yes, always in this folder", "No"], 0, { timeoutMs: 170000, timeoutIndex: 2 });
10678
+ if (idx === 1) {
10679
+ try {
10680
+ allowIn(folder || resolve(process.cwd()), true);
10681
+ process.stdout.write(dim(" (won't ask again here — /browser forget undoes it)\n"));
10682
+ } catch (e) {
10683
+ process.stdout.write(yellow(` could not save that (${e?.message || e}) — allowed this once\n`));
10684
+ }
10685
+ }
10686
+ return { allow: idx === 0 || idx === 1, dontAsk: false };
10687
+ }
10554
10688
  if (command.startsWith(MCPTOOL_PREFIX)) {
10555
10689
  const [server = "", tool = "", preview = ""] = command.slice(MCPTOOL_PREFIX.length).split("\t");
10556
10690
  if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
@@ -14007,6 +14141,7 @@ async function main() {
14007
14141
  // the turn ends; a queued line carries no Enter keypress, so its re-prompt overwrites
14008
14142
  // in place (\r + refresh) rather than stacking ">> >> >> " on one row.
14009
14143
  let lineRaw;
14144
+ mainPromptWaiting = true; // a finished background shell may start a recap turn now (#362)
14010
14145
  for (;;) {
14011
14146
  lineRaw = await nextLine();
14012
14147
  const echoed = enterEchoed;
@@ -14035,8 +14170,21 @@ async function main() {
14035
14170
  }
14036
14171
  }
14037
14172
  enterEchoed = false;
14173
+ mainPromptWaiting = false;
14038
14174
  footerActive = false; // a line arrived from the queue (no Enter keypress) → disarm
14039
14175
  if (lineRaw === null) break; // stdin closed
14176
+ // A RECAP TURN FOR A FINISHED BACKGROUND SHELL (#362). Nobody typed it, so it is not echoed as
14177
+ // a question under ">>" — one dim line says what started it, then the turn runs as usual.
14178
+ let shellRecap = false;
14179
+ if (lineRaw.startsWith(SHELL_DONE_PREFIX)) {
14180
+ const ids = lineRaw.slice(SHELL_DONE_PREFIX.length).split(",");
14181
+ const entries = readShells().map((rec) => ({ rec, st: shellState(rec) })).filter((e) => ids.includes(String(e.rec.id)));
14182
+ if (!entries.length) continue;
14183
+ shellRecap = true;
14184
+ alignOutputToLineStart();
14185
+ console.log("\n" + GUTTER + dim(`⏺ background ${entries.length === 1 ? "shell" : "shells"} finished — ${entries.map((e) => `${e.rec.id} ${shellStatusText(e.st)}`).join(", ")} · gu asks the agent to recap`));
14186
+ lineRaw = recapQuestion(entries);
14187
+ }
14040
14188
  const line = lineRaw.trim();
14041
14189
  // #302: what was typed for THIS line (before expansion), and whether it was sent from a
14042
14190
  // stopped turn's restored question.
@@ -14051,7 +14199,7 @@ async function main() {
14051
14199
  // carries a NUL and a raw jobId, and echoing it as a question would put both in the
14052
14200
  // scrollback under a ">>" that claims the user wrote them. Its branch prints its own
14053
14201
  // "⇄ following: …" line, which is the readable version of the same event.
14054
- if (barPinned() && line && !line.startsWith(ATTACH_PREFIX)) {
14202
+ if (barPinned() && line && !line.startsWith(ATTACH_PREFIX) && !shellRecap) {
14055
14203
  inputVisible = false; // the zone goes back to an empty prompt for the turn
14056
14204
  // START THIS LINE AT COLUMN 0, don't assume it is already there.
14057
14205
  //
@@ -14147,6 +14295,26 @@ async function main() {
14147
14295
  await showShells();
14148
14296
  continue;
14149
14297
  }
14298
+ if (line === "/browser") {
14299
+ await showBrowser();
14300
+ continue;
14301
+ }
14302
+ if (line === "/browser cleanup") {
14303
+ await browserCleanup();
14304
+ continue;
14305
+ }
14306
+ if (line === "/browser install") {
14307
+ await browserInstall();
14308
+ continue;
14309
+ }
14310
+ if (line === "/browser allow" || line === "/browser forget") {
14311
+ allowIn(cwd, line === "/browser allow");
14312
+ console.log("\n" + GUTTER + (line === "/browser allow"
14313
+ ? accent("✓ The agent may use the browser in this folder without asking.") + dim(" (/browser forget undoes it)")
14314
+ : accent("✓ The agent asks again before it uses the browser in this folder.")) + "\n");
14315
+ paintBar();
14316
+ continue;
14317
+ }
14150
14318
  if (line === "/model") {
14151
14319
  await chooseModel();
14152
14320
  continue;
package/gu_browser.py ADDED
@@ -0,0 +1,335 @@
1
+ """The agent's browser tool, Python side (task #363, GitHub #144).
2
+
3
+ The agent runs in a new process every turn, so it never holds the Chrome extension itself: it talks
4
+ to browser-bridge.mjs (a small local Node process that does) over HTTP, starting it when needed.
5
+ This module finds or starts the bridge, sends one command, and turns the answer into the text the
6
+ model reads. No model or smolagents import here — it is tested on its own.
7
+ """
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ import urllib.error
17
+ import urllib.request
18
+
19
+ HERE = os.path.dirname(os.path.abspath(__file__))
20
+ EXT_SRC = os.path.join(HERE, "browser-extension")
21
+ EXT_FILES = ("manifest.json", "background.js", "actions.js", "popup.html", "popup.js",
22
+ "icons/icon16.png", "icons/icon48.png", "icons/icon128.png")
23
+ BRIDGE = os.path.join(HERE, "browser-bridge.mjs")
24
+
25
+
26
+ def data_dir():
27
+ return os.path.join(os.path.expanduser("~"), ".gonext")
28
+
29
+
30
+ def state_path():
31
+ return os.environ.get("GU_BROWSER_STATE") or os.path.join(data_dir(), "browser-bridge.json")
32
+
33
+
34
+ def seen_path():
35
+ # Written the first time an extension answers: from then on a missing connection means "Chrome
36
+ # is asleep or closed", worth a longer wait, not "never installed".
37
+ return os.path.join(os.path.dirname(state_path()), "browser-extension-seen")
38
+
39
+
40
+ def extension_dir():
41
+ """The folder the user loads into Chrome. A fixed place under ~/.gonext, not the npm package:
42
+ an update moves the package, and Chrome would lose an unpacked extension loaded from there."""
43
+ return os.environ.get("GU_BROWSER_EXT_DIR") or os.path.join(data_dir(), "browser-extension")
44
+
45
+
46
+ def install_extension_files():
47
+ """Copy the extension to extension_dir() when it is missing or older. Returns the folder."""
48
+ dst = extension_dir()
49
+ try:
50
+ with open(os.path.join(EXT_SRC, "manifest.json"), encoding="utf-8") as f:
51
+ want = f.read()
52
+ try:
53
+ with open(os.path.join(dst, "manifest.json"), encoding="utf-8") as f:
54
+ have = f.read()
55
+ except OSError:
56
+ have = ""
57
+ def same(n):
58
+ try:
59
+ with open(os.path.join(dst, n), "rb") as a, open(os.path.join(EXT_SRC, n), "rb") as b:
60
+ return a.read() == b.read()
61
+ except OSError:
62
+ return False
63
+ if have != want or not all(same(n) for n in EXT_FILES):
64
+ os.makedirs(os.path.join(dst, "icons"), exist_ok=True)
65
+ for n in EXT_FILES:
66
+ shutil.copyfile(os.path.join(EXT_SRC, n), os.path.join(dst, n))
67
+ except OSError:
68
+ pass
69
+ return dst
70
+
71
+
72
+ # The Chrome Web Store page of the gu extension, once it is published (#149/#150). Until then the
73
+ # extension is the developer path only — and nobody needs it: gu's own Chrome window works without.
74
+ STORE_URL = os.environ.get("GU_BROWSER_STORE_URL", "")
75
+
76
+
77
+ def allow_path():
78
+ return os.path.join(os.path.dirname(state_path()), "browser-allow.json")
79
+
80
+
81
+ def _real(folder):
82
+ try:
83
+ return os.path.realpath(folder)
84
+ except OSError:
85
+ return folder
86
+
87
+
88
+ def allowed_in(folder):
89
+ """Has the user said "always allow the browser" for this folder (#150)?"""
90
+ try:
91
+ with open(allow_path(), encoding="utf-8") as f:
92
+ return _real(folder) in (json.load(f).get("folders") or [])
93
+ except (OSError, ValueError):
94
+ return False
95
+
96
+
97
+ def allow_in(folder, allow=True):
98
+ """Add (or remove) a folder on the "always allow" list. The terminal writes it on "Always"."""
99
+ try:
100
+ with open(allow_path(), encoding="utf-8") as f:
101
+ folders = json.load(f).get("folders") or []
102
+ except (OSError, ValueError):
103
+ folders = []
104
+ f_ = _real(folder)
105
+ folders = [x for x in folders if x != f_] + ([f_] if allow else [])
106
+ os.makedirs(os.path.dirname(allow_path()), exist_ok=True)
107
+ with open(allow_path(), "w", encoding="utf-8") as f:
108
+ json.dump({"folders": folders}, f, indent=2)
109
+
110
+
111
+ def last_seen():
112
+ """{version, installType} of the extension that last connected, or {} (#146). An older marker
113
+ holds only the version."""
114
+ try:
115
+ with open(seen_path(), encoding="utf-8") as f:
116
+ raw = f.read().strip()
117
+ try:
118
+ return json.loads(raw) if raw.startswith("{") else {"version": raw}
119
+ except ValueError:
120
+ return {"version": raw}
121
+ except OSError:
122
+ return {}
123
+
124
+
125
+ def install_text(reason="not connected"):
126
+ """What the model is told when the extension is not there: stop and ask the user."""
127
+ if last_seen().get("installType") == "normal":
128
+ # The Chrome Web Store copy is installed: never point at the developer folder again (#145).
129
+ return (f"BROWSER NOT AVAILABLE ({reason}): the gu Chrome extension (Chrome Web Store) is "
130
+ "installed but not connected — Chrome is probably closed. STOP and ask the user to "
131
+ "open Chrome (the extension reconnects within 30 seconds, or its icon → Connect now), "
132
+ "then continue. /browser in the gu terminal shows the status.")
133
+ # gu's own Chrome window needs no extension (#150), so this is reached only when there is no
134
+ # Chrome to start at all.
135
+ return (
136
+ f"BROWSER NOT AVAILABLE ({reason}). gu opens its own Chrome window when the gu extension is "
137
+ "not there, and it found no Chrome, Chromium, Edge or Brave on this computer. Do NOT work "
138
+ "around it with curl or a headless script for a UI check — STOP and tell the user: install "
139
+ "Google Chrome (or set GU_BROWSER_CHROME to a Chromium-based browser), then ask again."
140
+ + (f" To use their own Chrome instead, they can add the gu extension: {STORE_URL}" if STORE_URL else ""))
141
+
142
+
143
+ def _node():
144
+ return os.environ.get("GU_NODE") or shutil.which("node") or "node"
145
+
146
+
147
+ def _read_state():
148
+ try:
149
+ with open(state_path(), encoding="utf-8") as f:
150
+ st = json.load(f)
151
+ return st if st.get("port") and st.get("token") else None
152
+ except (OSError, ValueError):
153
+ return None
154
+
155
+
156
+ def _http(st, method, path, body=None, timeout=10):
157
+ req = urllib.request.Request(
158
+ f"http://127.0.0.1:{st['port']}{path}", method=method,
159
+ data=json.dumps(body).encode() if body is not None else None,
160
+ headers={"authorization": f"Bearer {st['token']}", "content-type": "application/json"})
161
+ try:
162
+ with urllib.request.urlopen(req, timeout=timeout) as r:
163
+ return json.loads(r.read() or b"{}")
164
+ except urllib.error.HTTPError as e:
165
+ try:
166
+ return json.loads(e.read() or b"{}") | {"_status": e.code}
167
+ except ValueError:
168
+ return {"_status": e.code, "error": str(e)}
169
+
170
+
171
+ def _start_bridge(log=None):
172
+ """Start browser-bridge.mjs detached (it outlives this turn) and wait for its state file."""
173
+ os.makedirs(data_dir(), exist_ok=True)
174
+ logf = open(os.path.join(data_dir(), "browser-bridge.log"), "ab")
175
+ kw = {"start_new_session": True} if os.name != "nt" else {
176
+ "creationflags": getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
177
+ subprocess.Popen([_node(), BRIDGE], stdin=subprocess.DEVNULL, stdout=logf, stderr=logf,
178
+ env=dict(os.environ), **kw)
179
+ logf.close()
180
+ if log:
181
+ log("browser: started the bridge")
182
+ end = time.time() + 6
183
+ while time.time() < end:
184
+ st = _read_state()
185
+ if st:
186
+ try:
187
+ _http(st, "GET", "/status", timeout=2)
188
+ return st
189
+ except OSError:
190
+ pass
191
+ time.sleep(0.15)
192
+ return None
193
+
194
+
195
+ def connect(wait=True, log=None, on_wait=None, own=False):
196
+ """→ (state, None) when the extension is connected, else (None, reason).
197
+ own=True (#150): gu's own Chrome window may stand in, so a missing extension is no failure —
198
+ → (state, None) either way, and the wait for a sleeping extension is shorter."""
199
+ st = _read_state()
200
+ started = False
201
+ try:
202
+ if not st:
203
+ raise OSError("no bridge")
204
+ _http(st, "GET", "/status", timeout=3)
205
+ except OSError:
206
+ st = _start_bridge(log)
207
+ started = True
208
+ if not st:
209
+ return None, "the gu browser bridge did not start (is node installed?)"
210
+ # A Chrome extension's worker sleeps; its alarm wakes it to reconnect within 30 s. Wait that
211
+ # long only when an extension has connected before — otherwise it is most likely not installed.
212
+ seen = os.path.exists(seen_path())
213
+ if own:
214
+ wait_ms = (8000 if seen else 0) if wait else 0 # then gu's own window
215
+ else:
216
+ wait_ms = (35000 if (seen and started) else 8000 if seen else 4000) if wait else 0
217
+ if wait_ms >= 8000 and on_wait:
218
+ on_wait()
219
+ try:
220
+ s = _http(st, "GET", f"/status?wait_ms={wait_ms}", timeout=wait_ms / 1000 + 5)
221
+ except OSError as e:
222
+ return None, f"the gu browser bridge is not answering ({e})"
223
+ if not s.get("connected"):
224
+ return (st, None) if own else (None, "extension not connected")
225
+ mark = {"version": s.get("version"), "installType": s.get("installType")}
226
+ if last_seen() != mark:
227
+ try:
228
+ with open(seen_path(), "w") as f:
229
+ json.dump(mark, f)
230
+ except OSError:
231
+ pass
232
+ return st, None
233
+
234
+
235
+ def command(cmd, log=None, on_wait=None, own=False):
236
+ """Send one command. → (ok, result_or_error, not_connected).
237
+ own=True: without the extension, the bridge uses gu's own Chrome window (#150). The result
238
+ then carries "_backend": "chrome"."""
239
+ st, why = connect(log=log, on_wait=on_wait, own=own)
240
+ if not st:
241
+ return False, why, True
242
+ if own:
243
+ cmd = dict(cmd, backend="auto")
244
+ timeout_s = 75 if cmd.get("action") in ("open", "wait", "js") else 40
245
+ try:
246
+ r = _http(st, "POST", "/cmd", body=dict(cmd, timeout_ms=(timeout_s - 5) * 1000), timeout=timeout_s)
247
+ except OSError as e:
248
+ return False, f"the browser bridge did not answer ({e})", False
249
+ if r.get("_status") == 503:
250
+ return False, "extension not connected", True
251
+ if r.get("noChrome"):
252
+ return False, str(r.get("error")), True # nothing to drive: the install answer applies
253
+ if not r.get("ok"):
254
+ return False, str(r.get("error") or "failed"), False
255
+ res = r.get("result") or {}
256
+ if isinstance(res, dict) and r.get("backend"):
257
+ res["_backend"] = r["backend"]
258
+ return True, res, False
259
+
260
+
261
+ # ── what the model reads ────────────────────────────────────────────────────────────────────
262
+ def _head(res):
263
+ out = f"Page: {res.get('title') or '(no title)'} — {res.get('url') or ''}"
264
+ for d in res.get("dialogs") or []:
265
+ out += f"\nA dialog opened and gu accepted it: {d}"
266
+ errs = res.get("errors") or []
267
+ if errs:
268
+ out += "\nNEW console errors:\n" + "\n".join(" " + e for e in errs)
269
+ return out
270
+
271
+
272
+ def format_read(res):
273
+ els = res.get("elements") or []
274
+ lines = [_head(res), f"Viewport {res.get('viewport')}, scrolled {res.get('scroll')} px."]
275
+ if els:
276
+ lines.append("Interactive elements in view — click/type by [number] (good until the next read):")
277
+ lines += [" " + e for e in els]
278
+ else:
279
+ lines.append("No interactive elements in view.")
280
+ if res.get("offscreen"):
281
+ lines.append(f"(+{res['offscreen']} more off screen — scroll to reach them)")
282
+ text = (res.get("text") or "").strip()
283
+ lines.append("Page text:\n" + (text or "(empty)"))
284
+ return "\n".join(lines)
285
+
286
+
287
+ def format_result(action, res):
288
+ a = action
289
+ if a == "tabs":
290
+ tabs = res.get("tabs") or []
291
+ if not tabs:
292
+ return "gu has no tab open."
293
+ return "gu's tabs:\n" + "\n".join(
294
+ f" {t['id']}{' (current)' if t.get('current') else ''}: {t.get('title') or ''} — {t.get('url')}" for t in tabs)
295
+ if a == "read":
296
+ return format_read(res)
297
+ if a == "close":
298
+ return f"Closed tab {res.get('closed')}."
299
+ out = _head(res)
300
+ if a in ("click", "double_click", "right_click", "hover"):
301
+ out = f"{a.replace('_', ' ').capitalize()} at {res.get('at')} on {res.get('hit')}.\n" + out
302
+ if a == "scroll":
303
+ out += f"\nScrolled to {res.get('scroll')} px."
304
+ if a == "js":
305
+ out += f"\nResult: {res.get('value')}"
306
+ if a == "console":
307
+ msgs = res.get("console") or []
308
+ out += ("\nConsole since the last read:\n" + "\n".join(f" [{m.get('level')}] {m.get('text')}" for m in msgs)
309
+ if msgs else "\nConsole: nothing new.")
310
+ if a in ("open", "click", "double_click", "type", "key", "back", "reload"):
311
+ out += "\n(read or screenshot to see the page now)"
312
+ return out
313
+
314
+
315
+ def decode_screenshot(res, max_edge=1568):
316
+ """The screenshot as a PIL image at the page's CSS size, so its pixels ARE click coordinates.
317
+ → (image, note). Chrome captures in device pixels (2× on a Retina screen)."""
318
+ from io import BytesIO
319
+ from PIL import Image
320
+ im = Image.open(BytesIO(base64.b64decode(res.get("image") or ""))).convert("RGB")
321
+ w, h = int(res.get("width") or im.width), int(res.get("height") or im.height)
322
+ if (w, h) != im.size and w > 0 and h > 0:
323
+ im = im.resize((w, h), Image.LANCZOS)
324
+ note = f"Screenshot {im.width}×{im.height} — its pixels are the page's x,y for click."
325
+ if max(im.size) > max_edge:
326
+ k = max_edge / max(im.size)
327
+ im = im.resize((max(1, int(im.width * k)), max(1, int(im.height * k))), Image.LANCZOS)
328
+ note = (f"Screenshot shrunk to {im.width}×{im.height}; the page is {w}×{h} — multiply a point "
329
+ f"in it by {1 / k:.3f} for click x,y.")
330
+ return im, note
331
+
332
+
333
+ if __name__ == "__main__": # tiny manual check: python3 gu_browser.py open https://example.com
334
+ ok, res, _ = command({"action": sys.argv[1], "url": sys.argv[2] if len(sys.argv) > 2 else ""})
335
+ print(format_result(sys.argv[1], res) if ok else res)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.833",
3
+ "version": "1.0.835",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -125,6 +125,15 @@
125
125
  "skills.mjs",
126
126
  "skill-source.mjs",
127
127
  "shell-details.mjs",
128
+ "proc-identity.mjs",
129
+ "browser-command.mjs",
130
+ "browser-bridge.mjs",
131
+ "browser-extension/manifest.json",
132
+ "browser-extension/background.js",
133
+ "browser-extension/actions.js",
134
+ "browser-extension/popup.html",
135
+ "browser-extension/popup.js",
136
+ "browser-extension/icons/",
128
137
  "step-group.mjs",
129
138
  "mcp-config.mjs",
130
139
  "mcp-command.mjs",
@@ -136,6 +145,7 @@
136
145
  "gu_mcp.py",
137
146
  "gu_skills.py",
138
147
  "gu_test_digest.py",
148
+ "gu_browser.py",
139
149
  "gu_hooks.py",
140
150
  "prompt_scaffold_check.py",
141
151
  "gonext_transcribe.py",
@@ -0,0 +1,53 @@
1
+ // IS THIS PID STILL THE PROCESS WE MEANT? (task #373, GitHub #154)
2
+ //
3
+ // gu keeps process ids in files — shells.json, the local API's pid file, the browser bridge's state —
4
+ // and those files outlive their processes. After a reboot or a crash the id is free, and the OS
5
+ // hands it to the next program (VS Code, Chrome); a SIGTERM to the saved id then lands on that.
6
+ // A process's START TIME is what tells two holders of one id apart, so it is what gu records and
7
+ // checks before it signals anything. POSIX only: `ps -o lstart=` reads the same on macOS and procps.
8
+
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ const MONTHS = "JanFebMarAprMayJunJulAugSepOctNovDec";
12
+
13
+ /** When `pid` started (epoch s); null = no such process; NaN = cannot tell (no ps, Windows). */
14
+ export function procStart(pid, { platform = process.platform } = {}) {
15
+ if (platform === "win32" || !(Number(pid) > 0)) return NaN;
16
+ const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
17
+ encoding: "utf8",
18
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
19
+ timeout: 5000,
20
+ });
21
+ if (r.error) return NaN;
22
+ const raw = String(r.stdout || "").trim().replace(/\s+/g, " ");
23
+ if (!raw) return null;
24
+ // "Wed Oct 14 23:26:17 2026", local time.
25
+ const m = raw.match(/^\w{3} (\w{3}) (\d{1,2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/);
26
+ const mon = m ? MONTHS.indexOf(m[1]) / 3 : -1;
27
+ if (!m || mon < 0 || !Number.isInteger(mon)) return NaN;
28
+ return new Date(Number(m[6]), mon, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])).getTime() / 1000;
29
+ }
30
+
31
+ /** The command line of `pid` ("" when there is none, or no ps). */
32
+ export function procCommand(pid, { platform = process.platform } = {}) {
33
+ if (platform === "win32" || !(Number(pid) > 0)) return "";
34
+ const r = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 5000 });
35
+ return r.error ? "" : String(r.stdout || "").trim();
36
+ }
37
+
38
+ /**
39
+ * Is `pid` the process that was running at `startedAt` (epoch s, as recorded)?
40
+ * "same" — it started then (within `before` s before and `after` s after the record);
41
+ * "gone" — no process has that id;
42
+ * "other" — that id belongs to a process started at another time: never signal it;
43
+ * "unknown" — nothing to compare (Windows, no ps, nothing recorded).
44
+ * `before` is the room for a record written a while after its process started (a server that
45
+ * records itself once it listens); `after` is ps's whole-second rounding.
46
+ */
47
+ export function sameProcess(pid, startedAt, { before = 5, after = 5, startOf = procStart } = {}) {
48
+ const t = startOf(pid);
49
+ if (t === null) return "gone";
50
+ const rec = Number(startedAt);
51
+ if (!Number.isFinite(t) || startedAt == null || !Number.isFinite(rec)) return "unknown";
52
+ return t >= rec - before && t <= rec + after ? "same" : "other";
53
+ }