@tiens.nguyen/gu-cli 1.0.832 → 1.0.834

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8024,7 +8024,9 @@ def _run_command_prompt_line(shell, windows=None):
8024
8024
  "interactive input() CLI fails with EOFError. A server or GUI app is left RUNNING — "
8025
8025
  "stop_server closes it. " + _BG_LINE)
8026
8026
  return (
8027
- " run_command IS A SHELL (bash): pipes, redirects, `;`, `||`, `$( )`, globs and heredocs "
8027
+ " run_command IS A SHELL (bash), and it KEEPS its folder and exported variables from one "
8028
+ "call to the next in this turn (`cd sub`, then `npm test` runs in sub; an explicit workdir "
8029
+ "wins). Pipes, redirects, `;`, `||`, `$( )`, globs and heredocs "
8028
8030
  "work, so put a whole sequence in ONE call — dependent steps with &&, independent ones with "
8029
8031
  "`;` (e.g. `cd app && npm test 2>&1 | tail -40`); a multi-line command goes in a "
8030
8032
  "triple-quoted string.\n"
@@ -8037,12 +8039,43 @@ def _run_command_prompt_line(shell, windows=None):
8037
8039
  " GITHUB: use `gh`. Multi-line text goes as a heredoc on stdin — "
8038
8040
  "`gh issue comment 12 --body-file - <<'EOF'` … `EOF` (same for gh issue create / gh pr "
8039
8041
  "create) — never write a body file first, and do not run `gh auth status` first.\n"
8042
+ # #362: how Claude Code checks work — scripts as heredocs, the one function not the app,
8043
+ # cheapest check first, and never a pipe between a test run and the decision it feeds.
8044
+ " CHECKS: a Python/Node script goes as a heredoc — `python3 - <<'EOF'` … `EOF` (node: "
8045
+ "`node --input-type=module - <<'EOF'`) — never `python3 -c` built from joined strings. To "
8046
+ "check one function, load its module and call it with real input instead of running the "
8047
+ "app. Order: syntax check → the ONE test → the suite once. Test output arrives with a TEST "
8048
+ "SUMMARY of each failure first, so never pipe it into tail/grep (a pipe hides the exit code).\n"
8040
8049
  " Read and search FILES with read_file_lines / grep_repo, not cat/grep/sed. `sudo`/`su` "
8041
8050
  "are blocked; piping into sh/python and writing or deleting outside the project ask the "
8042
8051
  "user first; an interactive input() CLI fails with EOFError. A server or GUI app is left "
8043
8052
  "RUNNING — stop_server closes it. " + _BG_LINE)
8044
8053
 
8045
8054
 
8055
+ # THE BROWSER (task #363, GitHub #144): a real Chrome the agent drives through the gu extension.
8056
+ # Offered only where commands may run (the terminal's own folders, never the web app's scratch one)
8057
+ # and node can start the bridge. GU_BROWSER=0 turns it off.
8058
+ def _browser_on() -> bool:
8059
+ if os.environ.get("GU_BROWSER", "1").strip() == "0":
8060
+ return False
8061
+ if not any(w.get("allowRun") for w in _WS_ROOTS):
8062
+ return False
8063
+ import shutil as _sh
8064
+ return bool(os.environ.get("GU_NODE") or _sh.which("node"))
8065
+
8066
+
8067
+ _BROWSER_LINE = (
8068
+ " UI CHECKS — THE BROWSER: to see, test or operate a web page (does the page render, does "
8069
+ "the button work, does the form submit, what does the user see), use browser(...): a REAL "
8070
+ "Chrome with a real mouse and keyboard. Loop: browser(\"open\", url=…) → browser(\"read\") "
8071
+ "(text + numbered elements) or browser(\"screenshot\") → browser(\"click\", ref=n) / "
8072
+ "browser(\"type\", ref=n, text=…) → read again to check the result; browser(\"console\") shows "
8073
+ "page errors. A local app needs its server running first (run_command background=True). "
8074
+ "Never report a UI as working without looking at it. If browser answers BROWSER NOT "
8075
+ "AVAILABLE, stop and ask the user to install the gu Chrome extension (steps are in the "
8076
+ "answer).\n")
8077
+
8078
+
8046
8079
  # Background shells (task #360). Never `&` (refused) — background=True is the way to say it.
