@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.
- package/README.md +1 -0
- package/browser-bridge.mjs +274 -0
- package/browser-command.mjs +116 -0
- package/browser-extension/README.md +111 -0
- package/browser-extension/background.js +483 -0
- package/browser-extension/icons/icon128.png +0 -0
- package/browser-extension/icons/icon16.png +0 -0
- package/browser-extension/icons/icon48.png +0 -0
- package/browser-extension/manifest.json +35 -0
- package/browser-extension/popup.html +22 -0
- package/browser-extension/popup.js +22 -0
- package/gonext_agent_chat.py +339 -19
- package/gu-cli.mjs +3 -0
- package/gu-repl.mjs +66 -2
- package/gu_browser.py +263 -0
- package/gu_test_digest.py +352 -0
- package/package.json +10 -1
- package/shell-details.mjs +58 -4
package/gonext_agent_chat.py
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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 `
|
|
9055
|
-
#
|
|
9056
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
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{
|
|
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).
|