@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.
@@ -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 two signals used."""
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 keeps the argv path (no bash to rely on there yet).
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 without the `&`.")
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(), os.path.expanduser(a[1])))
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 = os.path.basename(a[0]).lower() if a else ""
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
- """bash (else sh) for run_command's shell lines, or "" for the argv-only path: Windows, or
7813
- GU_RUN_NO_SHELL=1 (the way back if a shell line ever misbehaves for a user)."""
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.name == "nt" or os.environ.get("GU_RUN_NO_SHELL") == "1":
7899
+ if os.environ.get("GU_RUN_NO_SHELL") == "1":
7816
7900
  return ""
7817
7901
  if _RUN_SHELL_BIN is None:
7818
- import shutil as _sh
7819
- _RUN_SHELL_BIN = next((p for p in (_sh.which("bash"), "/bin/bash", "/bin/sh")
7820
- if p and os.path.exists(p)), "")
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.\n")
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.\n")
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.\n\n"
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) -> str:
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: give up after this many seconds if the command neither exits nor starts a server (max 600).
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
- argv = [_shell_bin, "-c", _cmd_text]
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
- try:
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, argv only on Windows (#359). Set before tool() reads the docstring.
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 OR desktop app previously started by run_command (closes its window). Use when the user asks to stop/kill/close it.
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 one).
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.824",
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
+ }