@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.
@@ -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. 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")
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 _ws_pgroup_pids(int(s.get("pgid", 0)))]
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
- pgid = int(s.get("pgid", 0))
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(_ws_pgroup_pids(int(s.get("pgid") or 0)))
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
- _ws_kill_tree(int(s.get("pgid") or 0))
9337
- _t.sleep(1.0)
9338
- if _ws_pgroup_pids(int(s.get("pgid") or 0)):
9339
- _ws_kill_tree(int(s.get("pgid") or 0), force=True)
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
- _ws_kill_tree(pgid)
15363
- _t.sleep(1.5)
15364
- if _ws_pgroup_pids(pgid):
15365
- _ws_kill_tree(pgid, force=True)
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: int = 0,
15695
+ direction: str = "", amount: int = 0, seconds: int = 0, tab: str = "",
15585
15696
  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).
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 the gu Chrome extension to connect…"}))
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": "⚠ The gu Chrome extension is not connected — /browser shows how to install it"})
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
- const pid = Number((await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim());
858
- if (Number.isFinite(pid) && pid > 0) {
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 = Number((await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim());
887
- if (!Number.isFinite(pid) || pid <= 0) {
888
- console.log("\n No local API pid on record.\n");
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 = Number((await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim());
950
+ const { pid } = await ce.apiPidToStop();
950
951
  const before = await ce.probeLocalApi();
951
- if (Number.isFinite(pid) && pid > 0) {
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 there is no pid on record,` +
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 = Number(
1296
- (await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim()
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
- try {
9570
- folder = installExtensionFiles();
9571
- } catch (e) {
9572
- console.log(GUTTER + yellow(`could not copy the extension to ${folder}: ${e.message}`));
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
- const st = await bridgeStatus().catch(() => ({ running: false, connected: false }));
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 [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.)"));
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;