@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/README.md +1 -0
- package/browser-bridge.mjs +335 -0
- package/browser-command.mjs +310 -0
- package/browser-extension/README.md +151 -0
- package/browser-extension/actions.js +319 -0
- package/browser-extension/background.js +261 -0
- package/browser-extension/icons/icon128.png +0 -0
- package/browser-extension/icons/icon16.png +0 -0
- package/browser-extension/icons/icon32.png +0 -0
- package/browser-extension/icons/icon48.png +0 -0
- package/browser-extension/manifest.json +37 -0
- package/browser-extension/popup.html +26 -0
- package/browser-extension/popup.js +45 -0
- package/client-expert-api.mjs +43 -2
- package/gonext_agent_chat.py +325 -19
- package/gu-cli.mjs +14 -12
- package/gu-repl.mjs +170 -2
- package/gu_browser.py +335 -0
- package/package.json +11 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +98 -10
- package/terminal-layout.mjs +1 -0
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. The first browser call asks the user's "
|
|
8075
|
+
"permission (nothing to install: without the gu extension it opens gu's own Chrome window). "
|
|
8076
|
+
"If the user says no, or it answers BROWSER NOT AVAILABLE, stop and tell the user.\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 "
|
|
@@ -8701,6 +8725,8 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
|
|
|
8701
8725
|
_emit({"type": "step", "text": "Awaiting your choice: compact the context?"})
|
|
8702
8726
|
elif command.startswith("__S3SETUP__::"):
|
|
8703
8727
|
_emit({"type": "step", "text": "Awaiting your choice: set up S3, or save on this computer?"})
|
|
8728
|
+
elif command.startswith("__BROWSER__::"):
|
|
8729
|
+
_emit({"type": "step", "text": "Awaiting your approval: use a web browser"})
|
|
8704
8730
|
elif command.startswith("__MCPTOOL__::"):
|
|
8705
8731
|
_srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
|
|
8706
8732
|
_emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
|
|
@@ -8918,6 +8944,90 @@ def _ws_pgroup_pids(pgid: int) -> list:
|
|
|
8918
8944
|
return []
|
|
8919
8945
|
|
|
8920
8946
|
|
|
8947
|
+
# A SAVED PGID IS ONLY OURS WHILE ITS LEADER IS THE PROCESS WE STARTED (task #373, GitHub #154).
|
|
8948
|
+
# shells.json / servers.json outlive their commands. After a reboot, a crash or a closed terminal
|
|
8949
|
+
# the id is free, and the OS hands it to the next program — an Electron app such as VS Code is its
|
|
8950
|
+
# own group leader — so "a group with that id exists" said "running" and a stop SIGTERMed that
|
|
8951
|
+
# app. The record's own start time is the proof: a process whose pid == the pgid must have started
|
|
8952
|
+
# when the record says (± _WS_START_SLACK). The leader gone but members left is still ours: an id
|
|
8953
|
+
# is never reused while a process group of that id exists.
|
|
8954
|
+
_WS_START_SLACK = 5.0
|
|
8955
|
+
|
|
8956
|
+
|
|
8957
|
+
def _ws_proc_start(pid: int):
|
|
8958
|
+
"""When process `pid` started (epoch seconds), None when there is no such process, or "" when
|
|
8959
|
+
it cannot be told (no ps). POSIX ps lstart: the same spelling on macOS and procps."""
|
|
8960
|
+
import subprocess
|
|
8961
|
+
import time as _t
|
|
8962
|
+
try:
|
|
8963
|
+
out = subprocess.run(["ps", "-o", "lstart=", "-p", str(int(pid))], stdout=subprocess.PIPE,
|
|
8964
|
+
stderr=subprocess.DEVNULL, text=True, timeout=5,
|
|
8965
|
+
env={**os.environ, "LC_ALL": "C", "LANG": "C"})
|
|
8966
|
+
except Exception: # noqa: BLE001
|
|
8967
|
+
return ""
|
|
8968
|
+
raw = " ".join(out.stdout.split())
|
|
8969
|
+
if not raw:
|
|
8970
|
+
return None
|
|
8971
|
+
try:
|
|
8972
|
+
return _t.mktime(_t.strptime(raw, "%a %b %d %H:%M:%S %Y"))
|
|
8973
|
+
except ValueError:
|
|
8974
|
+
return ""
|
|
8975
|
+
|
|
8976
|
+
|
|
8977
|
+
def _ws_record_started(rec: dict):
|
|
8978
|
+
"""The record's start time (epoch), from `started`, else the older `started_at` string."""
|
|
8979
|
+
import time as _t
|
|
8980
|
+
try:
|
|
8981
|
+
if rec.get("started") is not None:
|
|
8982
|
+
return float(rec["started"])
|
|
8983
|
+
except (TypeError, ValueError):
|
|
8984
|
+
pass
|
|
8985
|
+
try:
|
|
8986
|
+
return _t.mktime(_t.strptime(str(rec.get("started_at")), "%Y-%m-%d %H:%M:%S"))
|
|
8987
|
+
except (ValueError, OverflowError):
|
|
8988
|
+
return None
|
|
8989
|
+
|
|
8990
|
+
|
|
8991
|
+
def _ws_group_owner(rec: dict, proc_start=None) -> str:
|
|
8992
|
+
"""Whose is the group a shell/server record names now?
|
|
8993
|
+
"ours" — its leader is the process the record started;
|
|
8994
|
+
"leader-gone" — no process has that id; any group members left are still ours;
|
|
8995
|
+
"other" — that id now belongs to a process started at another time: NOT ours;
|
|
8996
|
+
"unknown" — nothing to compare (no start time recorded, or ps could not say).
|
|
8997
|
+
Windows has no process groups; its records are taken as they are."""
|
|
8998
|
+
if _IS_WINDOWS:
|
|
8999
|
+
return "ours"
|
|
9000
|
+
pgid = int(rec.get("pgid") or rec.get("pid") or 0)
|
|
9001
|
+
if pgid <= 0:
|
|
9002
|
+
return "unknown"
|
|
9003
|
+
leader = (proc_start or _ws_proc_start)(pgid)
|
|
9004
|
+
if leader is None:
|
|
9005
|
+
return "leader-gone"
|
|
9006
|
+
started = _ws_record_started(rec)
|
|
9007
|
+
if leader == "" or started is None:
|
|
9008
|
+
return "unknown"
|
|
9009
|
+
return "ours" if abs(float(leader) - started) <= _WS_START_SLACK else "other"
|
|
9010
|
+
|
|
9011
|
+
|
|
9012
|
+
def _ws_record_pids(rec: dict) -> list:
|
|
9013
|
+
"""Live pids of a record's group — none when the id has passed to another program."""
|
|
9014
|
+
if _ws_group_owner(rec) == "other":
|
|
9015
|
+
return []
|
|
9016
|
+
return _ws_pgroup_pids(int(rec.get("pgid") or rec.get("pid") or 0))
|
|
9017
|
+
|
|
9018
|
+
|
|
9019
|
+
def _ws_stop_record(rec: dict, force: bool = False) -> bool:
|
|
9020
|
+
"""Signal a record's group ONLY when it is provably ours. False = nothing was signalled."""
|
|
9021
|
+
owner = _ws_group_owner(rec)
|
|
9022
|
+
pgid = int(rec.get("pgid") or rec.get("pid") or 0)
|
|
9023
|
+
if owner not in ("ours", "leader-gone") or pgid <= 0:
|
|
9024
|
+
_log(f"not stopping pgid={pgid} ('{str(rec.get('command'))[:40]}'): {owner} — the id is no "
|
|
9025
|
+
"longer the process gu started")
|
|
9026
|
+
return False
|
|
9027
|
+
_ws_kill_tree(pgid, force=force)
|
|
9028
|
+
return True
|
|
9029
|
+
|
|
9030
|
+
|
|
8921
9031
|
def _ws_parse_netstat_ports(stdout: str, pids: set) -> list:
|
|
8922
9032
|
"""LISTENING ports owned by `pids`, from `netstat -ano` output. Pure, so it is testable
|
|
8923
9033
|
on a Mac against recorded Windows output — running netstat here is not."""
|
|
@@ -9030,7 +9140,7 @@ def _ws_load_servers(prune: bool = True) -> list:
|
|
|
9030
9140
|
except Exception: # noqa: BLE001
|
|
9031
9141
|
return []
|
|
9032
9142
|
if prune:
|
|
9033
|
-
servers = [s for s in servers if
|
|
9143
|
+
servers = [s for s in servers if _ws_record_pids(s)]
|
|
9034
9144
|
return servers
|
|
9035
9145
|
|
|
9036
9146
|
|
|
@@ -9056,10 +9166,8 @@ def _ws_cap_servers(max_live: int = 3) -> list:
|
|
|
9056
9166
|
if len(servers) > max_live:
|
|
9057
9167
|
for s in servers[:-max_live]: # registry is append-ordered → oldest first
|
|
9058
9168
|
try:
|
|
9059
|
-
|
|
9060
|
-
if pgid <= 0:
|
|
9169
|
+
if not _ws_stop_record(s):
|
|
9061
9170
|
continue
|
|
9062
|
-
_ws_kill_tree(pgid)
|
|
9063
9171
|
_log(f"server lifecycle: stopped old background server pid={s.get('pid')} "
|
|
9064
9172
|
f"ports={s.get('ports')} ('{str(s.get('command'))[:40]}')")
|
|
9065
9173
|
except (OSError, ValueError) as e: # already gone / no permission
|
|
@@ -9145,7 +9253,7 @@ def _shell_state(s: dict, alive=None, now=None) -> dict:
|
|
|
9145
9253
|
started = float(s.get("started") or now)
|
|
9146
9254
|
code, code_at = _shell_exit_code(s)
|
|
9147
9255
|
if alive is None:
|
|
9148
|
-
alive = code is None and bool(
|
|
9256
|
+
alive = code is None and bool(_ws_record_pids(s))
|
|
9149
9257
|
if alive and code is None and not s.get("killed"):
|
|
9150
9258
|
return {"state": "running", "code": None, "runtime": max(0.0, now - started), "ended": now}
|
|
9151
9259
|
ended = float(s.get("ended") or code_at or now)
|
|
@@ -9155,7 +9263,8 @@ def _shell_state(s: dict, alive=None, now=None) -> dict:
|
|
|
9155
9263
|
state = "ended"
|
|
9156
9264
|
else:
|
|
9157
9265
|
state = "exited" if code == 0 else "failed"
|
|
9158
|
-
return {"state": state, "code": code, "runtime": max(0.0, ended - started), "ended": ended
|
|
9266
|
+
return {"state": state, "code": code, "runtime": max(0.0, ended - started), "ended": ended,
|
|
9267
|
+
"by": s.get("killed_by") or ""}
|
|
9159
9268
|
|
|
9160
9269
|
|
|
9161
9270
|
def _fmt_runtime(seconds: float) -> str:
|
|
@@ -9171,7 +9280,11 @@ def _shell_status_text(st: dict) -> str:
|
|
|
9171
9280
|
return "running"
|
|
9172
9281
|
if st["state"] in ("exited", "failed"):
|
|
9173
9282
|
return f"exited {st['code']}"
|
|
9174
|
-
|
|
9283
|
+
# WHO STOPPED IT (#362/#143). A bare "killed" read as a crash — "shell got status killed why?"
|
|
9284
|
+
# — when the agent had stopped its own search on purpose. Every stop says who did it.
|
|
9285
|
+
if st["state"] == "killed":
|
|
9286
|
+
return f"killed — stopped by {st['by']}" if st.get("by") else "killed"
|
|
9287
|
+
return "ended (exit code unknown)"
|
|
9175
9288
|
|
|
9176
9289
|
|
|
9177
9290
|
def _shell_find(shells: list, key: str):
|
|
@@ -9298,22 +9411,65 @@ def _file_tail(path: str, max_chars: int = 4000) -> str:
|
|
|
9298
9411
|
return ""
|
|
9299
9412
|
|
|
9300
9413
|
|
|
9301
|
-
def _shell_kill(s: dict) -> None:
|
|
9414
|
+
def _shell_kill(s: dict, by: str = "the agent (stop_server)") -> None:
|
|
9302
9415
|
"""Stop a shell's whole process group and mark it killed (unless it already finished)."""
|
|
9303
9416
|
import time as _t
|
|
9304
9417
|
if _shell_state(s)["state"] != "running":
|
|
9305
9418
|
return
|
|
9306
9419
|
try:
|
|
9307
|
-
|
|
9308
|
-
|
|
9309
|
-
|
|
9310
|
-
|
|
9420
|
+
if _ws_stop_record(s):
|
|
9421
|
+
_t.sleep(1.0)
|
|
9422
|
+
if _ws_record_pids(s):
|
|
9423
|
+
_ws_stop_record(s, force=True)
|
|
9311
9424
|
except (OSError, ValueError):
|
|
9312
9425
|
pass
|
|
9313
9426
|
s["killed"] = True
|
|
9427
|
+
s["killed_by"] = by
|
|
9314
9428
|
s["ended"] = _t.time()
|
|
9315
9429
|
|
|
9316
9430
|
|
|
9431
|
+
# A SHELL THAT FINISHES IS NEWS (#362), the way Claude Code tells its model "background command X
|
|
9432
|
+
# completed". During a turn the note rides on the next observation (append-only — no cache
|
|
9433
|
+
# break); after the turn the terminal starts a short recap turn (gu-repl.mjs, wakeForShells).
|
|
9434
|
+
# `announced` stops both from telling twice; `seen` means the agent already read the final state.
|
|
9435
|
+
def _shells_in_scope(shells: list, roots=None) -> list:
|
|
9436
|
+
"""The shells started inside these folders (default: the registered workspaces) — shells.json
|
|
9437
|
+
is one file for every project, and another project's build is not this turn's news."""
|
|
9438
|
+
paths = [os.path.realpath(r["path"]) for r in (_WS_ROOTS if roots is None else roots)
|
|
9439
|
+
if isinstance(r, dict) and r.get("path")]
|
|
9440
|
+
out = []
|
|
9441
|
+
for s in shells:
|
|
9442
|
+
wd = os.path.realpath(str(s.get("workdir") or ""))
|
|
9443
|
+
if any(wd == p or wd.startswith(p.rstrip(os.sep) + os.sep) for p in paths):
|
|
9444
|
+
out.append(s)
|
|
9445
|
+
return out
|
|
9446
|
+
|
|
9447
|
+
|
|
9448
|
+
def _shells_finished_note(shells: list, now=None) -> str:
|
|
9449
|
+
"""Notes for shells that finished and were neither read nor announced; marks them announced
|
|
9450
|
+
in `shells` (the caller saves). "" when there is nothing new."""
|
|
9451
|
+
notes = []
|
|
9452
|
+
for s in shells:
|
|
9453
|
+
if s.get("seen") or s.get("announced"):
|
|
9454
|
+
continue
|
|
9455
|
+
st = _shell_state(s, now=now)
|
|
9456
|
+
if st["state"] == "running":
|
|
9457
|
+
continue
|
|
9458
|
+
s["announced"] = True
|
|
9459
|
+
note = (f"[background shell {s.get('id')} finished: {_shell_status_text(st)} after "
|
|
9460
|
+
f"{_fmt_runtime(st['runtime'])} — '{_clip(str(s.get('command')), 120)}'. "
|
|
9461
|
+
f"shell_output('{s.get('id')}') has its output.]")
|
|
9462
|
+
try:
|
|
9463
|
+
with open(s.get("log") or "", encoding="utf-8", errors="replace") as fh:
|
|
9464
|
+
dg = _test_digest(fh.read(), str(s.get("workdir") or ""))
|
|
9465
|
+
if dg:
|
|
9466
|
+
note += "\n" + dg
|
|
9467
|
+
except OSError:
|
|
9468
|
+
pass
|
|
9469
|
+
notes.append(note)
|
|
9470
|
+
return "\n\n".join(notes)
|
|
9471
|
+
|
|
9472
|
+
|
|
9317
9473
|
def _shells_context_line(shells: list, now=None) -> str:
|
|
9318
9474
|
"""The turn's line about shells: what is still running, and what finished since the model
|
|
9319
9475
|
last looked — a build that ended between turns is news the model has not seen."""
|
|
@@ -10725,7 +10881,7 @@ def run_agent_chat(cfg):
|
|
|
10725
10881
|
except Exception as _e: # noqa: BLE001
|
|
10726
10882
|
_log(f"server context skip: {_e}")
|
|
10727
10883
|
try:
|
|
10728
|
-
_ws_servers_line += _shells_context_line(_shells_load())
|
|
10884
|
+
_ws_servers_line += _shells_context_line(_shells_in_scope(_shells_load()))
|
|
10729
10885
|
except Exception as _e: # noqa: BLE001
|
|
10730
10886
|
_log(f"shell context skip: {_e}")
|
|
10731
10887
|
_ws_tests_line = ""
|
|
@@ -10792,7 +10948,8 @@ def run_agent_chat(cfg):
|
|
|
10792
10948
|
"and reads do not pile up here and are not re-sent on every later step. Then read only "
|
|
10793
10949
|
"the exact ranges you will edit. Use grep_repo/read_file_lines directly when you already "
|
|
10794
10950
|
"know the file or the exact string.\n"
|
|
10795
|
-
+ _run_command_prompt_line(bool(_run_shell_bin()))
|
|
10951
|
+
+ _run_command_prompt_line(bool(_run_shell_bin()))
|
|
10952
|
+
+ (_BROWSER_LINE if _browser_on() else "") +
|
|
10796
10953
|
" DEPLOY: prefer deploy_web(local_dir, host, user, remote_path, domain) over "
|
|
10797
10954
|
"hand-written scp/ssh/rsync (it rsyncs over your SSH key and finishes nginx when it "
|
|
10798
10955
|
"can, else writes deploy.sh and returns the exact commands). KEY auth ONLY "
|
|
@@ -11871,9 +12028,38 @@ def run_agent_chat(cfg):
|
|
|
11871
12028
|
saved += _trim_tool_call_args(_st, 120)
|
|
11872
12029
|
return saved
|
|
11873
12030
|
|
|
12031
|
+
_browser_shot = {"img": None} # the browser tool's newest screenshot, for this step (#363)
|
|
12032
|
+
|
|
11874
12033
|
def step_callback(step_log):
|
|
11875
12034
|
step_num = getattr(step_log, "step_number", "?")
|
|
11876
12035
|
|
|
12036
|
+
# A browser screenshot taken in this step goes to the model as an image with the step's
|
|
12037
|
+
# observation (#363). Only the newest two stay in memory: every image is re-sent on every
|
|
12038
|
+
# later step, and an old view of a page that has since changed only misleads.
|
|
12039
|
+
try:
|
|
12040
|
+
if _browser_shot.get("img") is not None:
|
|
12041
|
+
step_log.observations_images = [_browser_shot["img"]]
|
|
12042
|
+
_browser_shot["img"] = None
|
|
12043
|
+
_with = [s for s in (getattr(getattr(_agent_ref.get("a"), "memory", None), "steps", None) or [])
|
|
12044
|
+
if getattr(s, "observations_images", None) and s is not step_log]
|
|
12045
|
+
for _old in _with[:-1]:
|
|
12046
|
+
_old.observations_images = None
|
|
12047
|
+
except Exception as e: # noqa: BLE001
|
|
12048
|
+
_log(f"browser screenshot attach skip: {e}")
|
|
12049
|
+
|
|
12050
|
+
# A background shell that finished during this step is told to the model now (#362).
|
|
12051
|
+
try:
|
|
12052
|
+
_obs_now = getattr(step_log, "observations", None)
|
|
12053
|
+
if isinstance(_obs_now, str):
|
|
12054
|
+
_shs = _shells_load()
|
|
12055
|
+
_fin = _shells_finished_note(_shells_in_scope(_shs))
|
|
12056
|
+
if _fin:
|
|
12057
|
+
step_log.observations = _obs_now + "\n\n" + _fin
|
|
12058
|
+
_shells_save(_shs)
|
|
12059
|
+
_log(f"step {step_num}: told the model a background shell finished")
|
|
12060
|
+
except Exception as e: # noqa: BLE001
|
|
12061
|
+
_log(f"shell-finished note skip: {e}")
|
|
12062
|
+
|
|
11877
12063
|
# Task #74: an executor-timeout error means whatever the step's tool call
|
|
11878
12064
|
# achieved was NEVER reported to the model (a create_pdf may have finished —
|
|
11879
12065
|
# or may still be finishing — in the background). Flag it so a repeat
|
|
@@ -15255,12 +15441,11 @@ def run_agent_chat(cfg):
|
|
|
15255
15441
|
return msg
|
|
15256
15442
|
else:
|
|
15257
15443
|
target = servers[-1]
|
|
15258
|
-
pgid = int(target["pgid"])
|
|
15259
15444
|
try:
|
|
15260
|
-
|
|
15261
|
-
|
|
15262
|
-
|
|
15263
|
-
|
|
15445
|
+
if _ws_stop_record(target):
|
|
15446
|
+
_t.sleep(1.5)
|
|
15447
|
+
if _ws_record_pids(target):
|
|
15448
|
+
_ws_stop_record(target, force=True)
|
|
15264
15449
|
except OSError:
|
|
15265
15450
|
pass
|
|
15266
15451
|
_ws_save_servers([s for s in servers if s is not target])
|
|
@@ -15476,6 +15661,125 @@ def run_agent_chat(cfg):
|
|
|
15476
15661
|
_last_obs["text"] = msg
|
|
15477
15662
|
return msg
|
|
15478
15663
|
|
|
15664
|
+
# ASK FIRST (#150), the way Claude Code does: the first browser call of a turn asks the user
|
|
15665
|
+
# (Allow once / Always in this folder / No), unless this folder is on the "always" list in
|
|
15666
|
+
# ~/.gonext/browser-allow.json. A "No" holds for the rest of the turn.
|
|
15667
|
+
_browser_consent = {"ok": False, "no": False, "where": ""}
|
|
15668
|
+
|
|
15669
|
+
def _browser_ask(what: str):
|
|
15670
|
+
"""→ "" when the browser may be used, else the message for the model."""
|
|
15671
|
+
import gu_browser as _gb
|
|
15672
|
+
if _browser_consent["ok"]:
|
|
15673
|
+
return ""
|
|
15674
|
+
if _browser_consent["no"]:
|
|
15675
|
+
return ("The user did not allow the browser in this turn. Do not call browser again; "
|
|
15676
|
+
"carry on without it, or ask the user.")
|
|
15677
|
+
folder = _WS_ACTIVE or _default_ws_root()
|
|
15678
|
+
if _gb.allowed_in(folder):
|
|
15679
|
+
_browser_consent["ok"] = True
|
|
15680
|
+
return ""
|
|
15681
|
+
if not (_interactive_approval and _job_id and worker_api_base and worker_api_key):
|
|
15682
|
+
return ("The browser needs the user's permission and this session cannot ask. Tell the "
|
|
15683
|
+
"user to allow it (/browser allow in the gu terminal), then carry on without it.")
|
|
15684
|
+
if _ws_request_approval(worker_api_base, worker_api_key, _job_id,
|
|
15685
|
+
f"__BROWSER__::{folder}\t{what}", "", deadline_s=180):
|
|
15686
|
+
_browser_consent["ok"] = True
|
|
15687
|
+
return ""
|
|
15688
|
+
_browser_consent["no"] = True
|
|
15689
|
+
return ("The user did not allow the browser. Do not call browser again this turn; carry on "
|
|
15690
|
+
"without it, or ask the user.")
|
|
15691
|
+
|
|
15692
|
+
@tool
|
|
15693
|
+
def browser(action: str, url: str = "", ref: int = 0, x: int = -1, y: int = -1,
|
|
15694
|
+
text: str = "", clear: bool = False, submit: bool = False,
|
|
15695
|
+
direction: str = "", amount: int = 0, seconds: int = 0, tab: str = "",
|
|
15696
|
+
new_tab: bool = False) -> str:
|
|
15697
|
+
"""Use a REAL Chrome browser 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. The first call asks the user's permission. It uses the user's Chrome through the gu extension when that is installed, otherwise gu's own Chrome window (a separate profile: not logged in to the user's accounts). Loop: open → read (or screenshot) → click / type → read again to check what happened. Only gu's own tabs, and tabs the user shared, are used. If the answer starts BROWSER NOT AVAILABLE, stop and tell the user (the answer says what to do).
|
|
15698
|
+
|
|
15699
|
+
Args:
|
|
15700
|
+
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
|
|
15701
|
+
url: for open — a full http(s):// or file:// url (localhost:3000 is taken as http://localhost:3000)
|
|
15702
|
+
ref: an element's [n] from the last read, for click/hover/type
|
|
15703
|
+
x: page x coordinate for click/hover (from read's @x,y or the screenshot), when no ref
|
|
15704
|
+
y: page y coordinate, with x
|
|
15705
|
+
text: what to type (type), the key(s) to press (key), or the JavaScript expression (js)
|
|
15706
|
+
clear: type only — replace the field's current value instead of adding to it
|
|
15707
|
+
submit: type only — press Enter after typing
|
|
15708
|
+
direction: scroll only — down (default), up, left or right
|
|
15709
|
+
amount: scroll only — pixels (default most of a screen)
|
|
15710
|
+
seconds: wait only — 1 to 30
|
|
15711
|
+
tab: switch only — a tab id from tabs (a number, or an id like "8F3A…" in gu's own window)
|
|
15712
|
+
new_tab: open only — open in a new tab instead of the current gu tab
|
|
15713
|
+
"""
|
|
15714
|
+
import gu_browser as _gb
|
|
15715
|
+
a = (action or "").strip().lower().replace(" ", "_").replace("-", "_")
|
|
15716
|
+
try:
|
|
15717
|
+
cmd = {"action": a}
|
|
15718
|
+
if url:
|
|
15719
|
+
u = url.strip()
|
|
15720
|
+
if re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?(/|$)", u):
|
|
15721
|
+
u = "http://" + u
|
|
15722
|
+
elif u.startswith("/") and os.path.exists(u):
|
|
15723
|
+
u = "file://" + u
|
|
15724
|
+
cmd["url"] = u
|
|
15725
|
+
if ref:
|
|
15726
|
+
cmd["ref"] = int(ref)
|
|
15727
|
+
if x is not None and y is not None and int(x) >= 0 and int(y) >= 0:
|
|
15728
|
+
cmd["x"], cmd["y"] = float(x), float(y)
|
|
15729
|
+
if text:
|
|
15730
|
+
cmd["text"] = str(text)
|
|
15731
|
+
for k, v in (("clear", clear), ("submit", submit), ("new_tab", new_tab)):
|
|
15732
|
+
if v:
|
|
15733
|
+
cmd[k] = True
|
|
15734
|
+
if direction:
|
|
15735
|
+
cmd["direction"] = direction
|
|
15736
|
+
if amount:
|
|
15737
|
+
cmd["amount"] = int(amount)
|
|
15738
|
+
if seconds:
|
|
15739
|
+
cmd["seconds"] = int(seconds)
|
|
15740
|
+
if tab:
|
|
15741
|
+
cmd["tab"] = int(tab) if str(tab).strip().isdigit() else str(tab).strip()
|
|
15742
|
+
shot_ok = a == "screenshot" and (
|
|
15743
|
+
_is_anthropic_url(coding_base_url) or _coder_takes_images(coding_base_url, coding_model_id))
|
|
15744
|
+
if a == "screenshot" and not shot_ok:
|
|
15745
|
+
cmd["action"] = "read" # this model is not sent images: it gets the page as text
|
|
15746
|
+
what = cmd.get("url") or (f"[{ref}]" if ref else f"{int(x)},{int(y)}" if "x" in cmd else
|
|
15747
|
+
_clip(str(text), 50) if text else "")
|
|
15748
|
+
_emit({"type": "step", "text": f"Browser {a}" + (f" → {what}" if what else "")})
|
|
15749
|
+
refused = _browser_ask(f"{a} {what}".strip())
|
|
15750
|
+
if refused:
|
|
15751
|
+
_last_obs["text"] = refused
|
|
15752
|
+
return refused
|
|
15753
|
+
ok, res, not_connected = _gb.command(
|
|
15754
|
+
cmd, log=_log, own=True,
|
|
15755
|
+
on_wait=lambda: _emit({"type": "step", "text": "Waiting for Chrome to connect…"}))
|
|
15756
|
+
where = res.pop("_backend", "") if ok and isinstance(res, dict) else ""
|
|
15757
|
+
if where and where != _browser_consent["where"]:
|
|
15758
|
+
_browser_consent["where"] = where
|
|
15759
|
+
_emit({"type": "step", "text": "Using gu's own Chrome window" if where == "chrome"
|
|
15760
|
+
else "Using your Chrome (gu extension)"})
|
|
15761
|
+
if not_connected:
|
|
15762
|
+
_emit({"type": "step", "text": "⚠ No browser gu can use — /browser shows why"})
|
|
15763
|
+
msg = _gb.install_text(str(res))
|
|
15764
|
+
elif not ok:
|
|
15765
|
+
msg = f"Error: browser {a} failed: {res}"
|
|
15766
|
+
elif a == "screenshot" and shot_ok:
|
|
15767
|
+
im, note = _gb.decode_screenshot(res, _IMAGE_MAX_EDGE)
|
|
15768
|
+
_browser_shot["img"] = im
|
|
15769
|
+
msg = _gb.format_result("screenshot", res) + "\n" + note + " The image is attached to this observation."
|
|
15770
|
+
elif a == "screenshot":
|
|
15771
|
+
msg = ("(This coding model is not sent images, so here is the page as text.)\n"
|
|
15772
|
+
+ _gb.format_result("read", res))
|
|
15773
|
+
else:
|
|
15774
|
+
msg = _gb.format_result(a, res)
|
|
15775
|
+
if ok and where == "chrome" and a == "open":
|
|
15776
|
+
msg = ("(gu's own Chrome window — a separate profile, not signed in to the user's "
|
|
15777
|
+
"accounts.)\n" + msg)
|
|
15778
|
+
except Exception as ex: # noqa: BLE001
|
|
15779
|
+
msg = f"Error: browser {a} failed: {type(ex).__name__}: {ex}"
|
|
15780
|
+
_last_obs["text"] = msg
|
|
15781
|
+
return msg
|
|
15782
|
+
|
|
15479
15783
|
agent_tools = [http_request, web_search, fetch_url, calculate,
|
|
15480
15784
|
get_current_datetime, create_pdf, create_download]
|
|
15481
15785
|
# Only register the PDF reader / email / RAG tools when available — the model
|
|
@@ -15496,6 +15800,8 @@ def run_agent_chat(cfg):
|
|
|
15496
15800
|
agent_tools += [symbol_context, grep_repo, read_file_lines, edit_lines, edit_file,
|
|
15497
15801
|
create_file, create_folder, run_command, stop_server,
|
|
15498
15802
|
shell_output, deploy_web]
|
|
15803
|
+
if _browser_on():
|
|
15804
|
+
agent_tools.append(browser)
|
|
15499
15805
|
# Workspace RAG (task #133): semantic search over the PROJECT itself, not just a
|
|
15500
15806
|
# ZIP-at-URL. Needs both a workspace to index and a working embedder, so it's
|
|
15501
15807
|
# 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).
|
|
@@ -851,8 +854,9 @@ const currentApiVersion = async () => {
|
|
|
851
854
|
}
|
|
852
855
|
// RESTART LAST, and only after the new version can actually run — currentVersion() now
|
|
853
856
|
// refuses a deps-less directory, so a failed install above leaves the old one serving.
|
|
854
|
-
|
|
855
|
-
|
|
857
|
+
// Only while the pid on record is still the local API (#373): a stale file's id may be another program's.
|
|
858
|
+
const { pid } = await ce.apiPidToStop();
|
|
859
|
+
if (pid) {
|
|
856
860
|
try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
|
|
857
861
|
// WAIT FOR THE PORT, not just for the signal. SIGTERM returns immediately and the old
|
|
858
862
|
// server keeps the socket for a moment; starting the new one into that gap fails as
|
|
@@ -880,9 +884,9 @@ const currentApiVersion = async () => {
|
|
|
880
884
|
}
|
|
881
885
|
|
|
882
886
|
if (sub === "stop") {
|
|
883
|
-
const pid =
|
|
884
|
-
if (!
|
|
885
|
-
console.log(
|
|
887
|
+
const { pid, why } = await ce.apiPidToStop();
|
|
888
|
+
if (!pid) {
|
|
889
|
+
console.log(`\n Nothing stopped: ${why}.\n`);
|
|
886
890
|
process.exit(0);
|
|
887
891
|
}
|
|
888
892
|
try {
|
|
@@ -943,9 +947,9 @@ const currentApiVersion = async () => {
|
|
|
943
947
|
* ANSWERING before starting the new one.
|
|
944
948
|
*/
|
|
945
949
|
if (sub === "restart") {
|
|
946
|
-
const pid =
|
|
950
|
+
const { pid } = await ce.apiPidToStop();
|
|
947
951
|
const before = await ce.probeLocalApi();
|
|
948
|
-
if (
|
|
952
|
+
if (pid) {
|
|
949
953
|
try {
|
|
950
954
|
process.kill(pid, "SIGTERM");
|
|
951
955
|
console.log(`\n Stopping the local API (pid ${pid})…`);
|
|
@@ -961,7 +965,7 @@ const currentApiVersion = async () => {
|
|
|
961
965
|
// Answering, but we have no pid to signal — started by hand, or the file was lost. Say so
|
|
962
966
|
// rather than starting a SECOND one on the same port and reporting success.
|
|
963
967
|
console.log(
|
|
964
|
-
`\n The API is answering on ${ce.LOCAL_API_BASE} but
|
|
968
|
+
`\n The API is answering on ${ce.LOCAL_API_BASE} but no pid on record is still it,` +
|
|
965
969
|
"\n so it cannot be stopped from here. Stop it yourself, then: gu-cli api start\n"
|
|
966
970
|
);
|
|
967
971
|
process.exit(1);
|
|
@@ -1289,10 +1293,8 @@ if (args[0] === "clear" || args[0] === "reset") {
|
|
|
1289
1293
|
// nothing on the machine any longer knows about.
|
|
1290
1294
|
if (groups.includes("identity")) {
|
|
1291
1295
|
const ce = await import("./client-expert-api.mjs");
|
|
1292
|
-
const pid =
|
|
1293
|
-
|
|
1294
|
-
);
|
|
1295
|
-
if (Number.isFinite(pid) && pid > 0) {
|
|
1296
|
+
const { pid } = await ce.apiPidToStop();
|
|
1297
|
+
if (pid) {
|
|
1296
1298
|
try {
|
|
1297
1299
|
process.kill(pid, "SIGTERM");
|
|
1298
1300
|
console.log(`stopped the local API (pid ${pid})`);
|