@tiens.nguyen/gu-cli 1.0.832 → 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.
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 { browserReport, bridgeStatus, extensionDir, helpInstall, installExtensionFiles } 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", 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,23 @@ 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
+ let folder = extensionDir();
9569
+ try {
9570
+ folder = installExtensionFiles();
9571
+ } catch (e) {
9572
+ console.log(GUTTER + yellow(`could not copy the extension to ${folder}: ${e.message}`));
9573
+ }
9574
+ const st = await bridgeStatus().catch(() => ({ running: false, connected: false }));
9575
+ const lines = browserReport(st, folder);
9576
+ console.log("");
9577
+ for (const [i, l] of lines.entries()) console.log(GUTTER + (i === 0 ? (st.connected ? accent(l) : l) : dim(l)));
9578
+ if (!st.connected && st.running && helpInstall(folder)) console.log(GUTTER + dim("(Opened chrome://extensions and copied the folder to your clipboard.)"));
9579
+ console.log("");
9580
+ paintBar();
9581
+ }
9582
+
9537
9583
  async function showShells() {
9538
9584
  if (listPickerActive || approvalActive) return;
9539
9585
  const width = Math.max(20, process.stdout.columns || 80);
@@ -14007,6 +14053,7 @@ async function main() {
14007
14053
  // the turn ends; a queued line carries no Enter keypress, so its re-prompt overwrites
14008
14054
  // in place (\r + refresh) rather than stacking ">> >> >> " on one row.
14009
14055
  let lineRaw;
14056
+ mainPromptWaiting = true; // a finished background shell may start a recap turn now (#362)
14010
14057
  for (;;) {
14011
14058
  lineRaw = await nextLine();
14012
14059
  const echoed = enterEchoed;
@@ -14035,8 +14082,21 @@ async function main() {
14035
14082
  }
14036
14083
  }
14037
14084
  enterEchoed = false;
14085
+ mainPromptWaiting = false;
14038
14086
  footerActive = false; // a line arrived from the queue (no Enter keypress) → disarm
14039
14087
  if (lineRaw === null) break; // stdin closed
14088
+ // A RECAP TURN FOR A FINISHED BACKGROUND SHELL (#362). Nobody typed it, so it is not echoed as
14089
+ // a question under ">>" — one dim line says what started it, then the turn runs as usual.
14090
+ let shellRecap = false;
14091
+ if (lineRaw.startsWith(SHELL_DONE_PREFIX)) {
14092
+ const ids = lineRaw.slice(SHELL_DONE_PREFIX.length).split(",");
14093
+ const entries = readShells().map((rec) => ({ rec, st: shellState(rec) })).filter((e) => ids.includes(String(e.rec.id)));
14094
+ if (!entries.length) continue;
14095
+ shellRecap = true;
14096
+ alignOutputToLineStart();
14097
+ 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`));
14098
+ lineRaw = recapQuestion(entries);
14099
+ }
14040
14100
  const line = lineRaw.trim();
14041
14101
  // #302: what was typed for THIS line (before expansion), and whether it was sent from a
14042
14102
  // stopped turn's restored question.
@@ -14051,7 +14111,7 @@ async function main() {
14051
14111
  // carries a NUL and a raw jobId, and echoing it as a question would put both in the
14052
14112
  // scrollback under a ">>" that claims the user wrote them. Its branch prints its own
14053
14113
  // "⇄ following: …" line, which is the readable version of the same event.
14054
- if (barPinned() && line && !line.startsWith(ATTACH_PREFIX)) {
14114
+ if (barPinned() && line && !line.startsWith(ATTACH_PREFIX) && !shellRecap) {
14055
14115
  inputVisible = false; // the zone goes back to an empty prompt for the turn
14056
14116
  // START THIS LINE AT COLUMN 0, don't assume it is already there.
14057
14117
  //
@@ -14147,6 +14207,10 @@ async function main() {
14147
14207
  await showShells();
14148
14208
  continue;
14149
14209
  }
14210
+ if (line === "/browser") {
14211
+ await showBrowser();
14212
+ continue;
14213
+ }
14150
14214
  if (line === "/model") {
14151
14215
  await chooseModel();
14152
14216
  continue;
package/gu_browser.py ADDED
@@ -0,0 +1,263 @@
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", "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
+ def install_text(reason="not connected"):
73
+ """What the model is told when the extension is not there: stop and ask the user."""
74
+ folder = install_extension_files()
75
+ return (
76
+ f"BROWSER NOT AVAILABLE ({reason}): the gu Chrome extension is not installed, or Chrome is "
77
+ "not running. Do NOT try to work around it with curl or a headless script for a UI check — "
78
+ "STOP and ask the user to install it, then continue when they say it is done. Tell them:\n"
79
+ " 1. Open Chrome and go to chrome://extensions\n"
80
+ " 2. Turn on Developer mode (top right)\n"
81
+ f" 3. Click \"Load unpacked\" and choose the folder {folder}\n"
82
+ " 4. (Or type /browser in the gu terminal for these steps and the connection status.)\n"
83
+ "gu only uses its own tabs (a tab group named \"gu\"); the user's other tabs are never touched.")
84
+
85
+
86
+ def _node():
87
+ return os.environ.get("GU_NODE") or shutil.which("node") or "node"
88
+
89
+
90
+ def _read_state():
91
+ try:
92
+ with open(state_path(), encoding="utf-8") as f:
93
+ st = json.load(f)
94
+ return st if st.get("port") and st.get("token") else None
95
+ except (OSError, ValueError):
96
+ return None
97
+
98
+
99
+ def _http(st, method, path, body=None, timeout=10):
100
+ req = urllib.request.Request(
101
+ f"http://127.0.0.1:{st['port']}{path}", method=method,
102
+ data=json.dumps(body).encode() if body is not None else None,
103
+ headers={"authorization": f"Bearer {st['token']}", "content-type": "application/json"})
104
+ try:
105
+ with urllib.request.urlopen(req, timeout=timeout) as r:
106
+ return json.loads(r.read() or b"{}")
107
+ except urllib.error.HTTPError as e:
108
+ try:
109
+ return json.loads(e.read() or b"{}") | {"_status": e.code}
110
+ except ValueError:
111
+ return {"_status": e.code, "error": str(e)}
112
+
113
+
114
+ def _start_bridge(log=None):
115
+ """Start browser-bridge.mjs detached (it outlives this turn) and wait for its state file."""
116
+ os.makedirs(data_dir(), exist_ok=True)
117
+ logf = open(os.path.join(data_dir(), "browser-bridge.log"), "ab")
118
+ kw = {"start_new_session": True} if os.name != "nt" else {
119
+ "creationflags": getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
120
+ subprocess.Popen([_node(), BRIDGE], stdin=subprocess.DEVNULL, stdout=logf, stderr=logf,
121
+ env=dict(os.environ), **kw)
122
+ logf.close()
123
+ if log:
124
+ log("browser: started the bridge")
125
+ end = time.time() + 6
126
+ while time.time() < end:
127
+ st = _read_state()
128
+ if st:
129
+ try:
130
+ _http(st, "GET", "/status", timeout=2)
131
+ return st
132
+ except OSError:
133
+ pass
134
+ time.sleep(0.15)
135
+ return None
136
+
137
+
138
+ def connect(wait=True, log=None, on_wait=None):
139
+ """→ (state, None) when the extension is connected, else (None, reason)."""
140
+ st = _read_state()
141
+ started = False
142
+ try:
143
+ if not st:
144
+ raise OSError("no bridge")
145
+ _http(st, "GET", "/status", timeout=3)
146
+ except OSError:
147
+ st = _start_bridge(log)
148
+ started = True
149
+ if not st:
150
+ return None, "the gu browser bridge did not start (is node installed?)"
151
+ # A Chrome extension's worker sleeps; its alarm wakes it to reconnect within 30 s. Wait that
152
+ # long only when an extension has connected before — otherwise it is most likely not installed.
153
+ seen = os.path.exists(seen_path())
154
+ wait_ms = (35000 if (seen and started) else 8000 if seen else 4000) if wait else 0
155
+ if wait_ms >= 8000 and on_wait:
156
+ on_wait()
157
+ try:
158
+ s = _http(st, "GET", f"/status?wait_ms={wait_ms}", timeout=wait_ms / 1000 + 5)
159
+ except OSError as e:
160
+ return None, f"the gu browser bridge is not answering ({e})"
161
+ if not s.get("connected"):
162
+ return None, "extension not connected"
163
+ if not seen:
164
+ try:
165
+ with open(seen_path(), "w") as f:
166
+ f.write(str(s.get("version") or ""))
167
+ except OSError:
168
+ pass
169
+ return st, None
170
+
171
+
172
+ def command(cmd, log=None, on_wait=None):
173
+ """Send one command. → (ok, result_or_error, not_connected)."""
174
+ st, why = connect(log=log, on_wait=on_wait)
175
+ if not st:
176
+ return False, why, True
177
+ timeout_s = 75 if cmd.get("action") in ("open", "wait", "js") else 40
178
+ try:
179
+ r = _http(st, "POST", "/cmd", body=dict(cmd, timeout_ms=(timeout_s - 5) * 1000), timeout=timeout_s)
180
+ except OSError as e:
181
+ return False, f"the browser bridge did not answer ({e})", False
182
+ if r.get("_status") == 503:
183
+ return False, "extension not connected", True
184
+ if not r.get("ok"):
185
+ return False, str(r.get("error") or "failed"), False
186
+ return True, r.get("result") or {}, False
187
+
188
+
189
+ # ── what the model reads ────────────────────────────────────────────────────────────────────
190
+ def _head(res):
191
+ out = f"Page: {res.get('title') or '(no title)'} — {res.get('url') or ''}"
192
+ for d in res.get("dialogs") or []:
193
+ out += f"\nA dialog opened and gu accepted it: {d}"
194
+ errs = res.get("errors") or []
195
+ if errs:
196
+ out += "\nNEW console errors:\n" + "\n".join(" " + e for e in errs)
197
+ return out
198
+
199
+
200
+ def format_read(res):
201
+ els = res.get("elements") or []
202
+ lines = [_head(res), f"Viewport {res.get('viewport')}, scrolled {res.get('scroll')} px."]
203
+ if els:
204
+ lines.append("Interactive elements in view — click/type by [number] (good until the next read):")
205
+ lines += [" " + e for e in els]
206
+ else:
207
+ lines.append("No interactive elements in view.")
208
+ if res.get("offscreen"):
209
+ lines.append(f"(+{res['offscreen']} more off screen — scroll to reach them)")
210
+ text = (res.get("text") or "").strip()
211
+ lines.append("Page text:\n" + (text or "(empty)"))
212
+ return "\n".join(lines)
213
+
214
+
215
+ def format_result(action, res):
216
+ a = action
217
+ if a == "tabs":
218
+ tabs = res.get("tabs") or []
219
+ if not tabs:
220
+ return "gu has no tab open."
221
+ return "gu's tabs:\n" + "\n".join(
222
+ f" {t['id']}{' (current)' if t.get('current') else ''}: {t.get('title') or ''} — {t.get('url')}" for t in tabs)
223
+ if a == "read":
224
+ return format_read(res)
225
+ if a == "close":
226
+ return f"Closed tab {res.get('closed')}."
227
+ out = _head(res)
228
+ if a in ("click", "double_click", "right_click", "hover"):
229
+ out = f"{a.replace('_', ' ').capitalize()} at {res.get('at')} on {res.get('hit')}.\n" + out
230
+ if a == "scroll":
231
+ out += f"\nScrolled to {res.get('scroll')} px."
232
+ if a == "js":
233
+ out += f"\nResult: {res.get('value')}"
234
+ if a == "console":
235
+ msgs = res.get("console") or []
236
+ out += ("\nConsole since the last read:\n" + "\n".join(f" [{m.get('level')}] {m.get('text')}" for m in msgs)
237
+ if msgs else "\nConsole: nothing new.")
238
+ if a in ("open", "click", "double_click", "type", "key", "back", "reload"):
239
+ out += "\n(read or screenshot to see the page now)"
240
+ return out
241
+
242
+
243
+ def decode_screenshot(res, max_edge=1568):
244
+ """The screenshot as a PIL image at the page's CSS size, so its pixels ARE click coordinates.
245
+ → (image, note). Chrome captures in device pixels (2× on a Retina screen)."""
246
+ from io import BytesIO
247
+ from PIL import Image
248
+ im = Image.open(BytesIO(base64.b64decode(res.get("image") or ""))).convert("RGB")
249
+ w, h = int(res.get("width") or im.width), int(res.get("height") or im.height)
250
+ if (w, h) != im.size and w > 0 and h > 0:
251
+ im = im.resize((w, h), Image.LANCZOS)
252
+ note = f"Screenshot {im.width}×{im.height} — its pixels are the page's x,y for click."
253
+ if max(im.size) > max_edge:
254
+ k = max_edge / max(im.size)
255
+ im = im.resize((max(1, int(im.width * k)), max(1, int(im.height * k))), Image.LANCZOS)
256
+ note = (f"Screenshot shrunk to {im.width}×{im.height}; the page is {w}×{h} — multiply a point "
257
+ f"in it by {1 / k:.3f} for click x,y.")
258
+ return im, note
259
+
260
+
261
+ if __name__ == "__main__": # tiny manual check: python3 gu_browser.py open https://example.com
262
+ ok, res, _ = command({"action": sys.argv[1], "url": sys.argv[2] if len(sys.argv) > 2 else ""})
263
+ print(format_result(sys.argv[1], res) if ok else res)