@tiens.nguyen/gu-cli 1.0.824 → 1.0.826
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/gonext_agent_chat.py +403 -35
- package/gu-repl.mjs +97 -1
- package/package.json +2 -1
- package/shell-details.mjs +224 -0
package/gonext_agent_chat.py
CHANGED
|
@@ -2198,11 +2198,41 @@ def _machine_context(platform_name: str = "", arch: str = "", cwd: str = "") ->
|
|
|
2198
2198
|
)
|
|
2199
2199
|
|
|
2200
2200
|
|
|
2201
|
+
# A reply that ENDS by announcing its next action is a plan, not an answer (#139, live: "Two
|
|
2202
|
+
# logical changes to commit: 1. … 2. … Let me check what changed in api/latest.json …" was
|
|
2203
|
+
# wrapped as the answer to "commit" because its numbered list looked finished). Only the LAST
|
|
2204
|
+
# paragraph counts, "let me know" / "I'll be" are not actions, and a step handed to the user
|
|
2205
|
+
# ("I'll push when you say so") is a finished answer.
|
|
2206
|
+
_NEXT_STEP_RE = re.compile(
|
|
2207
|
+
r"\b(?:let me|let's|let us|i'll|i will|i'm going to|i am going to|i need to|"
|
|
2208
|
+
r"next,? i(?:'ll| will| need to)?|now i(?:'ll| will)?)\s+(?:also\s+|now\s+|first\s+|quickly\s+)?"
|
|
2209
|
+
r"(?:check|look|see|read|run|inspect|verify|review|examine|find|search|grep|open|"
|
|
2210
|
+
r"stage|commit|create|write|edit|fix|update|add|build|test|install|start|try|"
|
|
2211
|
+
r"fetch|list|compare|construct|draft|prepare|explore|query|confirm|diff|do)\b",
|
|
2212
|
+
re.I,
|
|
2213
|
+
)
|
|
2214
|
+
_HANDED_TO_USER_RE = re.compile(
|
|
2215
|
+
r"\b(?:when|if|once|whenever|after) you\b|\byour (?:call|go-ahead|word|court)\b|"
|
|
2216
|
+
r"\band i(?:'ll| will)\b|\b(?:tell|give|ask|show) me\b|\bsay (?:the word|which|'|\")", re.I)
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
def _announces_next_step(stripped: str) -> bool:
|
|
2220
|
+
"""True if the reply's last paragraph says what the model will do NEXT (and it did not
|
|
2221
|
+
do it). Pure, so the live replies can be replayed."""
|
|
2222
|
+
paras = [p for p in re.split(r"\n\s*\n", stripped or "") if p.strip()]
|
|
2223
|
+
if not paras:
|
|
2224
|
+
return False
|
|
2225
|
+
last = paras[-1]
|
|
2226
|
+
return bool(_NEXT_STEP_RE.search(last)) and not _HANDED_TO_USER_RE.search(last)
|
|
2227
|
+
|
|
2228
|
+
|
|
2201
2229
|
def _looks_like_scratchpad(stripped: str) -> bool:
|
|
2202
2230
|
"""True if `stripped` reads like unresolved internal deliberation rather than a
|
|
2203
|
-
finished, user-facing answer — see module comment above for the
|
|
2231
|
+
finished, user-facing answer — see module comment above for the signals used."""
|
|
2204
2232
|
if _SCRATCHPAD_START_RE.match(stripped):
|
|
2205
2233
|
return True
|
|
2234
|
+
if _announces_next_step(stripped):
|
|
2235
|
+
return True
|
|
2206
2236
|
return len(_DELIBERATION_RE.findall(stripped)) >= 3
|
|
2207
2237
|
|
|
2208
2238
|
|
|
@@ -7396,7 +7426,7 @@ def _shell_syntax_error(command, argv):
|
|
|
7396
7426
|
# same checks an argv command gets (privilege, deny/allow lists, delete outside the project), plus
|
|
7397
7427
|
# the two things only a shell can do: write outside the project with a redirect, and pipe text into
|
|
7398
7428
|
# an interpreter. A command with no shell syntax still runs as argv, exactly as before.
|
|
7399
|
-
# Windows
|
|
7429
|
+
# Windows uses Git Bash when Git for Windows is installed (as Claude Code does), else argv.
|
|
7400
7430
|
_SHELL_OPS = ("&&", "||", ";;", "|&", "<<<", "<<-", "<<", "&>>", "&>", ">>", ">&", "<&", "<>",
|
|
7401
7431
|
">|", ";", "|", "&", "(", ")", ">", "<")
|
|
7402
7432
|
_SHELL_SEPARATORS = {"&&", "||", ";", ";;", "|", "|&", "&", "\n", "(", ")"}
|
|
@@ -7404,7 +7434,7 @@ _SHELL_WRITE_REDIRECTS = {">", ">>", ">|", "&>", "&>>", "<>"}
|
|
|
7404
7434
|
# Runners that execute TEXT as code. Piping into one with no script argument (`curl … | sh`) is the
|
|
7405
7435
|
# shape a prompt injection takes, so it is blocked when nobody can be asked.
|
|
7406
7436
|
_SHELL_INTERPRETERS = {"sh", "bash", "zsh", "dash", "ksh", "fish", "python", "python3", "node",
|
|
7407
|
-
"perl", "ruby", "php", "pwsh", "powershell"}
|
|
7437
|
+
"perl", "ruby", "php", "pwsh", "powershell", "py", "pythonw", "cmd"}
|
|
7408
7438
|
_SHELL_EVAL_RUNNERS = {"eval", "source", "."}
|
|
7409
7439
|
_SHELL_HARMLESS_TARGETS = {"/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty"}
|
|
7410
7440
|
|
|
@@ -7683,7 +7713,8 @@ def _shell_background_error(scan):
|
|
|
7683
7713
|
if any(t[0] == "op" and t[1] == "&" for t in scan["tokens"]):
|
|
7684
7714
|
return ("Error: that command did NOT run. Do not use `&` to background it — run_command "
|
|
7685
7715
|
"already leaves a server or app RUNNING when it starts listening or opens a window, "
|
|
7686
|
-
"and reports its URL. Send the same command
|
|
7716
|
+
"and reports its URL; for a long job pass background=True. Send the same command "
|
|
7717
|
+
"without the `&`.")
|
|
7687
7718
|
return None
|
|
7688
7719
|
|
|
7689
7720
|
|
|
@@ -7698,6 +7729,28 @@ def _shell_priv_reason(scan):
|
|
|
7698
7729
|
return None
|
|
7699
7730
|
|
|
7700
7731
|
|
|
7732
|
+
def _shell_prog_name(prog):
|
|
7733
|
+
"""`C:/Python/python.exe` / `/usr/bin/python3` → "python" / "python3" (Git Bash runs .exe)."""
|
|
7734
|
+
b = re.split(r"[\\/]", prog or "")[-1].lower()
|
|
7735
|
+
return b[:-4] if b.endswith(".exe") else b
|
|
7736
|
+
|
|
7737
|
+
|
|
7738
|
+
def _shell_path_to_native(p, windows=None):
|
|
7739
|
+
"""A path as Git Bash writes it → the Windows path it means, so the outside-the-project checks
|
|
7740
|
+
measure the right place: `/c/Users/x` → `C:\\Users\\x`, `/tmp/x` → the temp folder. On Windows
|
|
7741
|
+
os.path reads `/c/Users/x` as `<current drive>:\\c\\Users\\x`. Elsewhere `p` comes back as is."""
|
|
7742
|
+
if not (os.name == "nt" if windows is None else windows) or not p:
|
|
7743
|
+
return p
|
|
7744
|
+
m = re.match(r"^/([a-zA-Z])(?=/|$)(.*)$", p)
|
|
7745
|
+
if m:
|
|
7746
|
+
return m.group(1).upper() + ":" + (m.group(2) or "/").replace("/", "\\")
|
|
7747
|
+
m = re.match(r"^/tmp(?=/|$)(.*)$", p)
|
|
7748
|
+
if m:
|
|
7749
|
+
import tempfile
|
|
7750
|
+
return tempfile.gettempdir() + m.group(1).replace("/", "\\")
|
|
7751
|
+
return p
|
|
7752
|
+
|
|
7753
|
+
|
|
7701
7754
|
def _ws_write_outside_reason(redirects, wd, roots=None):
|
|
7702
7755
|
"""Why a redirect needs the user's yes: it writes a file OUTSIDE every workspace and the temp
|
|
7703
7756
|
folder. The shell's counterpart of _ws_delete_outside_reason."""
|
|
@@ -7713,7 +7766,7 @@ def _ws_write_outside_reason(redirects, wd, roots=None):
|
|
|
7713
7766
|
temp = [os.path.realpath(p) for p in {tempfile.gettempdir(), "/tmp", "/var/tmp"} if p]
|
|
7714
7767
|
outside = []
|
|
7715
7768
|
for t in targets:
|
|
7716
|
-
p = os.path.expanduser(t)
|
|
7769
|
+
p = os.path.expanduser(_shell_path_to_native(t))
|
|
7717
7770
|
p = os.path.realpath(p if os.path.isabs(p) else os.path.join(wd or os.getcwd(), p))
|
|
7718
7771
|
if not (_ws_is_inside(p, ws) or _ws_is_inside(p, temp)):
|
|
7719
7772
|
outside.append(t)
|
|
@@ -7749,7 +7802,8 @@ def _ws_shell_command_risk(scan, denyset, wd="", allowset=None, roots=None):
|
|
|
7749
7802
|
for c in top:
|
|
7750
7803
|
a = c["argv"]
|
|
7751
7804
|
if a and a[0] == "cd" and len(a) >= 2 and c["sep"] not in ("|", "|&"):
|
|
7752
|
-
cur = os.path.normpath(os.path.join(cur or os.getcwd(),
|
|
7805
|
+
cur = os.path.normpath(os.path.join(cur or os.getcwd(),
|
|
7806
|
+
os.path.expanduser(_shell_path_to_native(a[1]))))
|
|
7753
7807
|
continue
|
|
7754
7808
|
why = (_ws_delete_outside_reason(a, cur, roots)
|
|
7755
7809
|
or _ws_write_outside_reason(c["redirects"], cur, roots))
|
|
@@ -7761,7 +7815,7 @@ def _ws_shell_command_risk(scan, denyset, wd="", allowset=None, roots=None):
|
|
|
7761
7815
|
return (None, why, why)
|
|
7762
7816
|
for c in top:
|
|
7763
7817
|
a = c["argv"]
|
|
7764
|
-
r =
|
|
7818
|
+
r = _shell_prog_name(a[0]) if a else ""
|
|
7765
7819
|
if c["sep"] in ("|", "|&") and r in _SHELL_INTERPRETERS \
|
|
7766
7820
|
and all(x.startswith("-") for x in a[1:]):
|
|
7767
7821
|
msg = f"it pipes text into {r}, which runs that text as code"
|
|
@@ -7808,22 +7862,59 @@ def _with_attribution_text(command, scan, settings):
|
|
|
7808
7862
|
_RUN_SHELL_BIN = None
|
|
7809
7863
|
|
|
7810
7864
|
|
|
7865
|
+
def _git_bash_path(env=None, which=None, isfile=None):
|
|
7866
|
+
"""Git for Windows' bash.exe, or "". PURE given its three lookups (tested off Windows).
|
|
7867
|
+
|
|
7868
|
+
GU_GIT_BASH_PATH first, then the Git that owns `git` on PATH (…\\Git\\cmd|bin|mingw64\\bin →
|
|
7869
|
+
…\\Git\\bin\\bash.exe), then the usual install folders. Never WSL's bash (System32,
|
|
7870
|
+
WindowsApps): that runs in a separate Linux with its own files, not on the user's Windows."""
|
|
7871
|
+
import ntpath
|
|
7872
|
+
import shutil as _sh
|
|
7873
|
+
env = os.environ if env is None else env
|
|
7874
|
+
which = which or _sh.which
|
|
7875
|
+
isfile = isfile or os.path.isfile
|
|
7876
|
+
cands = [env.get("GU_GIT_BASH_PATH") or ""]
|
|
7877
|
+
git = which("git") or ""
|
|
7878
|
+
if git:
|
|
7879
|
+
d = ntpath.dirname(git)
|
|
7880
|
+
for _ in range(3): # cmd\git.exe, bin\git.exe, mingw64\bin\git.exe
|
|
7881
|
+
cands.append(ntpath.join(d, "bin", "bash.exe"))
|
|
7882
|
+
d = ntpath.dirname(d)
|
|
7883
|
+
for var, sub in (("ProgramFiles", "Git"), ("ProgramW6432", "Git"),
|
|
7884
|
+
("ProgramFiles(x86)", "Git"), ("LOCALAPPDATA", ntpath.join("Programs", "Git"))):
|
|
7885
|
+
if env.get(var):
|
|
7886
|
+
cands.append(ntpath.join(env[var], sub, "bin", "bash.exe"))
|
|
7887
|
+
for c in cands:
|
|
7888
|
+
low = c.lower()
|
|
7889
|
+
if c and "\\system32\\" not in low and "\\windowsapps\\" not in low and isfile(c):
|
|
7890
|
+
return c
|
|
7891
|
+
return ""
|
|
7892
|
+
|
|
7893
|
+
|
|
7811
7894
|
def _run_shell_bin():
|
|
7812
|
-
"""
|
|
7813
|
-
GU_RUN_NO_SHELL=1
|
|
7895
|
+
"""The shell for run_command's shell lines: bash (else sh), Git Bash on Windows — or "" for
|
|
7896
|
+
the argv-only path (Windows without Git for Windows, or GU_RUN_NO_SHELL=1, the way back if a
|
|
7897
|
+
shell line ever misbehaves for a user)."""
|
|
7814
7898
|
global _RUN_SHELL_BIN
|
|
7815
|
-
if os.
|
|
7899
|
+
if os.environ.get("GU_RUN_NO_SHELL") == "1":
|
|
7816
7900
|
return ""
|
|
7817
7901
|
if _RUN_SHELL_BIN is None:
|
|
7818
|
-
|
|
7819
|
-
|
|
7820
|
-
|
|
7902
|
+
if os.name == "nt":
|
|
7903
|
+
_RUN_SHELL_BIN = _git_bash_path()
|
|
7904
|
+
_log("run_command shell: " + ("Git Bash" if _RUN_SHELL_BIN else
|
|
7905
|
+
"none (Git for Windows not found) — commands run without a shell"))
|
|
7906
|
+
else:
|
|
7907
|
+
import shutil as _sh
|
|
7908
|
+
_RUN_SHELL_BIN = next((p for p in (_sh.which("bash"), "/bin/bash", "/bin/sh")
|
|
7909
|
+
if p and os.path.exists(p)), "")
|
|
7821
7910
|
return _RUN_SHELL_BIN
|
|
7822
7911
|
|
|
7823
7912
|
|
|
7824
|
-
def _run_command_prompt_line(shell):
|
|
7913
|
+
def _run_command_prompt_line(shell, windows=None):
|
|
7825
7914
|
"""The system prompt's run_command rule. With a shell it carries the git and GitHub recipes,
|
|
7826
|
-
the way Claude Code's Bash tool description does: told how to batch, the model does (#359).
|
|
7915
|
+
the way Claude Code's Bash tool description does: told how to batch, the model does (#359).
|
|
7916
|
+
On Windows the shell is Git Bash, and the line says what differs there."""
|
|
7917
|
+
windows = (os.name == "nt") if windows is None else windows
|
|
7827
7918
|
if not shell:
|
|
7828
7919
|
return (
|
|
7829
7920
|
" run_command CHAINS: put dependent commands in ONE call with && — e.g. "
|
|
@@ -7832,12 +7923,16 @@ def _run_command_prompt_line(shell):
|
|
|
7832
7923
|
"chain. There is NO shell otherwise: pipes `|`, redirects `>`, `;`, `||` and backticks do "
|
|
7833
7924
|
"NOT work (use python3 for anything that composes). `sudo`/`su` are blocked; an "
|
|
7834
7925
|
"interactive input() CLI fails with EOFError. A server or GUI app is left RUNNING — "
|
|
7835
|
-
"stop_server closes it
|
|
7926
|
+
"stop_server closes it. " + _BG_LINE)
|
|
7836
7927
|
return (
|
|
7837
7928
|
" run_command IS A SHELL (bash): pipes, redirects, `;`, `||`, `$( )`, globs and heredocs "
|
|
7838
7929
|
"work, so put a whole sequence in ONE call — dependent steps with &&, independent ones with "
|
|
7839
7930
|
"`;` (e.g. `cd app && npm test 2>&1 | tail -40`); a multi-line command goes in a "
|
|
7840
7931
|
"triple-quoted string.\n"
|
|
7932
|
+
+ (" WINDOWS: the shell is Git Bash. Write paths with forward slashes (`C:/Users/x` or "
|
|
7933
|
+
"`/c/Users/x`, never `C:\\Users`); Windows programs (npm, node, git, gh, py) run as usual; "
|
|
7934
|
+
"Python is `py -3` or `python`, not python3; cmd built-ins need `cmd //c dir`.\n"
|
|
7935
|
+
if windows else "") +
|
|
7841
7936
|
" GIT in one call each: look (`git status -sb; git diff --stat; git log --oneline -5`), "
|
|
7842
7937
|
"commit (`git add a.py b.py && git commit -m \"msg\" && git log --oneline -1`).\n"
|
|
7843
7938
|
" GITHUB: use `gh`. Multi-line text goes as a heredoc on stdin — "
|
|
@@ -7846,7 +7941,13 @@ def _run_command_prompt_line(shell):
|
|
|
7846
7941
|
" Read and search FILES with read_file_lines / grep_repo, not cat/grep/sed. `sudo`/`su` "
|
|
7847
7942
|
"are blocked; piping into sh/python and writing or deleting outside the project ask the "
|
|
7848
7943
|
"user first; an interactive input() CLI fails with EOFError. A server or GUI app is left "
|
|
7849
|
-
"RUNNING — stop_server closes it
|
|
7944
|
+
"RUNNING — stop_server closes it. " + _BG_LINE)
|
|
7945
|
+
|
|
7946
|
+
|
|
7947
|
+
# Background shells (task #360). Never `&` (refused) — background=True is the way to say it.
|
|
7948
|
+
_BG_LINE = ("A LONG job (build, deploy, big test run) → run_command(…, background=True), then "
|
|
7949
|
+
"shell_output(<id>, wait_seconds=…) to wait for it; a command still running at its "
|
|
7950
|
+
"timeout moves to the background by itself instead of being killed.\n")
|
|
7850
7951
|
|
|
7851
7952
|
|
|
7852
7953
|
_RUN_DOC_ARGV = ("No other shell syntax (no pipes/redirects/;). sudo/su are blocked.")
|
|
@@ -8184,7 +8285,7 @@ def _ws_delete_outside_reason(argv, wd, roots=None):
|
|
|
8184
8285
|
temp = [os.path.realpath(p) for p in {tempfile.gettempdir(), "/tmp", "/var/tmp"} if p]
|
|
8185
8286
|
outside = []
|
|
8186
8287
|
for t in targets:
|
|
8187
|
-
p = os.path.expanduser(t)
|
|
8288
|
+
p = os.path.expanduser(_shell_path_to_native(t))
|
|
8188
8289
|
p = os.path.realpath(p if os.path.isabs(p) else os.path.join(wd or os.getcwd(), p))
|
|
8189
8290
|
if not (_ws_is_inside(p, ws) or _ws_is_inside(p, temp)):
|
|
8190
8291
|
outside.append(t)
|
|
@@ -8724,6 +8825,153 @@ def _ws_cap_servers(max_live: int = 3) -> list:
|
|
|
8724
8825
|
return servers
|
|
8725
8826
|
|
|
8726
8827
|
|
|
8828
|
+
# ---- BACKGROUND SHELLS (task #360, GitHub #140) ------------------------------------------------
|
|
8829
|
+
# A long build (`fastlane ios beta`, 7+ minutes) is neither a server nor a GUI app, so run_command
|
|
8830
|
+
# used to KILL it at its deadline — the one outcome that is never what anyone wanted. Now it is
|
|
8831
|
+
# handed off instead: it keeps running in its own process group, and its record lives here, in a
|
|
8832
|
+
# file of its own rather than servers.json, because shells must not count toward the 3-server cap
|
|
8833
|
+
# (that would kill a build to make room for a dev server) and a FINISHED one stays listed so the
|
|
8834
|
+
# user and the model can read how it ended. The terminal reads the same file for its "shell
|
|
8835
|
+
# running" link and the Shell details view (shell-details.mjs).
|
|
8836
|
+
#
|
|
8837
|
+
# THE EXIT CODE OUTLIVES THE AGENT. The agent is one process per turn; a build that ends after the
|
|
8838
|
+
# turn has no parent left to wait() on it. So a shell line runs as `"$0" -c "$1"; rc=$?; echo $rc
|
|
8839
|
+
# > "$2"; exit $rc` — the command text untouched, its exit code written to `exit_file` AND passed
|
|
8840
|
+
# on (without the `exit $rc` every failure read as EXITED 0). The argv-only path (Windows)
|
|
8841
|
+
# has no such wrapper and says "exit code unknown" instead of guessing.
|
|
8842
|
+
_SHELLS_KEEP = 20 # newest records kept, running or not
|
|
8843
|
+
_SHELLS_KEEP_DONE_S = 86400 # a finished shell is listed for a day
|
|
8844
|
+
|
|
8845
|
+
|
|
8846
|
+
def _shells_path() -> str:
|
|
8847
|
+
import os
|
|
8848
|
+
base = os.path.join(os.path.expanduser("~"), ".gonext")
|
|
8849
|
+
os.makedirs(base, exist_ok=True)
|
|
8850
|
+
return os.path.join(base, "shells.json")
|
|
8851
|
+
|
|
8852
|
+
|
|
8853
|
+
def _shells_load() -> list:
|
|
8854
|
+
import json as _json
|
|
8855
|
+
try:
|
|
8856
|
+
with open(_shells_path(), encoding="utf-8") as fh:
|
|
8857
|
+
shells = _json.load(fh).get("shells", [])
|
|
8858
|
+
except Exception: # noqa: BLE001
|
|
8859
|
+
return []
|
|
8860
|
+
return [s for s in shells if isinstance(s, dict) and s.get("pid")]
|
|
8861
|
+
|
|
8862
|
+
|
|
8863
|
+
def _shells_save(shells: list) -> None:
|
|
8864
|
+
import json as _json
|
|
8865
|
+
import os
|
|
8866
|
+
import time as _t
|
|
8867
|
+
now = _t.time()
|
|
8868
|
+
kept = []
|
|
8869
|
+
for s in shells:
|
|
8870
|
+
st = _shell_state(s, now=now)
|
|
8871
|
+
if st["state"] != "running" and now - st["ended"] > _SHELLS_KEEP_DONE_S:
|
|
8872
|
+
continue
|
|
8873
|
+
kept.append(s)
|
|
8874
|
+
path = _shells_path()
|
|
8875
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
8876
|
+
try:
|
|
8877
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
8878
|
+
_json.dump({"shells": kept[-_SHELLS_KEEP:]}, fh, indent=2)
|
|
8879
|
+
os.replace(tmp, path) # the terminal polls this file — never let it read half of it
|
|
8880
|
+
except OSError as e:
|
|
8881
|
+
_log(f"shells.json write failed: {e}")
|
|
8882
|
+
|
|
8883
|
+
|
|
8884
|
+
def _shell_exit_code(s: dict):
|
|
8885
|
+
"""(code, mtime) from the shell's exit file, or (None, None) when it has not written one."""
|
|
8886
|
+
import os
|
|
8887
|
+
path = s.get("exit_file") or ""
|
|
8888
|
+
try:
|
|
8889
|
+
with open(path, encoding="utf-8") as fh:
|
|
8890
|
+
raw = fh.read().strip()
|
|
8891
|
+
return (int(raw), os.path.getmtime(path)) if raw.lstrip("-").isdigit() else (None, None)
|
|
8892
|
+
except (OSError, ValueError):
|
|
8893
|
+
return (None, None)
|
|
8894
|
+
|
|
8895
|
+
|
|
8896
|
+
def _shell_state(s: dict, alive=None, now=None) -> dict:
|
|
8897
|
+
"""{state, code, runtime, ended} for a shell record. state: running | exited (0) | failed |
|
|
8898
|
+
killed | ended (gone, exit code unknown). `alive` is injectable so tests need no process."""
|
|
8899
|
+
import time as _t
|
|
8900
|
+
now = now if now is not None else _t.time()
|
|
8901
|
+
started = float(s.get("started") or now)
|
|
8902
|
+
code, code_at = _shell_exit_code(s)
|
|
8903
|
+
if alive is None:
|
|
8904
|
+
alive = code is None and bool(_ws_pgroup_pids(int(s.get("pgid") or 0)))
|
|
8905
|
+
if alive and code is None and not s.get("killed"):
|
|
8906
|
+
return {"state": "running", "code": None, "runtime": max(0.0, now - started), "ended": now}
|
|
8907
|
+
ended = float(s.get("ended") or code_at or now)
|
|
8908
|
+
if s.get("killed"):
|
|
8909
|
+
state = "killed"
|
|
8910
|
+
elif code is None:
|
|
8911
|
+
state = "ended"
|
|
8912
|
+
else:
|
|
8913
|
+
state = "exited" if code == 0 else "failed"
|
|
8914
|
+
return {"state": state, "code": code, "runtime": max(0.0, ended - started), "ended": ended}
|
|
8915
|
+
|
|
8916
|
+
|
|
8917
|
+
def _fmt_runtime(seconds: float) -> str:
|
|
8918
|
+
"""7m 2s — the way the Shell details view writes it."""
|
|
8919
|
+
s = int(seconds)
|
|
8920
|
+
h, s = divmod(s, 3600)
|
|
8921
|
+
m, s = divmod(s, 60)
|
|
8922
|
+
return f"{h}h {m}m" if h else (f"{m}m {s}s" if m else f"{s}s")
|
|
8923
|
+
|
|
8924
|
+
|
|
8925
|
+
def _shell_status_text(st: dict) -> str:
|
|
8926
|
+
if st["state"] == "running":
|
|
8927
|
+
return "running"
|
|
8928
|
+
if st["state"] in ("exited", "failed"):
|
|
8929
|
+
return f"exited {st['code']}"
|
|
8930
|
+
return "killed" if st["state"] == "killed" else "ended (exit code unknown)"
|
|
8931
|
+
|
|
8932
|
+
|
|
8933
|
+
def _shell_find(shells: list, key: str):
|
|
8934
|
+
"""A shell by its id/pid, or the newest one for an empty key."""
|
|
8935
|
+
key = (key or "").strip().lstrip("#")
|
|
8936
|
+
if not key:
|
|
8937
|
+
return shells[-1] if shells else None
|
|
8938
|
+
for s in reversed(shells):
|
|
8939
|
+
if key in (str(s.get("id")), str(s.get("pid"))):
|
|
8940
|
+
return s
|
|
8941
|
+
return None
|
|
8942
|
+
|
|
8943
|
+
|
|
8944
|
+
def _shell_kill(s: dict) -> None:
|
|
8945
|
+
"""Stop a shell's whole process group and mark it killed (unless it already finished)."""
|
|
8946
|
+
import time as _t
|
|
8947
|
+
if _shell_state(s)["state"] != "running":
|
|
8948
|
+
return
|
|
8949
|
+
try:
|
|
8950
|
+
_ws_kill_tree(int(s.get("pgid") or 0))
|
|
8951
|
+
_t.sleep(1.0)
|
|
8952
|
+
if _ws_pgroup_pids(int(s.get("pgid") or 0)):
|
|
8953
|
+
_ws_kill_tree(int(s.get("pgid") or 0), force=True)
|
|
8954
|
+
except (OSError, ValueError):
|
|
8955
|
+
pass
|
|
8956
|
+
s["killed"] = True
|
|
8957
|
+
s["ended"] = _t.time()
|
|
8958
|
+
|
|
8959
|
+
|
|
8960
|
+
def _shells_context_line(shells: list, now=None) -> str:
|
|
8961
|
+
"""The turn's line about shells: what is still running, and what finished since the model
|
|
8962
|
+
last looked — a build that ended between turns is news the model has not seen."""
|
|
8963
|
+
bits = []
|
|
8964
|
+
for s in shells:
|
|
8965
|
+
st = _shell_state(s, now=now)
|
|
8966
|
+
if st["state"] == "running" or not s.get("seen"):
|
|
8967
|
+
bits.append(f"shell {s.get('id')} {_shell_status_text(st)} after "
|
|
8968
|
+
f"{_fmt_runtime(st['runtime'])}: '{_clip(str(s.get('command')), 60)}'")
|
|
8969
|
+
if not bits:
|
|
8970
|
+
return ""
|
|
8971
|
+
return (" Background shells YOU started: " + "; ".join(bits)
|
|
8972
|
+
+ ". Read one with shell_output(<id>); stop one with stop_server(<id>).\n")
|
|
8973
|
+
|
|
8974
|
+
|
|
8727
8975
|
def _ws_scrubbed_env() -> dict:
|
|
8728
8976
|
"""Minimal child env for run_command. The worker process env holds secrets
|
|
8729
8977
|
(GONEXT_WORKER_KEY etc. via worker.env) — a repo's test script must NEVER see
|
|
@@ -8734,6 +8982,13 @@ def _ws_scrubbed_env() -> dict:
|
|
|
8734
8982
|
# SSH agent socket so ssh/scp/rsync can authenticate with the user's KEY
|
|
8735
8983
|
# (no password in the model's context) for deploys (task #57).
|
|
8736
8984
|
"SSH_AUTH_SOCK", "SSH_AGENT_PID")
|
|
8985
|
+
if os.name == "nt":
|
|
8986
|
+
# Not secrets — what Windows programs need to start at all: without SYSTEMROOT Python's
|
|
8987
|
+
# sockets fail, npm needs APPDATA, Git Bash needs USERPROFILE/HOMEDRIVE to find home.
|
|
8988
|
+
keep += ("SYSTEMROOT", "SystemDrive", "WINDIR", "COMSPEC", "PATHEXT", "USERPROFILE",
|
|
8989
|
+
"HOMEDRIVE", "HOMEPATH", "USERNAME", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP",
|
|
8990
|
+
"ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "ProgramData",
|
|
8991
|
+
"CommonProgramFiles", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE")
|
|
8737
8992
|
env = {k: os.environ[k] for k in keep if k in os.environ}
|
|
8738
8993
|
# CI=1 makes watch-mode test runners (CRA's `npm test`, jest --watch) run ONCE and
|
|
8739
8994
|
# exit instead of sitting in interactive watch mode until the timeout kills them.
|
|
@@ -10030,6 +10285,10 @@ def run_agent_chat(cfg):
|
|
|
10030
10285
|
"are TAKEN, so never start a duplicate on one of them.\n")
|
|
10031
10286
|
except Exception as _e: # noqa: BLE001
|
|
10032
10287
|
_log(f"server context skip: {_e}")
|
|
10288
|
+
try:
|
|
10289
|
+
_ws_servers_line += _shells_context_line(_shells_load())
|
|
10290
|
+
except Exception as _e: # noqa: BLE001
|
|
10291
|
+
_log(f"shell context skip: {_e}")
|
|
10033
10292
|
_ws_context_block = (
|
|
10034
10293
|
_ws_current_line
|
|
10035
10294
|
+ f" Other registered workspaces (use only if the user names them): {_ws_names}\n"
|
|
@@ -10197,7 +10456,10 @@ def run_agent_chat(cfg):
|
|
|
10197
10456
|
"find a URL, THEN fetch_url to read it). You SEE the Observation before the next step. "
|
|
10198
10457
|
"When you have enough to answer, call final_answer(<your answer>) in a code block — pass a readable "
|
|
10199
10458
|
"markdown/prose recap (what you did, what you verified, what is left), never a dict, list or JSON. "
|
|
10200
|
-
"Prefer FEWER steps, and never repeat a tool call that already succeeded
|
|
10459
|
+
"Prefer FEWER steps, and never repeat a tool call that already succeeded. "
|
|
10460
|
+
# #139: Kimi K3 replied "…Let me check X" with no block, and that plan became the answer.
|
|
10461
|
+
"A reply WITHOUT a code block ENDS your turn and is shown to the user as your answer — "
|
|
10462
|
+
"never end on \"Let me check…\"; if there is more to do, put that call in the block NOW.\n\n"
|
|
10201
10463
|
"{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
|
|
10202
10464
|
+ _skills_mod.prompt_block(_skills)
|
|
10203
10465
|
+ _rag_tool_block + _ws_tool_block + _auto_test_block +
|
|
@@ -14037,13 +14299,15 @@ def run_agent_chat(cfg):
|
|
|
14037
14299
|
_last_obs["text"] = msg
|
|
14038
14300
|
return msg
|
|
14039
14301
|
|
|
14040
|
-
def run_command(command: str, workdir: str = "", timeout_seconds: int = 180
|
|
14302
|
+
def run_command(command: str, workdir: str = "", timeout_seconds: int = 180,
|
|
14303
|
+
background: bool = False) -> str:
|
|
14041
14304
|
"""Run a command inside a workspace that has run permission (e.g. 'npm test', 'npm run build', 'npm start', 'mvn test', 'python3 app.py'). One-shot commands return their output when they exit. If the command turns out to be a SERVER (keeps running and listens on a port), it is left RUNNING and its URL is reported. If it is a DESKTOP/GUI app (opens a window — tkinter, pygame, PyQt…), it is LAUNCHED and left running: its window appears on the user's Mac; tell them it's open. Both are stoppable via stop_server. Also allows DEPLOY commands (ssh/scp/rsync) — use non-interactive KEY auth (-o BatchMode=yes); never put a password in the command (there is no stdin). Note: an interactive CLI that reads stdin (input()) cannot be driven here — it will fail with EOFError; make it non-interactive or give the user the command to run themselves.
|
|
14042
14305
|
|
|
14043
14306
|
Args:
|
|
14044
14307
|
command: the command line. Chain dependent steps with && in ONE call ("cd app && npm ci && npm test"): they run in order, cd sets the folder, the first failure stops the rest. No other shell syntax (no pipes/redirects/;). sudo/su are blocked.
|
|
14045
14308
|
workdir: directory to run in. Empty = first registered workspace.
|
|
14046
|
-
timeout_seconds:
|
|
14309
|
+
timeout_seconds: wait this many seconds (max 600); a command still running then is NOT killed — it moves to the background as a shell, read later with shell_output.
|
|
14310
|
+
background: True for a long job you do not need to watch (a build, deploy, long test run): it starts, returns its shell id after ~3s, and keeps running. Check it with shell_output(<id>, wait_seconds=…).
|
|
14047
14311
|
"""
|
|
14048
14312
|
proc = None # read by the `finally` below, which runs even if Popen never did
|
|
14049
14313
|
try:
|
|
@@ -14144,7 +14408,7 @@ def run_agent_chat(cfg):
|
|
|
14144
14408
|
break
|
|
14145
14409
|
_res = str(run_command(_part, _cur_wd, int(min(timeout_seconds or 180, 600, _left))))
|
|
14146
14410
|
_outs.append(f"{_hdr}\n{_res}")
|
|
14147
|
-
if _res.startswith(("FAILED", "Error")):
|
|
14411
|
+
if _res.startswith(("FAILED", "Error", "BACKGROUND")):
|
|
14148
14412
|
_stopped = _part
|
|
14149
14413
|
break
|
|
14150
14414
|
_rest = _chain[len(_outs):]
|
|
@@ -14263,12 +14527,18 @@ def run_agent_chat(cfg):
|
|
|
14263
14527
|
os.makedirs(logs_dir, exist_ok=True)
|
|
14264
14528
|
log_path = os.path.join(logs_dir, f"{_t.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}.log")
|
|
14265
14529
|
log_fh = open(log_path, "wb")
|
|
14530
|
+
exit_path = log_path[:-4] + ".exit"
|
|
14266
14531
|
# gu as a co-author on the commits it makes (task #289) — after every check
|
|
14267
14532
|
# above, which judged the command as the model wrote it.
|
|
14268
14533
|
if _use_shell:
|
|
14269
14534
|
_cmd_text, _attrib_note = _with_attribution_text(
|
|
14270
14535
|
command, _scan, _attribution_settings(wd))
|
|
14271
|
-
|
|
14536
|
+
# The exit code is written to a file so it outlives this turn's process —
|
|
14537
|
+
# the line itself runs untouched as $1 (see BACKGROUND SHELLS).
|
|
14538
|
+
# Git Bash reads C:/x, not C:\x, inside the line it runs.
|
|
14539
|
+
_fs = (lambda x: x.replace("\\", "/")) if _IS_WINDOWS else (lambda x: x)
|
|
14540
|
+
argv = [_shell_bin, "-c", '"$0" -c "$1"; rc=$?; echo $rc > "$2"; exit $rc',
|
|
14541
|
+
_fs(_shell_bin), _cmd_text, _fs(exit_path)]
|
|
14272
14542
|
else:
|
|
14273
14543
|
argv, _attrib_note = _with_attribution(argv, _attribution_settings(wd))
|
|
14274
14544
|
if _attrib_note:
|
|
@@ -14285,6 +14555,25 @@ def run_agent_chat(cfg):
|
|
|
14285
14555
|
_INFLIGHT_CMDS.add(proc.pid) # killed with the agent until it is classified
|
|
14286
14556
|
start = _t.time()
|
|
14287
14557
|
|
|
14558
|
+
def _to_background(why: str) -> str:
|
|
14559
|
+
"""Leave the command running as a background shell; say so to the model."""
|
|
14560
|
+
shells = _shells_load()
|
|
14561
|
+
shells.append({
|
|
14562
|
+
"id": str(proc.pid), "pid": proc.pid, "pgid": proc.pid,
|
|
14563
|
+
"command": command, "workdir": wd, "log": log_path,
|
|
14564
|
+
"exit_file": exit_path if _use_shell else "",
|
|
14565
|
+
"started": start, "started_at": _t.strftime("%Y-%m-%d %H:%M:%S"),
|
|
14566
|
+
})
|
|
14567
|
+
_shells_save(shells)
|
|
14568
|
+
_emit({"type": "step", "text":
|
|
14569
|
+
f"Running in background → {_trim_words(command)} (shell {proc.pid})"})
|
|
14570
|
+
tail = _ws_distill_output(_read_log())
|
|
14571
|
+
return (f"BACKGROUND: '{_clip(command, 200)}' is {why} and keeps running as "
|
|
14572
|
+
f"shell {proc.pid} (log {log_path}). Do NOT start it again. Check it "
|
|
14573
|
+
f"with shell_output('{proc.pid}', wait_seconds=…); stop it with "
|
|
14574
|
+
f"stop_server('{proc.pid}').\nOutput so far:\n"
|
|
14575
|
+
+ (tail if tail.strip() else "(none yet)"))
|
|
14576
|
+
|
|
14288
14577
|
def _read_log() -> str:
|
|
14289
14578
|
log_fh.flush()
|
|
14290
14579
|
try:
|
|
@@ -14380,17 +14669,20 @@ def run_agent_chat(cfg):
|
|
|
14380
14669
|
+ (f"\nStartup output:\n{tail}" if tail.strip() else ""))
|
|
14381
14670
|
_last_obs["text"] = result
|
|
14382
14671
|
return result
|
|
14672
|
+
# A job sent to the background on purpose, once it has had its 3s to show
|
|
14673
|
+
# it is neither a server nor an app.
|
|
14674
|
+
if background and _t.time() - start >= 3:
|
|
14675
|
+
msg = _to_background("started")
|
|
14676
|
+
_last_obs["text"] = msg
|
|
14677
|
+
return msg
|
|
14678
|
+
# THE DEADLINE HANDS OFF, IT NO LONGER KILLS (task #360): a 7-minute build
|
|
14679
|
+
# is the command doing its job, not a hang. The 1h turn budget and
|
|
14680
|
+
# stop_server still bound it.
|
|
14383
14681
|
if _t.time() - start >= t:
|
|
14384
|
-
|
|
14385
|
-
_ws_kill_tree(proc.pid, force=True)
|
|
14386
|
-
except OSError:
|
|
14387
|
-
pass
|
|
14388
|
-
tail = _ws_distill_output(_read_log())
|
|
14389
|
-
msg = (f"Error: command neither exited nor started a server within "
|
|
14390
|
-
f"{t}s — killed. Output so far:\n{tail}")
|
|
14682
|
+
msg = _to_background(f"still running after {t}s")
|
|
14391
14683
|
_last_obs["text"] = msg
|
|
14392
14684
|
return msg
|
|
14393
|
-
_t.sleep(2)
|
|
14685
|
+
_t.sleep(1 if background else 2)
|
|
14394
14686
|
except Exception as ex: # noqa: BLE001
|
|
14395
14687
|
msg = f"Error: run_command failed: {type(ex).__name__}: {ex}"
|
|
14396
14688
|
_last_obs["text"] = msg
|
|
@@ -14406,22 +14698,44 @@ def run_agent_chat(cfg):
|
|
|
14406
14698
|
pass
|
|
14407
14699
|
|
|
14408
14700
|
# The description says what the command line may contain, which depends on the machine:
|
|
14409
|
-
# bash here
|
|
14701
|
+
# bash here (Git Bash on Windows), argv when there is none (#359). Set before tool() reads
|
|
14702
|
+
# the docstring.
|
|
14410
14703
|
if _run_shell_bin():
|
|
14411
14704
|
run_command.__doc__ = run_command.__doc__.replace(_RUN_DOC_ARGV, _RUN_DOC_SHELL)
|
|
14412
14705
|
run_command = tool(run_command)
|
|
14413
14706
|
|
|
14414
14707
|
@tool
|
|
14415
14708
|
def stop_server(port_or_pid: str = "") -> str:
|
|
14416
|
-
"""Stop a background server
|
|
14709
|
+
"""Stop a background server, desktop app (closes its window) or background shell previously started by run_command. Use when the user asks to stop/kill/close it.
|
|
14417
14710
|
|
|
14418
14711
|
Args:
|
|
14419
|
-
port_or_pid: the server's port or pid (empty = the most recently started
|
|
14712
|
+
port_or_pid: the server's port or pid, or a shell id (empty = the most recently started server).
|
|
14420
14713
|
"""
|
|
14421
14714
|
try:
|
|
14422
14715
|
import os
|
|
14423
14716
|
import signal
|
|
14424
14717
|
import time as _t
|
|
14718
|
+
key = (port_or_pid or "").strip()
|
|
14719
|
+
shells = _shells_load()
|
|
14720
|
+
if key:
|
|
14721
|
+
sh = _shell_find(shells, key)
|
|
14722
|
+
elif not _ws_load_servers():
|
|
14723
|
+
# Nothing else to stop: the newest shell still running.
|
|
14724
|
+
sh = next((x for x in reversed(shells)
|
|
14725
|
+
if _shell_state(x)["state"] == "running"), None)
|
|
14726
|
+
else:
|
|
14727
|
+
sh = None
|
|
14728
|
+
if sh is not None:
|
|
14729
|
+
was = _shell_status_text(_shell_state(sh))
|
|
14730
|
+
_shell_kill(sh)
|
|
14731
|
+
sh["seen"] = True
|
|
14732
|
+
_shells_save(shells)
|
|
14733
|
+
_emit({"type": "step", "text": f"Shell stopped → {_trim_words(str(sh.get('command')))}"})
|
|
14734
|
+
out = (f"Stopped shell {sh.get('id')} ('{_clip(str(sh.get('command')), 120)}')."
|
|
14735
|
+
if was == "running" else
|
|
14736
|
+
f"Shell {sh.get('id')} had already finished: {was}.")
|
|
14737
|
+
_last_obs["text"] = out
|
|
14738
|
+
return out
|
|
14425
14739
|
servers = _ws_load_servers()
|
|
14426
14740
|
if not servers:
|
|
14427
14741
|
msg = "No background servers are running."
|
|
@@ -14460,6 +14774,60 @@ def run_agent_chat(cfg):
|
|
|
14460
14774
|
_last_obs["text"] = msg
|
|
14461
14775
|
return msg
|
|
14462
14776
|
|
|
14777
|
+
@tool
|
|
14778
|
+
def shell_output(shell_id: str = "", wait_seconds: int = 0) -> str:
|
|
14779
|
+
"""Read a background shell started by run_command (a long build, deploy or test run): its status (running / exited N / killed), runtime, and the output it printed SINCE YOUR LAST READ. To wait for it to finish, pass wait_seconds (max 540) — one call that waits is cheaper than many that poll.
|
|
14780
|
+
|
|
14781
|
+
Args:
|
|
14782
|
+
shell_id: the shell id run_command gave you (empty = the newest shell).
|
|
14783
|
+
wait_seconds: wait up to this long for the shell to finish before answering (0 = answer now).
|
|
14784
|
+
"""
|
|
14785
|
+
try:
|
|
14786
|
+
import time as _t
|
|
14787
|
+
shells = _shells_load()
|
|
14788
|
+
sh = _shell_find(shells, shell_id)
|
|
14789
|
+
if sh is None:
|
|
14790
|
+
msg = ("No background shell matches '" + (shell_id or "") + "'. "
|
|
14791
|
+
+ ("Shells: " + ", ".join(str(x.get("id")) for x in shells) if shells
|
|
14792
|
+
else "No background shells were started."))
|
|
14793
|
+
_last_obs["text"] = msg
|
|
14794
|
+
return msg
|
|
14795
|
+
deadline = _t.time() + max(0, min(int(wait_seconds or 0), 540))
|
|
14796
|
+
st = _shell_state(sh)
|
|
14797
|
+
while st["state"] == "running" and _t.time() < deadline:
|
|
14798
|
+
_t.sleep(2)
|
|
14799
|
+
st = _shell_state(sh)
|
|
14800
|
+
try:
|
|
14801
|
+
with open(sh.get("log") or "", "rb") as fh:
|
|
14802
|
+
fh.seek(int(sh.get("read_offset") or 0))
|
|
14803
|
+
chunk = fh.read()
|
|
14804
|
+
except OSError:
|
|
14805
|
+
chunk = b""
|
|
14806
|
+
# Re-read the file before writing: the terminal may have killed it meanwhile.
|
|
14807
|
+
shells = _shells_load()
|
|
14808
|
+
cur = _shell_find(shells, str(sh.get("id"))) or sh
|
|
14809
|
+
cur["read_offset"] = int(sh.get("read_offset") or 0) + len(chunk)
|
|
14810
|
+
if st["state"] != "running":
|
|
14811
|
+
cur["seen"] = True
|
|
14812
|
+
_shells_save(shells)
|
|
14813
|
+
new = _ws_distill_output(chunk.decode("utf-8", errors="replace"))
|
|
14814
|
+
head = (f"shell {sh.get('id')}: {_shell_status_text(st).upper()} after "
|
|
14815
|
+
f"{_fmt_runtime(st['runtime'])} — '{_clip(str(sh.get('command')), 200)}' "
|
|
14816
|
+
f"(log {sh.get('log')})")
|
|
14817
|
+
if st["state"] == "exited":
|
|
14818
|
+
head += ("\nexit 0 alone does NOT prove the intended effect happened — check "
|
|
14819
|
+
"the output and VERIFY.")
|
|
14820
|
+
msg = head + "\n" + (f"New output:\n{new}" if new.strip()
|
|
14821
|
+
else "No new output since the last read.")
|
|
14822
|
+
_emit({"type": "step", "text":
|
|
14823
|
+
f"Shell {sh.get('id')} {_shell_status_text(st)} → {_trim_words(str(sh.get('command')))}"})
|
|
14824
|
+
_last_obs["text"] = msg
|
|
14825
|
+
return msg
|
|
14826
|
+
except Exception as ex: # noqa: BLE001
|
|
14827
|
+
msg = f"Error: shell_output failed: {type(ex).__name__}: {ex}"
|
|
14828
|
+
_last_obs["text"] = msg
|
|
14829
|
+
return msg
|
|
14830
|
+
|
|
14463
14831
|
@tool
|
|
14464
14832
|
def deploy_web(local_dir: str, host: str, user: str, remote_path: str,
|
|
14465
14833
|
domain: str = "") -> str:
|
|
@@ -14603,7 +14971,7 @@ def run_agent_chat(cfg):
|
|
|
14603
14971
|
# tools below it is always available wherever a workspace is (task #221).
|
|
14604
14972
|
agent_tools += [symbol_context, grep_repo, read_file_lines, edit_lines, edit_file,
|
|
14605
14973
|
create_file, create_folder, run_command, stop_server,
|
|
14606
|
-
deploy_web]
|
|
14974
|
+
shell_output, deploy_web]
|
|
14607
14975
|
# Workspace RAG (task #133): semantic search over the PROJECT itself, not just a
|
|
14608
14976
|
# ZIP-at-URL. Needs both a workspace to index and a working embedder, so it's
|
|
14609
14977
|
# registered only when RAG is actually available — the model never sees a tool it
|
package/gu-repl.mjs
CHANGED
|
@@ -139,6 +139,7 @@ import {
|
|
|
139
139
|
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
140
140
|
import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
141
141
|
import { clearLogsNoticeText } from "./clear-logs.mjs";
|
|
142
|
+
import { killShell, logTail, readShells, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
|
|
142
143
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
143
144
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
144
145
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
@@ -947,6 +948,7 @@ const COMMANDS = [
|
|
|
947
948
|
{ name: "/open-presets", usage: "/open-presets [name]", desc: "your whole setup now, keys included, read-only in your editor — to copy to another computer; with a name, edit that preset" },
|
|
948
949
|
{ name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
|
|
949
950
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
951
|
+
{ name: "/shells", desc: "long commands the agent left running in the background — status, runtime, command, output; stop one" },
|
|
950
952
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
951
953
|
{ name: "/clear-logs", desc: "show what gu's logs, RAG indexes and old API versions take on disk, then ask what to clear — the logs first" },
|
|
952
954
|
{ name: "/prices", usage: "/prices [update]", desc: "the price table and the cache table, and what the last turn and this session cost — \"update\" applies gu's newest published tables" },
|
|
@@ -1544,6 +1546,11 @@ function applyClick({ row, col }) {
|
|
|
1544
1546
|
void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
|
|
1545
1547
|
return;
|
|
1546
1548
|
}
|
|
1549
|
+
if (shellsRowTarget && row === updateRowAbs &&
|
|
1550
|
+
col - 1 >= shellsRowTarget.from && col - 1 < shellsRowTarget.to) {
|
|
1551
|
+
void showShells();
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1547
1554
|
// The input line has a KNOWN absolute row now (it is frozen), so a click on it maps straight
|
|
1548
1555
|
// to a text offset — no \x1b[6n probe, no anchor to go stale (task #141).
|
|
1549
1556
|
if (barPinned()) {
|
|
@@ -2957,6 +2964,8 @@ function zoneNotice(width) {
|
|
|
2957
2964
|
if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
|
|
2958
2965
|
const agents = agentsIndicator(otherAgents);
|
|
2959
2966
|
if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
|
|
2967
|
+
const shells = shellsNotice(shellEntries, { viewed: shellsViewed, since: SHELLS_SINCE });
|
|
2968
|
+
if (shells) items.push({ key: "shells", clickable: true, paint: shells.tone === "warn" ? yellow : accent, text: shells.text });
|
|
2960
2969
|
// LAST, so it is the first to give way on a narrow window: housekeeping, never urgent (#329).
|
|
2961
2970
|
const logs = clearLogsNoticeText(logsBytes);
|
|
2962
2971
|
if (logs) items.push({ key: "logs", clickable: true, paint: dim, text: logs });
|
|
@@ -3042,6 +3051,7 @@ function frozenZone() {
|
|
|
3042
3051
|
pricesRowTarget = notice?.spans.prices ?? null;
|
|
3043
3052
|
limitsRowTarget = notice?.spans.limits ?? null;
|
|
3044
3053
|
logsRowTarget = notice?.spans.logs ?? null;
|
|
3054
|
+
shellsRowTarget = notice?.spans.shells ?? null;
|
|
3045
3055
|
jumpSpan = notice?.spans.jump ?? null;
|
|
3046
3056
|
return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
|
|
3047
3057
|
}
|
|
@@ -3111,7 +3121,7 @@ function paintBar() {
|
|
|
3111
3121
|
// THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
|
|
3112
3122
|
const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
|
|
3113
3123
|
jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
|
|
3114
|
-
updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || limitsRowTarget || logsRowTarget ? noticeAbs : 0;
|
|
3124
|
+
updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || limitsRowTarget || logsRowTarget || shellsRowTarget ? noticeAbs : 0;
|
|
3115
3125
|
// preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
|
|
3116
3126
|
// is parked on the input line right after, so there is nothing to preserve here.
|
|
3117
3127
|
ui(
|
|
@@ -3277,6 +3287,8 @@ let pricesRowTarget = null;
|
|
|
3277
3287
|
let limitsRowTarget = null;
|
|
3278
3288
|
// …and "clear logs · 175 MB" (#329), shown only once the logs are worth a row.
|
|
3279
3289
|
let logsRowTarget = null;
|
|
3290
|
+
// …and "1 shell running (click)" (#360) — a long command the agent left running in the background.
|
|
3291
|
+
let shellsRowTarget = null;
|
|
3280
3292
|
// A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
|
|
3281
3293
|
// bracketed-paste and mouse-reporting teardowns.
|
|
3282
3294
|
process.on("exit", () => {
|
|
@@ -9470,6 +9482,83 @@ async function chooseServer() {
|
|
|
9470
9482
|
*/
|
|
9471
9483
|
let otherAgents = [];
|
|
9472
9484
|
const OTHER_AGENTS_MS = 20000;
|
|
9485
|
+
|
|
9486
|
+
/**
|
|
9487
|
+
* BACKGROUND SHELLS (task #360, GitHub #140). The agent leaves a long command — a 7-minute build —
|
|
9488
|
+
* running instead of killing it at its deadline, and records it in ~/.gonext/shells.json. Read
|
|
9489
|
+
* here once a second (a local file, no API), so the notice row can say it is still running and
|
|
9490
|
+
* a click can show it. Repaints only when the row's text changes.
|
|
9491
|
+
*/
|
|
9492
|
+
let shellEntries = [];
|
|
9493
|
+
const shellsViewed = new Set();
|
|
9494
|
+
const SHELLS_SINCE = Date.now() / 1000; // a shell that ended before this terminal opened is old news
|
|
9495
|
+
const SHELLS_MS = 1000;
|
|
9496
|
+
function refreshShells() {
|
|
9497
|
+
const before = shellsNotice(shellEntries, { viewed: shellsViewed, since: SHELLS_SINCE })?.text ?? "";
|
|
9498
|
+
shellEntries = readShells().map((rec) => ({ rec, st: shellState(rec) }));
|
|
9499
|
+
const after = shellsNotice(shellEntries, { viewed: shellsViewed, since: SHELLS_SINCE })?.text ?? "";
|
|
9500
|
+
if (after !== before) paintBar();
|
|
9501
|
+
}
|
|
9502
|
+
|
|
9503
|
+
/** The Shell details block for one shell, printed into the transcript. */
|
|
9504
|
+
function printShellDetails(rec) {
|
|
9505
|
+
const width = Math.max(20, process.stdout.columns || 80);
|
|
9506
|
+
const st = shellState(rec);
|
|
9507
|
+
shellsViewed.add(String(rec.id));
|
|
9508
|
+
const rows = renderShellDetails({
|
|
9509
|
+
rec, st, output: logTail(rec.log), width: width - 1, gutter: GUTTER,
|
|
9510
|
+
style: { title: (t) => bold(accent(t)), label: bold, running: accent, ok: accent, bad: red, dim },
|
|
9511
|
+
});
|
|
9512
|
+
console.log("\n" + GUTTER + accent("─".repeat(Math.max(10, width - 1 - GUTTER.length))));
|
|
9513
|
+
console.log(rows.join("\n") + "\n");
|
|
9514
|
+
return st;
|
|
9515
|
+
}
|
|
9516
|
+
|
|
9517
|
+
/**
|
|
9518
|
+
* Click on the shell control, or /shells: pick a shell (when there is more than one), see its
|
|
9519
|
+
* details, then refresh / stop it / close. During a turn it only prints the details — a picker
|
|
9520
|
+
* would fight the turn for the keyboard, as showOtherAgents explains.
|
|
9521
|
+
*/
|
|
9522
|
+
async function showShells() {
|
|
9523
|
+
if (listPickerActive || approvalActive) return;
|
|
9524
|
+
const width = Math.max(20, process.stdout.columns || 80);
|
|
9525
|
+
refreshShells();
|
|
9526
|
+
const entries = [...shellEntries].reverse(); // newest first
|
|
9527
|
+
if (!entries.length) {
|
|
9528
|
+
console.log("\n" + GUTTER + dim("No background shells. A long command the agent runs moves here instead of being stopped.\n"));
|
|
9529
|
+
paintBar();
|
|
9530
|
+
return;
|
|
9531
|
+
}
|
|
9532
|
+
let pick = 0;
|
|
9533
|
+
if (entries.length > 1 && !following) {
|
|
9534
|
+
console.log("");
|
|
9535
|
+
console.log(dim(` Background shells — pick one (${PICK_HINT}):`));
|
|
9536
|
+
pick = await pickFromList(entries.map((e) => shellPickerLabel(e.rec, e.st, width)), 0);
|
|
9537
|
+
if (pick < 0) {
|
|
9538
|
+
console.log(dim(" (cancelled)\n"));
|
|
9539
|
+
paintBar();
|
|
9540
|
+
return;
|
|
9541
|
+
}
|
|
9542
|
+
}
|
|
9543
|
+
const rec = entries[pick].rec;
|
|
9544
|
+
for (;;) {
|
|
9545
|
+
const st = printShellDetails(rec);
|
|
9546
|
+
if (following) break;
|
|
9547
|
+
const actions = [{ id: "refresh", label: "Refresh" }];
|
|
9548
|
+
if (st.state === "running") actions.push({ id: "kill", label: "Stop it", note: "kills the command and everything it started" });
|
|
9549
|
+
actions.push({ id: "close", label: "Close" });
|
|
9550
|
+
const act = await pickFromList(actions.map((a) => a.label + (a.note ? dim(` · ${a.note}`) : "")), 0);
|
|
9551
|
+
const id = act < 0 ? "close" : actions[act].id;
|
|
9552
|
+
if (id === "refresh") continue;
|
|
9553
|
+
if (id === "kill") {
|
|
9554
|
+
await killShell(rec);
|
|
9555
|
+
console.log(GUTTER + accent("✓ stopped") + dim(" — the agent sees it as killed next turn.\n"));
|
|
9556
|
+
}
|
|
9557
|
+
break;
|
|
9558
|
+
}
|
|
9559
|
+
refreshShells();
|
|
9560
|
+
paintBar();
|
|
9561
|
+
}
|
|
9473
9562
|
async function refreshOtherAgents() {
|
|
9474
9563
|
if (!barPinned() || following) return;
|
|
9475
9564
|
try {
|
|
@@ -13719,6 +13808,9 @@ async function main() {
|
|
|
13719
13808
|
// a turn runs; unref'd so a pending timer is never the reason gu refuses to exit.
|
|
13720
13809
|
void refreshOtherAgents();
|
|
13721
13810
|
setInterval(() => void refreshOtherAgents(), OTHER_AGENTS_MS).unref();
|
|
13811
|
+
// …and the background shells (#360): a local file, so cheap enough to read every second.
|
|
13812
|
+
refreshShells();
|
|
13813
|
+
setInterval(refreshShells, SHELLS_MS).unref();
|
|
13722
13814
|
|
|
13723
13815
|
const cwd = resolve(process.cwd());
|
|
13724
13816
|
const history = await loadSession(cwd);
|
|
@@ -14035,6 +14127,10 @@ async function main() {
|
|
|
14035
14127
|
await runModelDoctor();
|
|
14036
14128
|
continue;
|
|
14037
14129
|
}
|
|
14130
|
+
if (line === "/shells") {
|
|
14131
|
+
await showShells();
|
|
14132
|
+
continue;
|
|
14133
|
+
}
|
|
14038
14134
|
if (line === "/model") {
|
|
14039
14135
|
await chooseModel();
|
|
14040
14136
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.826",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -124,6 +124,7 @@
|
|
|
124
124
|
"update-verify.mjs",
|
|
125
125
|
"skills.mjs",
|
|
126
126
|
"skill-source.mjs",
|
|
127
|
+
"shell-details.mjs",
|
|
127
128
|
"step-group.mjs",
|
|
128
129
|
"mcp-config.mjs",
|
|
129
130
|
"mcp-command.mjs",
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// BACKGROUND SHELLS IN THE TERMINAL (task #360, GitHub #140) — Claude Code's "Shell details".
|
|
2
|
+
//
|
|
3
|
+
// The agent hands a long command (a 7-minute `fastlane ios beta`) off to the background instead
|
|
4
|
+
// of killing it at its deadline, and records it in ~/.gonext/shells.json (gonext_agent_chat.py,
|
|
5
|
+
// BACKGROUND SHELLS). This module is the terminal's half: it reads that file, says on the bottom
|
|
6
|
+
// notice row that something is still running (a click target, like the other notice controls),
|
|
7
|
+
// and renders the details view — Status, Runtime, Command, Output — the way Claude Code does.
|
|
8
|
+
//
|
|
9
|
+
// Pure except for the small fs/process probes, which take their inputs as arguments where a test
|
|
10
|
+
// needs to fake them. The agent process is gone by the time a shell is looked at, so the state is
|
|
11
|
+
// read from the same two facts the agent uses: is the process group alive, and has the shell
|
|
12
|
+
// written its exit file.
|
|
13
|
+
|
|
14
|
+
import { existsSync, openSync, readSync, closeSync, fstatSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
export const shellsPath = (home = homedir()) => join(home, ".gonext", "shells.json");
|
|
20
|
+
|
|
21
|
+
export function readShells(path = shellsPath()) {
|
|
22
|
+
try {
|
|
23
|
+
const shells = JSON.parse(readFileSync(path, "utf8"))?.shells;
|
|
24
|
+
return Array.isArray(shells) ? shells.filter((s) => s && s.pid) : [];
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** {code, at} from the exit file, or null while the shell has not written one. */
|
|
31
|
+
export function readExit(rec) {
|
|
32
|
+
const path = rec?.exit_file;
|
|
33
|
+
if (!path) return null;
|
|
34
|
+
try {
|
|
35
|
+
const raw = readFileSync(path, "utf8").trim();
|
|
36
|
+
if (!/^-?\d+$/.test(raw)) return null;
|
|
37
|
+
return { code: Number(raw), at: statSync(path).mtimeMs / 1000 };
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Is the shell's process group (POSIX) or process (Windows) still there? */
|
|
44
|
+
export function isAlive(rec, platform = process.platform) {
|
|
45
|
+
const pid = Number(rec?.pgid || rec?.pid || 0);
|
|
46
|
+
if (!pid) return false;
|
|
47
|
+
try {
|
|
48
|
+
process.kill(platform === "win32" ? pid : -pid, 0);
|
|
49
|
+
return true;
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return e?.code === "EPERM"; // exists, just not ours to signal
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* {state, code, runtime, ended}. state: running | exited | failed | killed | ended (gone, exit
|
|
57
|
+
* code unknown). The same rules as the agent's _shell_state, so both sides tell the same story.
|
|
58
|
+
*/
|
|
59
|
+
export function shellState(rec, { alive, exit, now = Date.now() / 1000 } = {}) {
|
|
60
|
+
const started = rec?.started != null ? Number(rec.started) : now;
|
|
61
|
+
const ex = exit === undefined ? readExit(rec) : exit;
|
|
62
|
+
const live = alive === undefined ? !ex && isAlive(rec) : alive;
|
|
63
|
+
if (live && !ex && !rec?.killed) return { state: "running", code: null, runtime: Math.max(0, now - started), ended: now };
|
|
64
|
+
const ended = Number(rec?.ended) || ex?.at || now;
|
|
65
|
+
const state = rec?.killed ? "killed" : !ex ? "ended" : ex.code === 0 ? "exited" : "failed";
|
|
66
|
+
return { state, code: ex ? ex.code : null, runtime: Math.max(0, ended - started), ended };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 7m 2s — Claude Code's spelling. */
|
|
70
|
+
export function fmtRuntime(seconds) {
|
|
71
|
+
let s = Math.max(0, Math.floor(Number(seconds) || 0));
|
|
72
|
+
const h = Math.floor(s / 3600);
|
|
73
|
+
s -= h * 3600;
|
|
74
|
+
const m = Math.floor(s / 60);
|
|
75
|
+
s -= m * 60;
|
|
76
|
+
return h ? `${h}h ${m}m` : m ? `${m}m ${s}s` : `${s}s`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function statusText(st) {
|
|
80
|
+
if (st.state === "running") return "running";
|
|
81
|
+
if (st.state === "exited" || st.state === "failed") return `exited ${st.code}`;
|
|
82
|
+
return st.state === "killed" ? "killed" : "ended (exit code unknown)";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The bottom notice row's shell control, or null. Running shells first — that is the thing worth
|
|
87
|
+
* a row. Otherwise the newest one that finished while this terminal was open and that the user
|
|
88
|
+
* has not opened yet, so a build that ends between questions is not missed.
|
|
89
|
+
*/
|
|
90
|
+
export function shellsNotice(entries, { viewed = new Set(), since = 0 } = {}) {
|
|
91
|
+
const running = entries.filter((e) => e.st.state === "running");
|
|
92
|
+
if (running.length) {
|
|
93
|
+
const one = running.length === 1;
|
|
94
|
+
// No runtime here: it would change the row every second, and the bar repaints on change.
|
|
95
|
+
return { tone: "accent", text: `${running.length} shell${one ? "" : "s"} running (click)` };
|
|
96
|
+
}
|
|
97
|
+
const done = entries.filter((e) => !viewed.has(String(e.rec.id)) && e.st.ended >= since);
|
|
98
|
+
if (!done.length) return null;
|
|
99
|
+
const last = done[done.length - 1];
|
|
100
|
+
const ok = last.st.state === "exited";
|
|
101
|
+
return { tone: ok ? "accent" : "warn", text: `shell ${statusText(last.st)} (click)` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The last `lines` lines of a log, as they would read on a terminal: colour codes stripped, and a
|
|
108
|
+
* progress bar redrawn with \r collapsed to its final state (a build log is mostly those).
|
|
109
|
+
*/
|
|
110
|
+
export function logTail(path, { lines = 15, maxBytes = 64 * 1024 } = {}) {
|
|
111
|
+
let text = "";
|
|
112
|
+
try {
|
|
113
|
+
const fd = openSync(path, "r");
|
|
114
|
+
try {
|
|
115
|
+
const size = fstatSync(fd).size;
|
|
116
|
+
const len = Math.min(size, maxBytes);
|
|
117
|
+
const buf = Buffer.alloc(len);
|
|
118
|
+
readSync(fd, buf, 0, len, size - len);
|
|
119
|
+
text = buf.toString("utf8");
|
|
120
|
+
if (size > len) text = text.slice(text.indexOf("\n") + 1); // drop the cut first line
|
|
121
|
+
} finally {
|
|
122
|
+
closeSync(fd);
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const out = text
|
|
128
|
+
.replace(ANSI_RE, "")
|
|
129
|
+
.split("\n")
|
|
130
|
+
.map((l) => {
|
|
131
|
+
const parts = l.split("\r").filter((p) => p.length);
|
|
132
|
+
return (parts[parts.length - 1] ?? "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "").trimEnd();
|
|
133
|
+
});
|
|
134
|
+
while (out.length && !out[out.length - 1]) out.pop();
|
|
135
|
+
return out.slice(-lines);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Word-wrap `text` to `width`; at most `maxRows`, the last ending in "…" when cut. */
|
|
139
|
+
export function wrapClip(text, width, maxRows = 4) {
|
|
140
|
+
const w = Math.max(10, width);
|
|
141
|
+
const words = String(text ?? "").replace(/\s+/g, " ").trim().split(" ");
|
|
142
|
+
const rows = [];
|
|
143
|
+
let cur = "";
|
|
144
|
+
for (let word of words) {
|
|
145
|
+
while (word.length > w) {
|
|
146
|
+
if (cur) { rows.push(cur); cur = ""; }
|
|
147
|
+
rows.push(word.slice(0, w));
|
|
148
|
+
word = word.slice(w);
|
|
149
|
+
}
|
|
150
|
+
if (!cur) cur = word;
|
|
151
|
+
else if (cur.length + 1 + word.length <= w) cur += " " + word;
|
|
152
|
+
else { rows.push(cur); cur = word; }
|
|
153
|
+
}
|
|
154
|
+
if (cur) rows.push(cur);
|
|
155
|
+
if (rows.length <= maxRows) return rows;
|
|
156
|
+
const kept = rows.slice(0, maxRows);
|
|
157
|
+
const last = kept[maxRows - 1];
|
|
158
|
+
kept[maxRows - 1] = (last.length + 2 > w ? last.slice(0, w - 2) : last) + " …";
|
|
159
|
+
return kept;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const LABEL_W = 10; // "Command: " — the value column, as in Claude Code's view
|
|
163
|
+
const plain = (s) => s;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The Shell details block, one string per row, no row wider than `width`.
|
|
167
|
+
* `style`: {title, label, running, ok, bad, dim} paint functions (identity in tests).
|
|
168
|
+
*/
|
|
169
|
+
export function renderShellDetails({ rec, st, output = [], width = 80, gutter = " ", style = {} }) {
|
|
170
|
+
const p = { title: plain, label: plain, running: plain, ok: plain, bad: plain, dim: plain, ...style };
|
|
171
|
+
const inner = Math.max(20, width - gutter.length);
|
|
172
|
+
const valW = inner - LABEL_W;
|
|
173
|
+
const pad = " ".repeat(LABEL_W);
|
|
174
|
+
const row = (label, value) => gutter + p.label(label.padEnd(LABEL_W)) + value;
|
|
175
|
+
const tone = st.state === "running" ? p.running : st.state === "exited" ? p.ok : st.state === "failed" ? p.bad : p.dim;
|
|
176
|
+
const cmd = wrapClip(rec?.command ?? "", valW, 4);
|
|
177
|
+
const rows = [
|
|
178
|
+
gutter + p.title("Shell details"),
|
|
179
|
+
"",
|
|
180
|
+
row("Status:", tone(statusText(st))),
|
|
181
|
+
row("Runtime:", fmtRuntime(st.runtime)),
|
|
182
|
+
row("Command:", cmd[0] ?? ""),
|
|
183
|
+
...cmd.slice(1).map((c) => gutter + pad + c),
|
|
184
|
+
];
|
|
185
|
+
if (rec?.workdir) rows.push(row("Folder:", p.dim(wrapClip(rec.workdir, valW, 1)[0])));
|
|
186
|
+
rows.push("", gutter + p.label("Output:"));
|
|
187
|
+
if (!output.length) rows.push(gutter + p.dim("No output available"));
|
|
188
|
+
else for (const l of output) rows.push(gutter + (l.length > inner ? l.slice(0, inner - 1) + "…" : l));
|
|
189
|
+
return rows;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A picker row for one shell: status · runtime · command, clipped to `width`. */
|
|
193
|
+
export function shellPickerLabel(rec, st, width = 80) {
|
|
194
|
+
const head = `${statusText(st)} · ${fmtRuntime(st.runtime)} · `;
|
|
195
|
+
const room = Math.max(10, width - head.length - 8);
|
|
196
|
+
const cmd = String(rec?.command ?? "").replace(/\s+/g, " ");
|
|
197
|
+
return head + (cmd.length > room ? cmd.slice(0, room - 1) + "…" : cmd);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Stop a shell's whole group and record that it was killed — the record is what the agent reads
|
|
202
|
+
* next turn, so "killed" must not later read as "ended (exit code unknown)".
|
|
203
|
+
*/
|
|
204
|
+
export async function killShell(rec, { path = shellsPath(), platform = process.platform } = {}) {
|
|
205
|
+
const pid = Number(rec?.pgid || rec?.pid || 0);
|
|
206
|
+
if (pid && isAlive(rec, platform)) {
|
|
207
|
+
if (platform === "win32") {
|
|
208
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
|
|
209
|
+
} else {
|
|
210
|
+
try { process.kill(-pid, "SIGTERM"); } catch { /* gone already */ }
|
|
211
|
+
for (let i = 0; i < 10 && isAlive(rec, platform); i++) await new Promise((r) => setTimeout(r, 100));
|
|
212
|
+
if (isAlive(rec, platform)) try { process.kill(-pid, "SIGKILL"); } catch { /* gone */ }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (!existsSync(path)) return;
|
|
216
|
+
const shells = readShells(path);
|
|
217
|
+
const mine = shells.find((s) => String(s.id) === String(rec.id));
|
|
218
|
+
if (!mine || readExit(mine)) return; // it finished on its own first: keep the real exit code
|
|
219
|
+
mine.killed = true;
|
|
220
|
+
mine.ended = Date.now() / 1000;
|
|
221
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
222
|
+
writeFileSync(tmp, JSON.stringify({ shells }, null, 2));
|
|
223
|
+
renameSync(tmp, path);
|
|
224
|
+
}
|