@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.
@@ -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 _ws_pgroup_pids(int(s.get("pgid", 0)))]
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
- pgid = int(s.get("pgid", 0))
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(_ws_pgroup_pids(int(s.get("pgid") or 0)))
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
- return "killed" if st["state"] == "killed" else "ended (exit code unknown)"
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
- _ws_kill_tree(int(s.get("pgid") or 0))
9308
- _t.sleep(1.0)
9309
- if _ws_pgroup_pids(int(s.get("pgid") or 0)):
9310
- _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)
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
- _ws_kill_tree(pgid)
15261
- _t.sleep(1.5)
15262
- if _ws_pgroup_pids(pgid):
15263
- _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)
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
- const pid = Number((await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim());
855
- 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) {
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 = Number((await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim());
884
- if (!Number.isFinite(pid) || pid <= 0) {
885
- 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`);
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 = Number((await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim());
950
+ const { pid } = await ce.apiPidToStop();
947
951
  const before = await ce.probeLocalApi();
948
- if (Number.isFinite(pid) && pid > 0) {
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 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,` +
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 = Number(
1293
- (await readFile(ce.API_PID_FILE, "utf8").catch(() => "")).trim()
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})`);