8047
8080
  _BG_LINE = ("A LONG job (build, deploy, big test run) → run_command(…, background=True), then "
8048
8081
  "shell_output(<id>, wait_seconds=…) to wait for it; a command still running at its "
@@ -8156,9 +8189,28 @@ def _test_commands_line(root: str) -> str:
8156
8189
  cmds = _project_test_commands(root) if root else []
8157
8190
  if not cmds:
8158
8191
  return ""
8159
- return (" TEST COMMANDS, from this project's own manifests — use these, never guess a runner "
8192
+ line = (" TEST COMMANDS, from this project's own manifests — use these, never guess a runner "
8160
8193
  "(a missing pytest fails silently behind a pipe): "
8161
8194
  + "; ".join(f"{d} → {c}" for d, c in cmds) + "\n")
8195
+ try:
8196
+ import gu_test_digest as _td
8197
+ forms = _td.one_test_forms(c for _, c in cmds)
8198
+ except Exception: # noqa: BLE001
8199
+ forms = []
8200
+ if forms:
8201
+ # #362: check a fix with ONE test (seconds), run the suite once at the end.
8202
+ line += (" ONE TEST while fixing (the suite once at the end): " + " · ".join(forms) + "\n")
8203
+ return line
8204
+
8205
+
8206
+ def _test_digest(output: str, cwd: str = "") -> str:
8207
+ """gu_test_digest.digest, or "" if the module is missing — a summary must never fail a command."""
8208
+ try:
8209
+ import gu_test_digest as _td
8210
+ return _td.digest(output or "", cwd or "")
8211
+ except Exception as e: # noqa: BLE001
8212
+ _log(f"test digest skip: {e}")
8213
+ return ""
8162
8214
 
8163
8215
 
8164
8216
  # bash's words for a line whose quotes do not pair up.
@@ -9051,10 +9103,10 @@ def _ws_cap_servers(max_live: int = 3) -> list:
9051
9103
  # running" link and the Shell details view (shell-details.mjs).
9052
9104
  #
9053
9105
  # THE EXIT CODE OUTLIVES THE AGENT. The agent is one process per turn; a build that ends after the
9054
- # turn has no parent left to wait() on it. So a shell line runs as `"$0" -c "$1"; rc=$?; echo $rc
9055
- # > "$2"; exit $rc` — the command text untouched, its exit code written to `exit_file` AND passed
9056
- # on (without the `exit $rc` every failure read as EXITED 0). The argv-only path (Windows)
9057
- # has no such wrapper and says "exit code unknown" instead of guessing.
9106
+ # turn has no parent left to wait() on it. So a shell line runs as `eval "$1"` under an EXIT trap
9107
+ # that writes its exit code to `exit_file` AND exits with it (without that every failure read as
9108
+ # EXITED 0) — see _run_state_wrapper, which also keeps the folder and exports for the next call.
9109
+ # The argv-only path has no such wrapper and says "exit code unknown" instead of guessing.
9058
9110
  _SHELLS_KEEP = 20 # newest records kept, running or not
9059
9111
  _SHELLS_KEEP_DONE_S = 86400 # a finished shell is listed for a day
9060
9112
 
@@ -9127,7 +9179,8 @@ def _shell_state(s: dict, alive=None, now=None) -> dict:
9127
9179
  state = "ended"
9128
9180
  else:
9129
9181
  state = "exited" if code == 0 else "failed"
9130
- return {"state": state, "code": code, "runtime": max(0.0, ended - started), "ended": ended}
9182
+ return {"state": state, "code": code, "runtime": max(0.0, ended - started), "ended": ended,
9183
+ "by": s.get("killed_by") or ""}
9131
9184
 
9132
9185
 
9133
9186
  def _fmt_runtime(seconds: float) -> str:
@@ -9143,7 +9196,11 @@ def _shell_status_text(st: dict) -> str:
9143
9196
  return "running"
9144
9197
  if st["state"] in ("exited", "failed"):
9145
9198
  return f"exited {st['code']}"
9146
- return "killed" if st["state"] == "killed" else "ended (exit code unknown)"
9199
+ # WHO STOPPED IT (#362/#143). A bare "killed" read as a crash — "shell got status killed why?"
9200
+ # — when the agent had stopped its own search on purpose. Every stop says who did it.
9201
+ if st["state"] == "killed":
9202
+ return f"killed — stopped by {st['by']}" if st.get("by") else "killed"
9203
+ return "ended (exit code unknown)"
9147
9204
 
9148
9205
 
9149
9206
  def _shell_find(shells: list, key: str):
@@ -9270,7 +9327,7 @@ def _file_tail(path: str, max_chars: int = 4000) -> str:
9270
9327
  return ""
9271
9328
 
9272
9329
 
9273
- def _shell_kill(s: dict) -> None:
9330
+ def _shell_kill(s: dict, by: str = "the agent (stop_server)") -> None:
9274
9331
  """Stop a shell's whole process group and mark it killed (unless it already finished)."""
9275
9332
  import time as _t
9276
9333
  if _shell_state(s)["state"] != "running":
@@ -9283,9 +9340,52 @@ def _shell_kill(s: dict) -> None:
9283
9340
  except (OSError, ValueError):
9284
9341
  pass
9285
9342
  s["killed"] = True
9343
+ s["killed_by"] = by
9286
9344
  s["ended"] = _t.time()
9287
9345
 
9288
9346
 
9347
+ # A SHELL THAT FINISHES IS NEWS (#362), the way Claude Code tells its model "background command X
9348
+ # completed". During a turn the note rides on the next observation (append-only — no cache
9349
+ # break); after the turn the terminal starts a short recap turn (gu-repl.mjs, wakeForShells).
9350
+ # `announced` stops both from telling twice; `seen` means the agent already read the final state.
9351
+ def _shells_in_scope(shells: list, roots=None) -> list:
9352
+ """The shells started inside these folders (default: the registered workspaces) — shells.json
9353
+ is one file for every project, and another project's build is not this turn's news."""
9354
+ paths = [os.path.realpath(r["path"]) for r in (_WS_ROOTS if roots is None else roots)
9355
+ if isinstance(r, dict) and r.get("path")]
9356
+ out = []
9357
+ for s in shells:
9358
+ wd = os.path.realpath(str(s.get("workdir") or ""))
9359
+ if any(wd == p or wd.startswith(p.rstrip(os.sep) + os.sep) for p in paths):
9360
+ out.append(s)
9361
+ return out
9362
+
9363
+
9364
+ def _shells_finished_note(shells: list, now=None) -> str:
9365
+ """Notes for shells that finished and were neither read nor announced; marks them announced
9366
+ in `shells` (the caller saves). "" when there is nothing new."""
9367
+ notes = []
9368
+ for s in shells:
9369
+ if s.get("seen") or s.get("announced"):
9370
+ continue
9371
+ st = _shell_state(s, now=now)
9372
+ if st["state"] == "running":
9373
+ continue
9374
+ s["announced"] = True
9375
+ note = (f"[background shell {s.get('id')} finished: {_shell_status_text(st)} after "
9376
+ f"{_fmt_runtime(st['runtime'])} — '{_clip(str(s.get('command')), 120)}'. "
9377
+ f"shell_output('{s.get('id')}') has its output.]")
9378
+ try:
9379
+ with open(s.get("log") or "", encoding="utf-8", errors="replace") as fh:
9380
+ dg = _test_digest(fh.read(), str(s.get("workdir") or ""))
9381
+ if dg:
9382
+ note += "\n" + dg
9383
+ except OSError:
9384
+ pass
9385
+ notes.append(note)
9386
+ return "\n\n".join(notes)
9387
+
9388
+
9289
9389
  def _shells_context_line(shells: list, now=None) -> str:
9290
9390
  """The turn's line about shells: what is still running, and what finished since the model
9291
9391
  last looked — a build that ended between turns is news the model has not seen."""
@@ -9301,6 +9401,77 @@ def _shells_context_line(shells: list, now=None) -> str:
9301
9401
  + ". Read one with shell_output(<id>); stop one with stop_server(<id>).\n")
9302
9402
 
9303
9403
 
9404
+ # THE SHELL KEEPS ITS FOLDER AND EXPORTS BETWEEN CALLS (#362), the way Claude Code's Bash tool does.
9405
+ # Every run_command was a fresh bash, so `cd tools/x` or `source .venv/bin/activate` lasted one call
9406
+ # and every line started `cd … &&`. Not a long-lived pty (a hung one would wedge the turn): each
9407
+ # line runs in the outer shell via `eval`, and an EXIT trap — which also fires on `exit 3` — saves
9408
+ # `export -p` and `pwd` to this turn's state file; the next call sources it and starts in that
9409
+ # folder. One turn only: the file is removed when the agent exits. An explicit workdir wins.
9410
+ _RUN_STATE = {"path": ""}
9411
+ # Builtins that change the shell itself. As argv they run nothing (`export` is no program; macOS's
9412
+ # /usr/bin/cd exits 0 having changed nothing) — with a shell, a line using one goes to bash.
9413
+ _SHELL_STATE_BUILTINS = {"cd", "export", "source", ".", "set", "unset", "alias", "unalias", "pushd",
9414
+ "popd", "umask", "shopt", "declare", "typeset", "readonly", "trap", "ulimit",
9415
+ "hash", "eval"}
9416
+ _RUN_STATE_SKIP = r"^declare -x (PWD|OLDPWD|SHLVL|_)="
9417
+
9418
+
9419
+ def _run_state_path() -> str:
9420
+ import atexit
9421
+ import time as _t
9422
+ if not _RUN_STATE["path"]:
9423
+ d = os.path.join(os.path.expanduser("~"), ".gonext", "run-state")
9424
+ os.makedirs(d, exist_ok=True)
9425
+ # A background shell that ends after its turn rewrites its file; sweep day-old ones.
9426
+ try:
9427
+ for n in os.listdir(d):
9428
+ fp = os.path.join(d, n)
9429
+ if _t.time() - os.path.getmtime(fp) > 86400:
9430
+ os.remove(fp)
9431
+ except OSError:
9432
+ pass
9433
+ _RUN_STATE["path"] = os.path.join(d, f"{os.getpid()}.sh")
9434
+
9435
+ def _clean(base=_RUN_STATE["path"]):
9436
+ for ext in ("", ".cwd", ".tmp"):
9437
+ try:
9438
+ os.remove(base + ext)
9439
+ except OSError:
9440
+ pass
9441
+ atexit.register(_clean)
9442
+ return _RUN_STATE["path"]
9443
+
9444
+
9445
+ def _run_state_cwd() -> str:
9446
+ """The folder the last shell line ended in, this turn — or ""."""
9447
+ p = _RUN_STATE["path"]
9448
+ if not p:
9449
+ return ""
9450
+ try:
9451
+ with open(p + ".cwd", encoding="utf-8") as fh:
9452
+ return fh.read().strip()
9453
+ except OSError:
9454
+ return ""
9455
+
9456
+
9457
+ def _run_state_set_cwd(cwd: str) -> None:
9458
+ """Record the folder an && chain ended in — the chain runs its `cd` itself, not in bash."""
9459
+ try:
9460
+ with open(_run_state_path() + ".cwd", "w", encoding="utf-8") as fh:
9461
+ fh.write(cwd)
9462
+ except OSError:
9463
+ pass
9464
+
9465
+
9466
+ def _run_state_wrapper() -> str:
9467
+ """The outer script: $1 the line, $2 the exit file, $3 the state file."""
9468
+ return ('[ -f "$3" ] && . "$3" >/dev/null 2>&1; '
9469
+ "trap '__gu_rc=$?; echo $__gu_rc > \"$2\"; "
9470
+ f"export -p | grep -vE \"{_RUN_STATE_SKIP}\" > \"$3.tmp\" 2>/dev/null && mv -f \"$3.tmp\" \"$3\"; "
9471
+ "pwd > \"$3.cwd\"; exit $__gu_rc' EXIT; "
9472
+ 'eval "$1"')
9473
+
9474
+
9304
9475
  def _ws_scrubbed_env() -> dict:
9305
9476
  """Minimal child env for run_command. The worker process env holds secrets
9306
9477
  (GONEXT_WORKER_KEY etc. via worker.env) — a repo's test script must NEVER see
@@ -10626,7 +10797,7 @@ def run_agent_chat(cfg):
10626
10797
  except Exception as _e: # noqa: BLE001
10627
10798
  _log(f"server context skip: {_e}")
10628
10799
  try:
10629
- _ws_servers_line += _shells_context_line(_shells_load())
10800
+ _ws_servers_line += _shells_context_line(_shells_in_scope(_shells_load()))
10630
10801
  except Exception as _e: # noqa: BLE001
10631
10802
  _log(f"shell context skip: {_e}")
10632
10803
  _ws_tests_line = ""
@@ -10693,7 +10864,8 @@ def run_agent_chat(cfg):
10693
10864
  "and reads do not pile up here and are not re-sent on every later step. Then read only "
10694
10865
  "the exact ranges you will edit. Use grep_repo/read_file_lines directly when you already "
10695
10866
  "know the file or the exact string.\n"
10696
- + _run_command_prompt_line(bool(_run_shell_bin())) +
10867
+ + _run_command_prompt_line(bool(_run_shell_bin()))
10868
+ + (_BROWSER_LINE if _browser_on() else "") +
10697
10869
  " DEPLOY: prefer deploy_web(local_dir, host, user, remote_path, domain) over "
10698
10870
  "hand-written scp/ssh/rsync (it rsyncs over your SSH key and finishes nginx when it "
10699
10871
  "can, else writes deploy.sh and returns the exact commands). KEY auth ONLY "
@@ -11772,9 +11944,38 @@ def run_agent_chat(cfg):
11772
11944
  saved += _trim_tool_call_args(_st, 120)
11773
11945
  return saved
11774
11946
 
11947
+ _browser_shot = {"img": None} # the browser tool's newest screenshot, for this step (#363)
11948
+
11775
11949
  def step_callback(step_log):
11776
11950
  step_num = getattr(step_log, "step_number", "?")
11777
11951
 
11952
+ # A browser screenshot taken in this step goes to the model as an image with the step's
11953
+ # observation (#363). Only the newest two stay in memory: every image is re-sent on every
11954
+ # later step, and an old view of a page that has since changed only misleads.
11955
+ try:
11956
+ if _browser_shot.get("img") is not None:
11957
+ step_log.observations_images = [_browser_shot["img"]]
11958
+ _browser_shot["img"] = None
11959
+ _with = [s for s in (getattr(getattr(_agent_ref.get("a"), "memory", None), "steps", None) or [])
11960
+ if getattr(s, "observations_images", None) and s is not step_log]
11961
+ for _old in _with[:-1]:
11962
+ _old.observations_images = None
11963
+ except Exception as e: # noqa: BLE001
11964
+ _log(f"browser screenshot attach skip: {e}")
11965
+
11966
+ # A background shell that finished during this step is told to the model now (#362).
11967
+ try:
11968
+ _obs_now = getattr(step_log, "observations", None)
11969
+ if isinstance(_obs_now, str):
11970
+ _shs = _shells_load()
11971
+ _fin = _shells_finished_note(_shells_in_scope(_shs))
11972
+ if _fin:
11973
+ step_log.observations = _obs_now + "\n\n" + _fin
11974
+ _shells_save(_shs)
11975
+ _log(f"step {step_num}: told the model a background shell finished")
11976
+ except Exception as e: # noqa: BLE001
11977
+ _log(f"shell-finished note skip: {e}")
11978
+
11778
11979
  # Task #74: an executor-timeout error means whatever the step's tool call
11779
11980
  # achieved was NEVER reported to the model (a create_pdf may have finished —
11780
11981
  # or may still be finishing — in the background). Flag it so a repeat
@@ -14684,6 +14885,14 @@ def run_agent_chat(cfg):
14684
14885
  if os.path.isdir(os.path.join(_active, _stripped)):
14685
14886
  _wd_in = _stripped
14686
14887
  wd = _rag_read_allowed(_wd_in or _default_ws_root())
14888
+ _wd_before = wd
14889
+ if not _wd_in and _run_shell_bin():
14890
+ _kept = _run_state_cwd()
14891
+ if _kept and os.path.isdir(_kept):
14892
+ try:
14893
+ wd = _rag_read_allowed(_kept)
14894
+ except Exception: # noqa: BLE001 — left the allowed folders: start over
14895
+ wd = _wd_before
14687
14896
  w = _ws_for_path(wd)
14688
14897
  if w is None or not w.get("allowRun"):
14689
14898
  # Contextual nudge (task #102): the #1 reason the model shells out here is
@@ -14707,7 +14916,12 @@ def run_agent_chat(cfg):
14707
14916
  + (_HEREDOC_HINT if "quote" in str(_scan["error"]) else ""))
14708
14917
  _last_obs["text"] = msg
14709
14918
  return msg
14710
- _use_shell = bool(_scan) and _shell_needed(_scan)
14919
+ # Once this turn has shell state (a cd, an activated venv), every line goes through
14920
+ # the shell so it starts from that state — not only lines with shell syntax.
14921
+ _words = [c["argv"][0] for c in _shell_commands(_scan["tokens"]) if c["argv"]] if _scan else []
14922
+ _builtin = sorted({w for w in _words if w in _SHELL_STATE_BUILTINS})
14923
+ _use_shell = bool(_scan) and (_shell_needed(_scan) or bool(_builtin) or bool(
14924
+ _RUN_STATE["path"] and os.path.exists(_RUN_STATE["path"])))
14711
14925
  _bg_err = _shell_background_error(_scan) if _use_shell else None
14712
14926
  if _bg_err:
14713
14927
  _last_obs["text"] = _bg_err
@@ -14720,7 +14934,10 @@ def run_agent_chat(cfg):
14720
14934
  # AN `&&` CHAIN IS ONE STEP (task #274): each part runs through THIS function —
14721
14935
  # allowlist, risk gate, approval, server detection, all of it — in order, and
14722
14936
  # the first failure stops the rest. `cd DIR` sets the folder for what follows.
14723
- _chain = _and_chain_parts(command)
14937
+ # A chain runs each part as a program; `export`/`source` in it would run nothing,
14938
+ # so with a shell such a line goes to bash whole. (`cd` the chain does itself.)
14939
+ _chain = None if (_use_shell and [b for b in _builtin if b != "cd"]) \
14940
+ else _and_chain_parts(command)
14724
14941
  if _chain:
14725
14942
  _chain_start = _t.time()
14726
14943
  _cur_wd = wd
@@ -14764,6 +14981,10 @@ def run_agent_chat(cfg):
14764
14981
  + (": " + " && ".join(_rest) if _rest else "")
14765
14982
  if _stopped else f"CHAIN DONE — all {len(_chain)} parts ran")
14766
14983
  result = _summary + "\n" + "\n".join(_outs)
14984
+ if _shell_bin and _cur_wd != wd:
14985
+ _run_state_set_cwd(_cur_wd) # the next call starts where the chain ended
14986
+ result += (f"\n(the shell is now in {_cur_wd} — the next run_command "
14987
+ "without a workdir starts there)")
14767
14988
  _last_obs["text"] = result
14768
14989
  return result
14769
14990
  # REFUSE SHELL SYNTAX BEFORE SPAWNING (task #204). Without this, `cd X && Y`
@@ -14884,12 +15105,13 @@ def run_agent_chat(cfg):
14884
15105
  if _use_shell:
14885
15106
  _cmd_text, _attrib_note = _with_attribution_text(
14886
15107
  command, _scan, _attribution_settings(wd))
14887
- # The exit code is written to a file so it outlives this turn's process —
14888
- # the line itself runs untouched as $1 (see BACKGROUND SHELLS).
15108
+ # The exit code is written to a file so it outlives this turn's process, and
15109
+ # the folder + exports are kept for the next call — the line itself runs
15110
+ # untouched as $1 (see BACKGROUND SHELLS, _run_state_wrapper).
14889
15111
  # Git Bash reads C:/x, not C:\x, inside the line it runs.
14890
15112
  _fs = (lambda x: x.replace("\\", "/")) if _IS_WINDOWS else (lambda x: x)
14891
- argv = [_shell_bin, "-c", '"$0" -c "$1"; rc=$?; echo $rc > "$2"; exit $rc',
14892
- _fs(_shell_bin), _cmd_text, _fs(exit_path)]
15113
+ argv = [_shell_bin, "-c", _run_state_wrapper(),
15114
+ _fs(_shell_bin), _cmd_text, _fs(exit_path), _fs(_run_state_path())]
14893
15115
  else:
14894
15116
  argv, _attrib_note = _with_attribution(argv, _attribution_settings(wd))
14895
15117
  if _attrib_note:
@@ -14939,7 +15161,11 @@ def run_agent_chat(cfg):
14939
15161
  while True:
14940
15162
  rc = proc.poll()
14941
15163
  if rc is not None:
14942
- out = _ws_distill_output(_read_log())
15164
+ _raw = _read_log()
15165
+ out = _ws_distill_output(_raw)
15166
+ # The result FIRST (#362): counts and each failure with file:line, read
15167
+ # from the WHOLE output — the observation below is capped, this is not.
15168
+ _digest = _test_digest(_raw, wd)
14943
15169
  # Task #90 Phase 1 — truthful perception: never claim success the
14944
15170
  # tool didn't observe. exit 0 is a FACT about the process, not
14945
15171
  # proof of the GOAL (e.g. `npm start` exits 0 when its port is
@@ -14962,7 +15188,7 @@ def run_agent_chat(cfg):
14962
15188
  # what `cd X && Y` used to do — and the model read the silence as a
14963
15189
  # clean pass. An absence cannot be reasoned about; a stated fact can.
14964
15190
  result = (
14965
- f"{status}\n{out}" if out.strip()
15191
+ f"{status}\n" + (f"{_digest}\n\n" if _digest else "") + out if out.strip()
14966
15192
  else f"{status}\nNO OUTPUT: the command printed nothing on stdout or "
14967
15193
  "stderr. If you expected output, it probably did not do what "
14968
15194
  "you intended — check the command before repeating it."
@@ -14982,6 +15208,11 @@ def run_agent_chat(cfg):
14982
15208
  f"Command failed behind a pipe → {_trim_words(_hidden)}"})
14983
15209
  if rc != 0 and _use_shell and _SHELL_QUOTE_ERR_RE.search(out or ""):
14984
15210
  result += _HEREDOC_HINT
15211
+ # Say where the shell now is, so a next `cd sub` is not done twice (#362).
15212
+ _now = _run_state_cwd() if _use_shell else ""
15213
+ if _now and os.path.realpath(_now) != os.path.realpath(wd):
15214
+ result += (f"\n(the shell is now in {_now} — the next run_command "
15215
+ "without a workdir starts there)")
14985
15216
  if rc != 0 and _WS_PERM_ERR_RE.search(out or ""):
14986
15217
  _f = _ws_access_facts()
14987
15218
  result = f"{result}\n\n{_ws_access_report(_f, _ws_paths_in(command))}"
@@ -15197,6 +15428,14 @@ def run_agent_chat(cfg):
15197
15428
  if st["state"] == "exited":
15198
15429
  head += ("\nexit 0 alone does NOT prove the intended effect happened — check "
15199
15430
  "the output and VERIFY.")
15431
+ if st["state"] != "running":
15432
+ try:
15433
+ with open(sh.get("log") or "", encoding="utf-8", errors="replace") as fh:
15434
+ _dg = _test_digest(fh.read(), str(sh.get("workdir") or ""))
15435
+ except OSError:
15436
+ _dg = ""
15437
+ if _dg:
15438
+ head += "\n" + _dg
15200
15439
  tail_note = ""
15201
15440
  if not new.strip() and int(sh.get("read_offset") or 0) == 0:
15202
15441
  lf = _shell_log_file(str(sh.get("command") or ""), str(sh.get("workdir") or ""))
@@ -15339,6 +15578,85 @@ def run_agent_chat(cfg):
15339
15578
  _last_obs["text"] = msg
15340
15579
  return msg
15341
15580
 
15581
+ @tool
15582
+ def browser(action: str, url: str = "", ref: int = 0, x: int = -1, y: int = -1,
15583
+ text: str = "", clear: bool = False, submit: bool = False,
15584
+ direction: str = "", amount: int = 0, seconds: int = 0, tab: int = 0,
15585
+ new_tab: bool = False) -> str:
15586
+ """Use a REAL Chrome browser (through the gu Chrome extension) like a person does — open a page, see it, click with the mouse, type with the keyboard — to test a UI flow, check a page renders, fill a form or click through an app. Loop: open → read (or screenshot) → click / type → read again to check what happened. Only gu's own tabs are used. If the answer starts BROWSER NOT AVAILABLE, stop and ask the user to install the extension (the answer has the steps).
15587
+
15588
+ Args:
15589
+ action: one of: open (url) · read (page text + visible interactive elements numbered [n] with x,y) · screenshot (an image of the page; pixels = click x,y) · click / double_click / right_click / hover (ref, or x and y) · type (text into the focused field; with ref it clicks that field first) · key (text: "Enter", "Tab", "Escape", "ctrl+a", "shift+Tab"… several separated by spaces) · scroll (direction, amount) · back · reload · wait (seconds) · js (text = a JavaScript expression run in the page; returns its value) · console (the page's console messages since the last call) · tabs · switch (tab) · close
15590
+ url: for open — a full http(s):// or file:// url (localhost:3000 is taken as http://localhost:3000)
15591
+ ref: an element's [n] from the last read, for click/hover/type
15592
+ x: page x coordinate for click/hover (from read's @x,y or the screenshot), when no ref
15593
+ y: page y coordinate, with x
15594
+ text: what to type (type), the key(s) to press (key), or the JavaScript expression (js)
15595
+ clear: type only — replace the field's current value instead of adding to it
15596
+ submit: type only — press Enter after typing
15597
+ direction: scroll only — down (default), up, left or right
15598
+ amount: scroll only — pixels (default most of a screen)
15599
+ seconds: wait only — 1 to 30
15600
+ tab: switch only — a tab id from tabs
15601
+ new_tab: open only — open in a new tab instead of the current gu tab
15602
+ """
15603
+ import gu_browser as _gb
15604
+ a = (action or "").strip().lower().replace(" ", "_").replace("-", "_")
15605
+ try:
15606
+ cmd = {"action": a}
15607
+ if url:
15608
+ u = url.strip()
15609
+ if re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?(/|$)", u):
15610
+ u = "http://" + u
15611
+ elif u.startswith("/") and os.path.exists(u):
15612
+ u = "file://" + u
15613
+ cmd["url"] = u
15614
+ if ref:
15615
+ cmd["ref"] = int(ref)
15616
+ if x is not None and y is not None and int(x) >= 0 and int(y) >= 0:
15617
+ cmd["x"], cmd["y"] = float(x), float(y)
15618
+ if text:
15619
+ cmd["text"] = str(text)
15620
+ for k, v in (("clear", clear), ("submit", submit), ("new_tab", new_tab)):
15621
+ if v:
15622
+ cmd[k] = True
15623
+ if direction:
15624
+ cmd["direction"] = direction
15625
+ if amount:
15626
+ cmd["amount"] = int(amount)
15627
+ if seconds:
15628
+ cmd["seconds"] = int(seconds)
15629
+ if tab:
15630
+ cmd["tab"] = int(tab)
15631
+ shot_ok = a == "screenshot" and (
15632
+ _is_anthropic_url(coding_base_url) or _coder_takes_images(coding_base_url, coding_model_id))
15633
+ if a == "screenshot" and not shot_ok:
15634
+ cmd["action"] = "read" # this model is not sent images: it gets the page as text
15635
+ what = cmd.get("url") or (f"[{ref}]" if ref else f"{int(x)},{int(y)}" if "x" in cmd else
15636
+ _clip(str(text), 50) if text else "")
15637
+ _emit({"type": "step", "text": f"Browser {a}" + (f" → {what}" if what else "")})
15638
+ ok, res, not_connected = _gb.command(
15639
+ cmd, log=_log,
15640
+ on_wait=lambda: _emit({"type": "step", "text": "Waiting for the gu Chrome extension to connect…"}))
15641
+ if not_connected:
15642
+ _emit({"type": "step", "text": "⚠ The gu Chrome extension is not connected — /browser shows how to install it"})
15643
+ msg = _gb.install_text(str(res))
15644
+ elif not ok:
15645
+ msg = f"Error: browser {a} failed: {res}"
15646
+ elif a == "screenshot" and shot_ok:
15647
+ im, note = _gb.decode_screenshot(res, _IMAGE_MAX_EDGE)
15648
+ _browser_shot["img"] = im
15649
+ msg = _gb.format_result("screenshot", res) + "\n" + note + " The image is attached to this observation."
15650
+ elif a == "screenshot":
15651
+ msg = ("(This coding model is not sent images, so here is the page as text.)\n"
15652
+ + _gb.format_result("read", res))
15653
+ else:
15654
+ msg = _gb.format_result(a, res)
15655
+ except Exception as ex: # noqa: BLE001
15656
+ msg = f"Error: browser {a} failed: {type(ex).__name__}: {ex}"
15657
+ _last_obs["text"] = msg
15658
+ return msg
15659
+
15342
15660
  agent_tools = [http_request, web_search, fetch_url, calculate,
15343
15661
  get_current_datetime, create_pdf, create_download]
15344
15662
  # Only register the PDF reader / email / RAG tools when available — the model
@@ -15359,6 +15677,8 @@ def run_agent_chat(cfg):
15359
15677
  agent_tools += [symbol_context, grep_repo, read_file_lines, edit_lines, edit_file,
15360
15678
  create_file, create_folder, run_command, stop_server,
15361
15679
  shell_output, deploy_web]
15680
+ if _browser_on():
15681
+ agent_tools.append(browser)
15362
15682
  # Workspace RAG (task #133): semantic search over the PROJECT itself, not just a
15363
15683
  # ZIP-at-URL. Needs both a workspace to index and a working embedder, so it's
15364
15684
  # registered only when RAG is actually available — the model never sees a tool it
package/gu-cli.mjs CHANGED
@@ -74,6 +74,9 @@ function skillsAndMcpForJob(cwd) {
74
74
  }
75
75
 
76
76
  const WORKER_DIR = dirname(fileURLToPath(import.meta.url));
77
+ // The agent's browser tool starts browser-bridge.mjs with THIS node (#363): a launchd/systemd worker
78
+ // often has no node on PATH.
79
+ process.env.GU_NODE ||= process.execPath;
77
80
 
78
81
  // Running worker version, read from the package's own package.json (single source of
79
82
  // truth) so the startup banner shows exactly which build a box is running (task #105).