@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.
- package/README.md +1 -0
- package/browser-bridge.mjs +274 -0
- package/browser-command.mjs +116 -0
- package/browser-extension/README.md +111 -0
- package/browser-extension/background.js +483 -0
- package/browser-extension/icons/icon128.png +0 -0
- package/browser-extension/icons/icon16.png +0 -0
- package/browser-extension/icons/icon48.png +0 -0
- package/browser-extension/manifest.json +35 -0
- package/browser-extension/popup.html +22 -0
- package/browser-extension/popup.js +22 -0
- package/gonext_agent_chat.py +188 -5
- package/gu-cli.mjs +3 -0
- package/gu-repl.mjs +66 -2
- package/gu_browser.py +263 -0
- package/package.json +9 -1
- package/shell-details.mjs +58 -4
package/gonext_agent_chat.py
CHANGED
|
@@ -8052,6 +8052,30 @@ def _run_command_prompt_line(shell, windows=None):
|
|
|
8052
8052
|
"RUNNING — stop_server closes it. " + _BG_LINE)
|
|
8053
8053
|
|
|
8054
8054
|
|
|
8055
|
+
# THE BROWSER (task #363, GitHub #144): a real Chrome the agent drives through the gu extension.
|
|
8056
|
+
# Offered only where commands may run (the terminal's own folders, never the web app's scratch one)
|
|
8057
|
+
# and node can start the bridge. GU_BROWSER=0 turns it off.
|
|
8058
|
+
def _browser_on() -> bool:
|
|
8059
|
+
if os.environ.get("GU_BROWSER", "1").strip() == "0":
|
|
8060
|
+
return False
|
|
8061
|
+
if not any(w.get("allowRun") for w in _WS_ROOTS):
|
|
8062
|
+
return False
|
|
8063
|
+
import shutil as _sh
|
|
8064
|
+
return bool(os.environ.get("GU_NODE") or _sh.which("node"))
|
|
8065
|
+
|
|
8066
|
+
|
|
8067
|
+
_BROWSER_LINE = (
|
|
8068
|
+
" UI CHECKS — THE BROWSER: to see, test or operate a web page (does the page render, does "
|
|
8069
|
+
"the button work, does the form submit, what does the user see), use browser(...): a REAL "
|
|
8070
|
+
"Chrome with a real mouse and keyboard. Loop: browser(\"open\", url=…) → browser(\"read\") "
|
|
8071
|
+
"(text + numbered elements) or browser(\"screenshot\") → browser(\"click\", ref=n) / "
|
|
8072
|
+
"browser(\"type\", ref=n, text=…) → read again to check the result; browser(\"console\") shows "
|
|
8073
|
+
"page errors. A local app needs its server running first (run_command background=True). "
|
|
8074
|
+
"Never report a UI as working without looking at it. If browser answers BROWSER NOT "
|
|
8075
|
+
"AVAILABLE, stop and ask the user to install the gu Chrome extension (steps are in the "
|
|
8076
|
+
"answer).\n")
|
|
8077
|
+
|
|
8078
|
+
|
|
8055
8079
|
# Background shells (task #360). Never `&` (refused) — background=True is the way to say it.
|
|
8056
8080
|
_BG_LINE = ("A LONG job (build, deploy, big test run) → run_command(…, background=True), then "
|
|
8057
8081
|
"shell_output(<id>, wait_seconds=…) to wait for it; a command still running at its "
|
|
@@ -9155,7 +9179,8 @@ def _shell_state(s: dict, alive=None, now=None) -> dict:
|
|
|
9155
9179
|
state = "ended"
|
|
9156
9180
|
else:
|
|
9157
9181
|
state = "exited" if code == 0 else "failed"
|
|
9158
|
-
return {"state": state, "code": code, "runtime": max(0.0, ended - started), "ended": ended
|
|
9182
|
+
return {"state": state, "code": code, "runtime": max(0.0, ended - started), "ended": ended,
|
|
9183
|
+
"by": s.get("killed_by") or ""}
|
|
9159
9184
|
|
|
9160
9185
|
|
|
9161
9186
|
def _fmt_runtime(seconds: float) -> str:
|
|
@@ -9171,7 +9196,11 @@ def _shell_status_text(st: dict) -> str:
|
|
|
9171
9196
|
return "running"
|
|
9172
9197
|
if st["state"] in ("exited", "failed"):
|
|
9173
9198
|
return f"exited {st['code']}"
|
|
9174
|
-
|
|
9199
|
+
# WHO STOPPED IT (#362/#143). A bare "killed" read as a crash — "shell got status killed why?"
|
|
9200
|
+
# — when the agent had stopped its own search on purpose. Every stop says who did it.
|
|
9201
|
+
if st["state"] == "killed":
|
|
9202
|
+
return f"killed — stopped by {st['by']}" if st.get("by") else "killed"
|
|
9203
|
+
return "ended (exit code unknown)"
|
|
9175
9204
|
|
|
9176
9205
|
|
|
9177
9206
|
def _shell_find(shells: list, key: str):
|
|
@@ -9298,7 +9327,7 @@ def _file_tail(path: str, max_chars: int = 4000) -> str:
|
|
|
9298
9327
|
return ""
|
|
9299
9328
|
|
|
9300
9329
|
|
|
9301
|
-
def _shell_kill(s: dict) -> None:
|
|
9330
|
+
def _shell_kill(s: dict, by: str = "the agent (stop_server)") -> None:
|
|
9302
9331
|
"""Stop a shell's whole process group and mark it killed (unless it already finished)."""
|
|
9303
9332
|
import time as _t
|
|
9304
9333
|
if _shell_state(s)["state"] != "running":
|
|
@@ -9311,9 +9340,52 @@ def _shell_kill(s: dict) -> None:
|
|
|
9311
9340
|
except (OSError, ValueError):
|
|
9312
9341
|
pass
|
|
9313
9342
|
s["killed"] = True
|
|
9343
|
+
s["killed_by"] = by
|
|
9314
9344
|
s["ended"] = _t.time()
|
|
9315
9345
|
|
|
9316
9346
|
|
|
9347
|
+
# A SHELL THAT FINISHES IS NEWS (#362), the way Claude Code tells its model "background command X
|
|
9348
|
+
# completed". During a turn the note rides on the next observation (append-only — no cache
|
|
9349
|
+
# break); after the turn the terminal starts a short recap turn (gu-repl.mjs, wakeForShells).
|
|
9350
|
+
# `announced` stops both from telling twice; `seen` means the agent already read the final state.
|
|
9351
|
+
def _shells_in_scope(shells: list, roots=None) -> list:
|
|
9352
|
+
"""The shells started inside these folders (default: the registered workspaces) — shells.json
|
|
9353
|
+
is one file for every project, and another project's build is not this turn's news."""
|
|
9354
|
+
paths = [os.path.realpath(r["path"]) for r in (_WS_ROOTS if roots is None else roots)
|
|
9355
|
+
if isinstance(r, dict) and r.get("path")]
|
|
9356
|
+
out = []
|
|
9357
|
+
for s in shells:
|
|
9358
|
+
wd = os.path.realpath(str(s.get("workdir") or ""))
|
|
9359
|
+
if any(wd == p or wd.startswith(p.rstrip(os.sep) + os.sep) for p in paths):
|
|
9360
|
+
out.append(s)
|
|
9361
|
+
return out
|
|
9362
|
+
|
|
9363
|
+
|
|
9364
|
+
def _shells_finished_note(shells: list, now=None) -> str:
|
|
9365
|
+
"""Notes for shells that finished and were neither read nor announced; marks them announced
|
|
9366
|
+
in `shells` (the caller saves). "" when there is nothing new."""
|
|
9367
|
+
notes = []
|
|
9368
|
+
for s in shells:
|
|
9369
|
+
if s.get("seen") or s.get("announced"):
|
|
9370
|
+
continue
|
|
9371
|
+
st = _shell_state(s, now=now)
|
|
9372
|
+
if st["state"] == "running":
|
|
9373
|
+
continue
|
|
9374
|
+
s["announced"] = True
|
|
9375
|
+
note = (f"[background shell {s.get('id')} finished: {_shell_status_text(st)} after "
|
|
9376
|
+
f"{_fmt_runtime(st['runtime'])} — '{_clip(str(s.get('command')), 120)}'. "
|
|
9377
|
+
f"shell_output('{s.get('id')}') has its output.]")
|
|
9378
|
+
try:
|
|
9379
|
+
with open(s.get("log") or "", encoding="utf-8", errors="replace") as fh:
|
|
9380
|
+
dg = _test_digest(fh.read(), str(s.get("workdir") or ""))
|
|
9381
|
+
if dg:
|
|
9382
|
+
note += "\n" + dg
|
|
9383
|
+
except OSError:
|
|
9384
|
+
pass
|
|
9385
|
+
notes.append(note)
|
|
9386
|
+
return "\n\n".join(notes)
|
|
9387
|
+
|
|
9388
|
+
|
|
9317
9389
|
def _shells_context_line(shells: list, now=None) -> str:
|
|
9318
9390
|
"""The turn's line about shells: what is still running, and what finished since the model
|
|
9319
9391
|
last looked — a build that ended between turns is news the model has not seen."""
|
|
@@ -10725,7 +10797,7 @@ def run_agent_chat(cfg):
|
|
|
10725
10797
|
except Exception as _e: # noqa: BLE001
|
|
10726
10798
|
_log(f"server context skip: {_e}")
|
|
10727
10799
|
try:
|
|
10728
|
-
_ws_servers_line += _shells_context_line(_shells_load())
|
|
10800
|
+
_ws_servers_line += _shells_context_line(_shells_in_scope(_shells_load()))
|
|
10729
10801
|
except Exception as _e: # noqa: BLE001
|
|
10730
10802
|
_log(f"shell context skip: {_e}")
|
|
10731
10803
|
_ws_tests_line = ""
|
|
@@ -10792,7 +10864,8 @@ def run_agent_chat(cfg):
|
|
|
10792
10864
|
"and reads do not pile up here and are not re-sent on every later step. Then read only "
|
|
10793
10865
|
"the exact ranges you will edit. Use grep_repo/read_file_lines directly when you already "
|
|
10794
10866
|
"know the file or the exact string.\n"
|
|
10795
|
-
+ _run_command_prompt_line(bool(_run_shell_bin()))
|
|
10867
|
+
+ _run_command_prompt_line(bool(_run_shell_bin()))
|
|
10868
|
+
+ (_BROWSER_LINE if _browser_on() else "") +
|
|
10796
10869
|
" DEPLOY: prefer deploy_web(local_dir, host, user, remote_path, domain) over "
|
|
10797
10870
|
"hand-written scp/ssh/rsync (it rsyncs over your SSH key and finishes nginx when it "
|
|
10798
10871
|
"can, else writes deploy.sh and returns the exact commands). KEY auth ONLY "
|
|
@@ -11871,9 +11944,38 @@ def run_agent_chat(cfg):
|
|
|
11871
11944
|
saved += _trim_tool_call_args(_st, 120)
|
|
11872
11945
|
return saved
|
|
11873
11946
|
|
|
11947
|
+
_browser_shot = {"img": None} # the browser tool's newest screenshot, for this step (#363)
|
|
11948
|
+
|
|
11874
11949
|
def step_callback(step_log):
|
|
11875
11950
|
step_num = getattr(step_log, "step_number", "?")
|
|
11876
11951
|
|
|
11952
|
+
# A browser screenshot taken in this step goes to the model as an image with the step's
|
|
11953
|
+
# observation (#363). Only the newest two stay in memory: every image is re-sent on every
|
|
11954
|
+
# later step, and an old view of a page that has since changed only misleads.
|
|
11955
|
+
try:
|
|
11956
|
+
if _browser_shot.get("img") is not None:
|
|
11957
|
+
step_log.observations_images = [_browser_shot["img"]]
|
|
11958
|
+
_browser_shot["img"] = None
|
|
11959
|
+
_with = [s for s in (getattr(getattr(_agent_ref.get("a"), "memory", None), "steps", None) or [])
|
|
11960
|
+
if getattr(s, "observations_images", None) and s is not step_log]
|
|
11961
|
+
for _old in _with[:-1]:
|
|
11962
|
+
_old.observations_images = None
|
|
11963
|
+
except Exception as e: # noqa: BLE001
|
|
11964
|
+
_log(f"browser screenshot attach skip: {e}")
|
|
11965
|
+
|
|
11966
|
+
# A background shell that finished during this step is told to the model now (#362).
|
|
11967
|
+
try:
|
|
11968
|
+
_obs_now = getattr(step_log, "observations", None)
|
|
11969
|
+
if isinstance(_obs_now, str):
|
|
11970
|
+
_shs = _shells_load()
|
|
11971
|
+
_fin = _shells_finished_note(_shells_in_scope(_shs))
|
|
11972
|
+
if _fin:
|
|
11973
|
+
step_log.observations = _obs_now + "\n\n" + _fin
|
|
11974
|
+
_shells_save(_shs)
|
|
11975
|
+
_log(f"step {step_num}: told the model a background shell finished")
|
|
11976
|
+
except Exception as e: # noqa: BLE001
|
|
11977
|
+
_log(f"shell-finished note skip: {e}")
|
|
11978
|
+
|
|
11877
11979
|
# Task #74: an executor-timeout error means whatever the step's tool call
|
|
11878
11980
|
# achieved was NEVER reported to the model (a create_pdf may have finished —
|
|
11879
11981
|
# or may still be finishing — in the background). Flag it so a repeat
|
|
@@ -15476,6 +15578,85 @@ def run_agent_chat(cfg):
|
|
|
15476
15578
|
_last_obs["text"] = msg
|
|
15477
15579
|
return msg
|
|
15478
15580
|
|
|
15581
|
+
@tool
|
|
15582
|
+
def browser(action: str, url: str = "", ref: int = 0, x: int = -1, y: int = -1,
|
|
15583
|
+
text: str = "", clear: bool = False, submit: bool = False,
|
|
15584
|
+
direction: str = "", amount: int = 0, seconds: int = 0, tab: int = 0,
|
|
15585
|
+
new_tab: bool = False) -> str:
|
|
15586
|
+
"""Use a REAL Chrome browser (through the gu Chrome extension) like a person does — open a page, see it, click with the mouse, type with the keyboard — to test a UI flow, check a page renders, fill a form or click through an app. Loop: open → read (or screenshot) → click / type → read again to check what happened. Only gu's own tabs are used. If the answer starts BROWSER NOT AVAILABLE, stop and ask the user to install the extension (the answer has the steps).
|
|
15587
|
+
|
|
15588
|
+
Args:
|
|
15589
|
+
action: one of: open (url) · read (page text + visible interactive elements numbered [n] with x,y) · screenshot (an image of the page; pixels = click x,y) · click / double_click / right_click / hover (ref, or x and y) · type (text into the focused field; with ref it clicks that field first) · key (text: "Enter", "Tab", "Escape", "ctrl+a", "shift+Tab"… several separated by spaces) · scroll (direction, amount) · back · reload · wait (seconds) · js (text = a JavaScript expression run in the page; returns its value) · console (the page's console messages since the last call) · tabs · switch (tab) · close
|
|
15590
|
+
url: for open — a full http(s):// or file:// url (localhost:3000 is taken as http://localhost:3000)
|
|
15591
|
+
ref: an element's [n] from the last read, for click/hover/type
|
|
15592
|
+
x: page x coordinate for click/hover (from read's @x,y or the screenshot), when no ref
|
|
15593
|
+
y: page y coordinate, with x
|
|
15594
|
+
text: what to type (type), the key(s) to press (key), or the JavaScript expression (js)
|
|
15595
|
+
clear: type only — replace the field's current value instead of adding to it
|
|
15596
|
+
submit: type only — press Enter after typing
|
|
15597
|
+
direction: scroll only — down (default), up, left or right
|
|
15598
|
+
amount: scroll only — pixels (default most of a screen)
|
|
15599
|
+
seconds: wait only — 1 to 30
|
|
15600
|
+
tab: switch only — a tab id from tabs
|
|
15601
|
+
new_tab: open only — open in a new tab instead of the current gu tab
|
|
15602
|
+
"""
|
|
15603
|
+
import gu_browser as _gb
|
|
15604
|
+
a = (action or "").strip().lower().replace(" ", "_").replace("-", "_")
|
|
15605
|
+
try:
|
|
15606
|
+
cmd = {"action": a}
|
|
15607
|
+
if url:
|
|
15608
|
+
u = url.strip()
|
|
15609
|
+
if re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?(/|$)", u):
|
|
15610
|
+
u = "http://" + u
|
|
15611
|
+
elif u.startswith("/") and os.path.exists(u):
|
|
15612
|
+
u = "file://" + u
|
|
15613
|
+
cmd["url"] = u
|
|
15614
|
+
if ref:
|
|
15615
|
+
cmd["ref"] = int(ref)
|
|
15616
|
+
if x is not None and y is not None and int(x) >= 0 and int(y) >= 0:
|
|
15617
|
+
cmd["x"], cmd["y"] = float(x), float(y)
|
|
15618
|
+
if text:
|
|
15619
|
+
cmd["text"] = str(text)
|
|
15620
|
+
for k, v in (("clear", clear), ("submit", submit), ("new_tab", new_tab)):
|
|
15621
|
+
if v:
|
|
15622
|
+
cmd[k] = True
|
|
15623
|
+
if direction:
|
|
15624
|
+
cmd["direction"] = direction
|
|
15625
|
+
if amount:
|
|
15626
|
+
cmd["amount"] = int(amount)
|
|
15627
|
+
if seconds:
|
|
15628
|
+
cmd["seconds"] = int(seconds)
|
|
15629
|
+
if tab:
|
|
15630
|
+
cmd["tab"] = int(tab)
|
|
15631
|
+
shot_ok = a == "screenshot" and (
|
|
15632
|
+
_is_anthropic_url(coding_base_url) or _coder_takes_images(coding_base_url, coding_model_id))
|
|
15633
|
+
if a == "screenshot" and not shot_ok:
|
|
15634
|
+
cmd["action"] = "read" # this model is not sent images: it gets the page as text
|
|
15635
|
+
what = cmd.get("url") or (f"[{ref}]" if ref else f"{int(x)},{int(y)}" if "x" in cmd else
|
|
15636
|
+
_clip(str(text), 50) if text else "")
|
|
15637
|
+
_emit({"type": "step", "text": f"Browser {a}" + (f" → {what}" if what else "")})
|
|
15638
|
+
ok, res, not_connected = _gb.command(
|
|
15639
|
+
cmd, log=_log,
|
|
15640
|
+
on_wait=lambda: _emit({"type": "step", "text": "Waiting for the gu Chrome extension to connect…"}))
|
|
15641
|
+
if not_connected:
|
|
15642
|
+
_emit({"type": "step", "text": "⚠ The gu Chrome extension is not connected — /browser shows how to install it"})
|
|
15643
|
+
msg = _gb.install_text(str(res))
|
|
15644
|
+
elif not ok:
|
|
15645
|
+
msg = f"Error: browser {a} failed: {res}"
|
|
15646
|
+
elif a == "screenshot" and shot_ok:
|
|
15647
|
+
im, note = _gb.decode_screenshot(res, _IMAGE_MAX_EDGE)
|
|
15648
|
+
_browser_shot["img"] = im
|
|
15649
|
+
msg = _gb.format_result("screenshot", res) + "\n" + note + " The image is attached to this observation."
|
|
15650
|
+
elif a == "screenshot":
|
|
15651
|
+
msg = ("(This coding model is not sent images, so here is the page as text.)\n"
|
|
15652
|
+
+ _gb.format_result("read", res))
|
|
15653
|
+
else:
|
|
15654
|
+
msg = _gb.format_result(a, res)
|
|
15655
|
+
except Exception as ex: # noqa: BLE001
|
|
15656
|
+
msg = f"Error: browser {a} failed: {type(ex).__name__}: {ex}"
|
|
15657
|
+
_last_obs["text"] = msg
|
|
15658
|
+
return msg
|
|
15659
|
+
|
|
15479
15660
|
agent_tools = [http_request, web_search, fetch_url, calculate,
|
|
15480
15661
|
get_current_datetime, create_pdf, create_download]
|
|
15481
15662
|
# Only register the PDF reader / email / RAG tools when available — the model
|
|
@@ -15496,6 +15677,8 @@ def run_agent_chat(cfg):
|
|
|
15496
15677
|
agent_tools += [symbol_context, grep_repo, read_file_lines, edit_lines, edit_file,
|
|
15497
15678
|
create_file, create_folder, run_command, stop_server,
|
|
15498
15679
|
shell_output, deploy_web]
|
|
15680
|
+
if _browser_on():
|
|
15681
|
+
agent_tools.append(browser)
|
|
15499
15682
|
# Workspace RAG (task #133): semantic search over the PROJECT itself, not just a
|
|
15500
15683
|
# ZIP-at-URL. Needs both a workspace to index and a working embedder, so it's
|
|
15501
15684
|
# registered only when RAG is actually available — the model never sees a tool it
|
package/gu-cli.mjs
CHANGED
|
@@ -74,6 +74,9 @@ function skillsAndMcpForJob(cwd) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
const WORKER_DIR = dirname(fileURLToPath(import.meta.url));
|
|
77
|
+
// The agent's browser tool starts browser-bridge.mjs with THIS node (#363): a launchd/systemd worker
|
|
78
|
+
// often has no node on PATH.
|
|
79
|
+
process.env.GU_NODE ||= process.execPath;
|
|
77
80
|
|
|
78
81
|
// Running worker version, read from the package's own package.json (single source of
|
|
79
82
|
// truth) so the startup banner shows exactly which build a box is running (task #105).
|
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 {
|
|
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)
|