@tiens.nguyen/gu-cli 1.0.834 → 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 -1
- package/browser-bridge.mjs +72 -11
- package/browser-command.mjs +214 -20
- package/browser-extension/README.md +67 -27
- package/browser-extension/actions.js +319 -0
- package/browser-extension/background.js +100 -322
- package/browser-extension/icons/icon32.png +0 -0
- package/browser-extension/manifest.json +3 -1
- package/browser-extension/popup.html +4 -0
- package/browser-extension/popup.js +23 -0
- package/client-expert-api.mjs +43 -2
- package/gonext_agent_chat.py +147 -24
- package/gu-cli.mjs +11 -12
- package/gu-repl.mjs +114 -10
- package/gu_browser.py +92 -20
- package/package.json +3 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +40 -6
- package/terminal-layout.mjs +1 -0
package/gonext_agent_chat.py
CHANGED
|
@@ -8071,9 +8071,9 @@ _BROWSER_LINE = (
|
|
|
8071
8071
|
"(text + numbered elements) or browser(\"screenshot\") → browser(\"click\", ref=n) / "
|
|
8072
8072
|
"browser(\"type\", ref=n, text=…) → read again to check the result; browser(\"console\") shows "
|
|
8073
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.
|
|
8075
|
-
"
|
|
8076
|
-
"
|
|
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
8077
|
|
|
8078
8078
|
|
|
8079
8079
|
# Background shells (task #360). Never `&` (refused) — background=True is the way to say it.
|
|
@@ -8725,6 +8725,8 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
|
|
|
8725
8725
|
_emit({"type": "step", "text": "Awaiting your choice: compact the context?"})
|
|
8726
8726
|
elif command.startswith("__S3SETUP__::"):
|
|
8727
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"})
|
|
8728
8730
|
elif command.startswith("__MCPTOOL__::"):
|
|
8729
8731
|
_srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
|
|
8730
8732
|
_emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
|
|
@@ -8942,6 +8944,90 @@ def _ws_pgroup_pids(pgid: int) -> list:
|
|
|
8942
8944
|
return []
|
|
8943
8945
|
|
|
8944
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
|
+
|
|
8945
9031
|
def _ws_parse_netstat_ports(stdout: str, pids: set) -> list:
|
|
8946
9032
|
"""LISTENING ports owned by `pids`, from `netstat -ano` output. Pure, so it is testable
|
|
8947
9033
|
on a Mac against recorded Windows output — running netstat here is not."""
|
|
@@ -9054,7 +9140,7 @@ def _ws_load_servers(prune: bool = True) -> list:
|
|
|
9054
9140
|
except Exception: # noqa: BLE001
|
|
9055
9141
|
return []
|
|
9056
9142
|
if prune:
|
|
9057
|
-
servers = [s for s in servers if
|
|
9143
|
+
servers = [s for s in servers if _ws_record_pids(s)]
|
|
9058
9144
|
return servers
|
|
9059
9145
|
|
|
9060
9146
|
|
|
@@ -9080,10 +9166,8 @@ def _ws_cap_servers(max_live: int = 3) -> list:
|
|
|
9080
9166
|
if len(servers) > max_live:
|
|
9081
9167
|
for s in servers[:-max_live]: # registry is append-ordered → oldest first
|
|
9082
9168
|
try:
|
|
9083
|
-
|
|
9084
|
-
if pgid <= 0:
|
|
9169
|
+
if not _ws_stop_record(s):
|
|
9085
9170
|
continue
|
|
9086
|
-
_ws_kill_tree(pgid)
|
|
9087
9171
|
_log(f"server lifecycle: stopped old background server pid={s.get('pid')} "
|
|
9088
9172
|
f"ports={s.get('ports')} ('{str(s.get('command'))[:40]}')")
|
|
9089
9173
|
except (OSError, ValueError) as e: # already gone / no permission
|
|
@@ -9169,7 +9253,7 @@ def _shell_state(s: dict, alive=None, now=None) -> dict:
|
|
|
9169
9253
|
started = float(s.get("started") or now)
|
|
9170
9254
|
code, code_at = _shell_exit_code(s)
|
|
9171
9255
|
if alive is None:
|
|
9172
|
-
alive = code is None and bool(
|
|
9256
|
+
alive = code is None and bool(_ws_record_pids(s))
|
|
9173
9257
|
if alive and code is None and not s.get("killed"):
|
|
9174
9258
|
return {"state": "running", "code": None, "runtime": max(0.0, now - started), "ended": now}
|
|
9175
9259
|
ended = float(s.get("ended") or code_at or now)
|
|
@@ -9333,10 +9417,10 @@ def _shell_kill(s: dict, by: str = "the agent (stop_server)") -> None:
|
|
|
9333
9417
|
if _shell_state(s)["state"] != "running":
|
|
9334
9418
|
return
|
|
9335
9419
|
try:
|
|
9336
|
-
|
|
9337
|
-
|
|
9338
|
-
|
|
9339
|
-
|
|
9420
|
+
if _ws_stop_record(s):
|
|
9421
|
+
_t.sleep(1.0)
|
|
9422
|
+
if _ws_record_pids(s):
|
|
9423
|
+
_ws_stop_record(s, force=True)
|
|
9340
9424
|
except (OSError, ValueError):
|
|
9341
9425
|
pass
|
|
9342
9426
|
s["killed"] = True
|
|
@@ -15357,12 +15441,11 @@ def run_agent_chat(cfg):
|
|
|
15357
15441
|
return msg
|
|
15358
15442
|
else:
|
|
15359
15443
|
target = servers[-1]
|
|
15360
|
-
pgid = int(target["pgid"])
|
|
15361
15444
|
try:
|
|
15362
|
-
|
|
15363
|
-
|
|
15364
|
-
|
|
15365
|
-
|
|
15445
|
+
if _ws_stop_record(target):
|
|
15446
|
+
_t.sleep(1.5)
|
|
15447
|
+
if _ws_record_pids(target):
|
|
15448
|
+
_ws_stop_record(target, force=True)
|
|
15366
15449
|
except OSError:
|
|
15367
15450
|
pass
|
|
15368
15451
|
_ws_save_servers([s for s in servers if s is not target])
|
|
@@ -15578,12 +15661,40 @@ def run_agent_chat(cfg):
|
|
|
15578
15661
|
_last_obs["text"] = msg
|
|
15579
15662
|
return msg
|
|
15580
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
|
+
|
|
15581
15692
|
@tool
|
|
15582
15693
|
def browser(action: str, url: str = "", ref: int = 0, x: int = -1, y: int = -1,
|
|
15583
15694
|
text: str = "", clear: bool = False, submit: bool = False,
|
|
15584
|
-
direction: str = "", amount: int = 0, seconds: int = 0, tab:
|
|
15695
|
+
direction: str = "", amount: int = 0, seconds: int = 0, tab: str = "",
|
|
15585
15696
|
new_tab: bool = False) -> str:
|
|
15586
|
-
"""Use a REAL Chrome browser
|
|
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).
|
|
15587
15698
|
|
|
15588
15699
|
Args:
|
|
15589
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
|
|
@@ -15597,7 +15708,7 @@ def run_agent_chat(cfg):
|
|
|
15597
15708
|
direction: scroll only — down (default), up, left or right
|
|
15598
15709
|
amount: scroll only — pixels (default most of a screen)
|
|
15599
15710
|
seconds: wait only — 1 to 30
|
|
15600
|
-
tab: switch only — a tab id from tabs
|
|
15711
|
+
tab: switch only — a tab id from tabs (a number, or an id like "8F3A…" in gu's own window)
|
|
15601
15712
|
new_tab: open only — open in a new tab instead of the current gu tab
|
|
15602
15713
|
"""
|
|
15603
15714
|
import gu_browser as _gb
|
|
@@ -15627,7 +15738,7 @@ def run_agent_chat(cfg):
|
|
|
15627
15738
|
if seconds:
|
|
15628
15739
|
cmd["seconds"] = int(seconds)
|
|
15629
15740
|
if tab:
|
|
15630
|
-
cmd["tab"] = int(tab)
|
|
15741
|
+
cmd["tab"] = int(tab) if str(tab).strip().isdigit() else str(tab).strip()
|
|
15631
15742
|
shot_ok = a == "screenshot" and (
|
|
15632
15743
|
_is_anthropic_url(coding_base_url) or _coder_takes_images(coding_base_url, coding_model_id))
|
|
15633
15744
|
if a == "screenshot" and not shot_ok:
|
|
@@ -15635,11 +15746,20 @@ def run_agent_chat(cfg):
|
|
|
15635
15746
|
what = cmd.get("url") or (f"[{ref}]" if ref else f"{int(x)},{int(y)}" if "x" in cmd else
|
|
15636
15747
|
_clip(str(text), 50) if text else "")
|
|
15637
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
|
|
15638
15753
|
ok, res, not_connected = _gb.command(
|
|
15639
|
-
cmd, log=_log,
|
|
15640
|
-
on_wait=lambda: _emit({"type": "step", "text": "Waiting for
|
|
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)"})
|
|
15641
15761
|
if not_connected:
|
|
15642
|
-
_emit({"type": "step", "text": "⚠
|
|
15762
|
+
_emit({"type": "step", "text": "⚠ No browser gu can use — /browser shows why"})
|
|
15643
15763
|
msg = _gb.install_text(str(res))
|
|
15644
15764
|
elif not ok:
|
|
15645
15765
|
msg = f"Error: browser {a} failed: {res}"
|
|
@@ -15652,6 +15772,9 @@ def run_agent_chat(cfg):
|
|
|
15652
15772
|
+ _gb.format_result("read", res))
|
|
15653
15773
|
else:
|
|
15654
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)
|
|
15655
15778
|
except Exception as ex: # noqa: BLE001
|
|
15656
15779
|
msg = f"Error: browser {a} failed: {type(ex).__name__}: {ex}"
|
|
15657
15780
|
_last_obs["text"] = msg
|
package/gu-cli.mjs
CHANGED
|
@@ -854,8 +854,9 @@ const currentApiVersion = async () => {
|
|
|
854
854
|
}
|
|
855
855
|
// RESTART LAST, and only after the new version can actually run — currentVersion() now
|
|
856
856
|
// refuses a deps-less directory, so a failed install above leaves the old one serving.
|
|
857
|
-
|
|
858
|
-
|
|
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) {
|
|
859
860
|
try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
|
|
860
861
|
// WAIT FOR THE PORT, not just for the signal. SIGTERM returns immediately and the old
|
|
861
862
|
// server keeps the socket for a moment; starting the new one into that gap fails as
|
|
@@ -883,9 +884,9 @@ const currentApiVersion = async () => {
|
|
|
883
884
|
}
|
|
884
885
|
|
|
885
886
|
if (sub === "stop") {
|
|
886
|
-
const pid =
|
|
887
|
-
if (!
|
|
888
|
-
console.log(
|
|
887
|
+
const { pid, why } = await ce.apiPidToStop();
|
|
888
|
+
if (!pid) {
|
|
889
|
+
console.log(`\n Nothing stopped: ${why}.\n`);
|
|
889
890
|
process.exit(0);
|
|
890
891
|
}
|
|
891
892
|
try {
|
|
@@ -946,9 +947,9 @@ const currentApiVersion = async () => {
|
|
|
946
947
|
* ANSWERING before starting the new one.
|
|
947
948
|
*/
|
|
948
949
|
if (sub === "restart") {
|
|
949
|
-
const pid =
|
|
950
|
+
const { pid } = await ce.apiPidToStop();
|
|
950
951
|
const before = await ce.probeLocalApi();
|
|
951
|
-
if (
|
|
952
|
+
if (pid) {
|
|
952
953
|
try {
|
|
953
954
|
process.kill(pid, "SIGTERM");
|
|
954
955
|
console.log(`\n Stopping the local API (pid ${pid})…`);
|
|
@@ -964,7 +965,7 @@ const currentApiVersion = async () => {
|
|
|
964
965
|
// Answering, but we have no pid to signal — started by hand, or the file was lost. Say so
|
|
965
966
|
// rather than starting a SECOND one on the same port and reporting success.
|
|
966
967
|
console.log(
|
|
967
|
-
`\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,` +
|
|
968
969
|
"\n so it cannot be stopped from here. Stop it yourself, then: gu-cli api start\n"
|
|
969
970
|
);
|
|
970
971
|
process.exit(1);
|
|
@@ -1292,10 +1293,8 @@ if (args[0] === "clear" || args[0] === "reset") {
|
|
|
1292
1293
|
// nothing on the machine any longer knows about.
|
|
1293
1294
|
if (groups.includes("identity")) {
|
|
1294
1295
|
const ce = await import("./client-expert-api.mjs");
|
|
1295
|
-
const pid =
|
|
1296
|
-
|
|
1297
|
-
);
|
|
1298
|
-
if (Number.isFinite(pid) && pid > 0) {
|
|
1296
|
+
const { pid } = await ce.apiPidToStop();
|
|
1297
|
+
if (pid) {
|
|
1299
1298
|
try {
|
|
1300
1299
|
process.kill(pid, "SIGTERM");
|
|
1301
1300
|
console.log(`stopped the local API (pid ${pid})`);
|
package/gu-repl.mjs
CHANGED
|
@@ -139,7 +139,7 @@ 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 { browserReport, bridgeStatus, extensionDir, helpInstall, installExtensionFiles } from "./browser-command.mjs";
|
|
142
|
+
import { allowIn, allowedIn, bridgeCommand, browserReport, bridgeStatus, extensionDir, findDevInstalls, helpInstall, installExtensionFiles, installSteps, localLeftovers, openExtensionsPage, removeLocalFiles, STORE_URL, stopBridge } from "./browser-command.mjs";
|
|
143
143
|
import { commandLogFile, killShell, logTail, markAnnounced, readShells, recapQuestion, shellsToRecap, statusText as shellStatusText, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
|
|
144
144
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
145
145
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
@@ -954,7 +954,7 @@ const COMMANDS = [
|
|
|
954
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" },
|
|
955
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" },
|
|
956
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" },
|
|
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" },
|
|
958
958
|
{ name: "/shells", desc: "long commands the agent left running in the background — status, runtime, command, output; stop one" },
|
|
959
959
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
960
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" },
|
|
@@ -9565,21 +9565,87 @@ function printShellDetails(rec) {
|
|
|
9565
9565
|
*/
|
|
9566
9566
|
// /browser (#363): the extension's connection, else how to install it.
|
|
9567
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() {
|
|
9568
9579
|
let folder = extensionDir();
|
|
9569
|
-
|
|
9570
|
-
|
|
9571
|
-
|
|
9572
|
-
|
|
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
|
+
}
|
|
9573
9586
|
}
|
|
9574
|
-
|
|
9575
|
-
const lines = browserReport(st, folder);
|
|
9587
|
+
await bridgeStatus().catch(() => null); // running, so a fresh install has something to connect to
|
|
9576
9588
|
console.log("");
|
|
9577
|
-
for (const
|
|
9578
|
-
if (
|
|
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.)"));
|
|
9579
9591
|
console.log("");
|
|
9580
9592
|
paintBar();
|
|
9581
9593
|
}
|
|
9582
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
|
+
|
|
9583
9649
|
async function showShells() {
|
|
9584
9650
|
if (listPickerActive || approvalActive) return;
|
|
9585
9651
|
const width = Math.max(20, process.stdout.columns || 80);
|
|
@@ -10540,6 +10606,9 @@ const S3SETUP_PREFIX = "__S3SETUP__::";
|
|
|
10540
10606
|
// "Always" writes an allow rule to ~/.gonext/mcp.json — the agent re-reads that file before every
|
|
10541
10607
|
// call, so it counts from the next call of this same turn, and for every turn after.
|
|
10542
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__::";
|
|
10543
10612
|
async function approvalPrompt(command) {
|
|
10544
10613
|
// Wipe the live "thinking" status block BEFORE any picker draws, and stop the ticker from
|
|
10545
10614
|
// redrawing it (the tick early-returns while a picker is active) — otherwise the block
|
|
@@ -10597,6 +10666,25 @@ async function approvalPrompt(command) {
|
|
|
10597
10666
|
);
|
|
10598
10667
|
return { allow: idx === 0, dontAsk: false };
|
|
10599
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
|
+
}
|
|
10600
10688
|
if (command.startsWith(MCPTOOL_PREFIX)) {
|
|
10601
10689
|
const [server = "", tool = "", preview = ""] = command.slice(MCPTOOL_PREFIX.length).split("\t");
|
|
10602
10690
|
if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
|
|
@@ -14211,6 +14299,22 @@ async function main() {
|
|
|
14211
14299
|
await showBrowser();
|
|
14212
14300
|
continue;
|
|
14213
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
|
+
}
|
|
14214
14318
|
if (line === "/model") {
|
|
14215
14319
|
await chooseModel();
|
|
14216
14320
|
continue;
|