@tiens.nguyen/gu-cli 1.0.825 → 1.0.827

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.
@@ -7426,7 +7426,7 @@ def _shell_syntax_error(command, argv):
7426
7426
  # same checks an argv command gets (privilege, deny/allow lists, delete outside the project), plus
7427
7427
  # the two things only a shell can do: write outside the project with a redirect, and pipe text into
7428
7428
  # an interpreter. A command with no shell syntax still runs as argv, exactly as before.
7429
- # 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.
7430
7430
  _SHELL_OPS = ("&&", "||", ";;", "|&", "<<<", "<<-", "<<", "&>>", "&>", ">>", ">&", "<&", "<>",
7431
7431
  ">|", ";", "|", "&", "(", ")", ">", "<")
7432
7432
  _SHELL_SEPARATORS = {"&&", "||", ";", ";;", "|", "|&", "&", "\n", "(", ")"}
@@ -7434,7 +7434,7 @@ _SHELL_WRITE_REDIRECTS = {">", ">>", ">|", "&>", "&>>", "<>"}
7434
7434
  # Runners that execute TEXT as code. Piping into one with no script argument (`curl … | sh`) is the
7435
7435
  # shape a prompt injection takes, so it is blocked when nobody can be asked.
7436
7436
  _SHELL_INTERPRETERS = {"sh", "bash", "zsh", "dash", "ksh", "fish", "python", "python3", "node",
7437
- "perl", "ruby", "php", "pwsh", "powershell"}
7437
+ "perl", "ruby", "php", "pwsh", "powershell", "py", "pythonw", "cmd"}
7438
7438
  _SHELL_EVAL_RUNNERS = {"eval", "source", "."}
7439
7439
  _SHELL_HARMLESS_TARGETS = {"/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty"}
7440
7440
 
@@ -7713,7 +7713,8 @@ def _shell_background_error(scan):
7713
7713
  if any(t[0] == "op" and t[1] == "&" for t in scan["tokens"]):
7714
7714
  return ("Error: that command did NOT run. Do not use `&` to background it — run_command "
7715
7715
  "already leaves a server or app RUNNING when it starts listening or opens a window, "
7716
- "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 `&`.")
7717
7718
  return None
7718
7719
 
7719
7720
 
@@ -7728,6 +7729,28 @@ def _shell_priv_reason(scan):
7728
7729
  return None
7729
7730
 
7730
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
+
7731
7754
  def _ws_write_outside_reason(redirects, wd, roots=None):
7732
7755
  """Why a redirect needs the user's yes: it writes a file OUTSIDE every workspace and the temp
7733
7756
  folder. The shell's counterpart of _ws_delete_outside_reason."""
@@ -7743,7 +7766,7 @@ def _ws_write_outside_reason(redirects, wd, roots=None):
7743
7766
  temp = [os.path.realpath(p) for p in {tempfile.gettempdir(), "/tmp", "/var/tmp"} if p]
7744
7767
  outside = []
7745
7768
  for t in targets:
7746
- p = os.path.expanduser(t)
7769
+ p = os.path.expanduser(_shell_path_to_native(t))
7747
7770
  p = os.path.realpath(p if os.path.isabs(p) else os.path.join(wd or os.getcwd(), p))
7748
7771
  if not (_ws_is_inside(p, ws) or _ws_is_inside(p, temp)):
7749
7772
  outside.append(t)
@@ -7779,7 +7802,8 @@ def _ws_shell_command_risk(scan, denyset, wd="", allowset=None, roots=None):
7779
7802
  for c in top:
7780
7803
  a = c["argv"]
7781
7804
  if a and a[0] == "cd" and len(a) >= 2 and c["sep"] not in ("|", "|&"):
7782
- 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]))))
7783
7807
  continue
7784
7808
  why = (_ws_delete_outside_reason(a, cur, roots)
7785
7809
  or _ws_write_outside_reason(c["redirects"], cur, roots))
@@ -7791,7 +7815,7 @@ def _ws_shell_command_risk(scan, denyset, wd="", allowset=None, roots=None):
7791
7815
  return (None, why, why)
7792
7816
  for c in top:
7793
7817
  a = c["argv"]
7794
- r = os.path.basename(a[0]).lower() if a else ""
7818
+ r = _shell_prog_name(a[0]) if a else ""
7795
7819
  if c["sep"] in ("|", "|&") and r in _SHELL_INTERPRETERS \
7796
7820
  and all(x.startswith("-") for x in a[1:]):
7797
7821
  msg = f"it pipes text into {r}, which runs that text as code"
@@ -7838,22 +7862,59 @@ def _with_attribution_text(command, scan, settings):
7838
7862
  _RUN_SHELL_BIN = None
7839
7863
 
7840
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
+
7841
7894
  def _run_shell_bin():
7842
- """bash (else sh) for run_command's shell lines, or "" for the argv-only path: Windows, or
7843
- 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)."""
7844
7898
  global _RUN_SHELL_BIN
7845
- if os.name == "nt" or os.environ.get("GU_RUN_NO_SHELL") == "1":
7899
+ if os.environ.get("GU_RUN_NO_SHELL") == "1":
7846
7900
  return ""
7847
7901
  if _RUN_SHELL_BIN is None:
7848
- import shutil as _sh
7849
- _RUN_SHELL_BIN = next((p for p in (_sh.which("bash"), "/bin/bash", "/bin/sh")
7850
- 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)), "")
7851
7910
  return _RUN_SHELL_BIN
7852
7911
 
7853
7912
 
7854
- def _run_command_prompt_line(shell):
7913
+ def _run_command_prompt_line(shell, windows=None):
7855
7914
  """The system prompt's run_command rule. With a shell it carries the git and GitHub recipes,
7856
- 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
7857
7918
  if not shell:
7858
7919
  return (
7859
7920
  " run_command CHAINS: put dependent commands in ONE call with && — e.g. "
@@ -7862,12 +7923,16 @@ def _run_command_prompt_line(shell):
7862
7923
  "chain. There is NO shell otherwise: pipes `|`, redirects `>`, `;`, `||` and backticks do "
7863
7924
  "NOT work (use python3 for anything that composes). `sudo`/`su` are blocked; an "
7864
7925
  "interactive input() CLI fails with EOFError. A server or GUI app is left RUNNING — "
7865
- "stop_server closes it.\n")
7926
+ "stop_server closes it. " + _BG_LINE)
7866
7927
  return (
7867
7928
  " run_command IS A SHELL (bash): pipes, redirects, `;`, `||`, `$( )`, globs and heredocs "
7868
7929
  "work, so put a whole sequence in ONE call — dependent steps with &&, independent ones with "
7869
7930
  "`;` (e.g. `cd app && npm test 2>&1 | tail -40`); a multi-line command goes in a "
7870
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 "") +
7871
7936
  " GIT in one call each: look (`git status -sb; git diff --stat; git log --oneline -5`), "
7872
7937
  "commit (`git add a.py b.py && git commit -m \"msg\" && git log --oneline -1`).\n"
7873
7938
  " GITHUB: use `gh`. Multi-line text goes as a heredoc on stdin — "
@@ -7876,7 +7941,13 @@ def _run_command_prompt_line(shell):
7876
7941
  " Read and search FILES with read_file_lines / grep_repo, not cat/grep/sed. `sudo`/`su` "
7877
7942
  "are blocked; piping into sh/python and writing or deleting outside the project ask the "
7878
7943
  "user first; an interactive input() CLI fails with EOFError. A server or GUI app is left "
7879
- "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")
7880
7951
 
7881
7952
 
7882
7953
  _RUN_DOC_ARGV = ("No other shell syntax (no pipes/redirects/;). sudo/su are blocked.")
@@ -8214,7 +8285,7 @@ def _ws_delete_outside_reason(argv, wd, roots=None):
8214
8285
  temp = [os.path.realpath(p) for p in {tempfile.gettempdir(), "/tmp", "/var/tmp"} if p]
8215
8286
  outside = []
8216
8287
  for t in targets:
8217
- p = os.path.expanduser(t)
8288
+ p = os.path.expanduser(_shell_path_to_native(t))
8218
8289
  p = os.path.realpath(p if os.path.isabs(p) else os.path.join(wd or os.getcwd(), p))
8219
8290
  if not (_ws_is_inside(p, ws) or _ws_is_inside(p, temp)):
8220
8291
  outside.append(t)
@@ -8754,6 +8825,153 @@ def _ws_cap_servers(max_live: int = 3) -> list:
8754
8825
  return servers
8755
8826
 
8756
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
+
8757
8975
  def _ws_scrubbed_env() -> dict:
8758
8976
  """Minimal child env for run_command. The worker process env holds secrets
8759
8977
  (GONEXT_WORKER_KEY etc. via worker.env) — a repo's test script must NEVER see
@@ -8764,6 +8982,13 @@ def _ws_scrubbed_env() -> dict:
8764
8982
  # SSH agent socket so ssh/scp/rsync can authenticate with the user's KEY
8765
8983
  # (no password in the model's context) for deploys (task #57).
8766
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")
8767
8992
  env = {k: os.environ[k] for k in keep if k in os.environ}
8768
8993
  # CI=1 makes watch-mode test runners (CRA's `npm test`, jest --watch) run ONCE and
8769
8994
  # exit instead of sitting in interactive watch mode until the timeout kills them.
@@ -10060,6 +10285,10 @@ def run_agent_chat(cfg):
10060
10285
  "are TAKEN, so never start a duplicate on one of them.\n")
10061
10286
  except Exception as _e: # noqa: BLE001
10062
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}")
10063
10292
  _ws_context_block = (
10064
10293
  _ws_current_line
10065
10294
  + f" Other registered workspaces (use only if the user names them): {_ws_names}\n"
@@ -14070,13 +14299,15 @@ def run_agent_chat(cfg):
14070
14299
  _last_obs["text"] = msg
14071
14300
  return msg
14072
14301
 
14073
- 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:
14074
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.
14075
14305
 
14076
14306
  Args:
14077
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.
14078
14308
  workdir: directory to run in. Empty = first registered workspace.
14079
- 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=…).
14080
14311
  """
14081
14312
  proc = None # read by the `finally` below, which runs even if Popen never did
14082
14313
  try:
@@ -14177,7 +14408,7 @@ def run_agent_chat(cfg):
14177
14408
  break
14178
14409
  _res = str(run_command(_part, _cur_wd, int(min(timeout_seconds or 180, 600, _left))))
14179
14410
  _outs.append(f"{_hdr}\n{_res}")
14180
- if _res.startswith(("FAILED", "Error")):
14411
+ if _res.startswith(("FAILED", "Error", "BACKGROUND")):
14181
14412
  _stopped = _part
14182
14413
  break
14183
14414
  _rest = _chain[len(_outs):]
@@ -14296,12 +14527,18 @@ def run_agent_chat(cfg):
14296
14527
  os.makedirs(logs_dir, exist_ok=True)
14297
14528
  log_path = os.path.join(logs_dir, f"{_t.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}.log")
14298
14529
  log_fh = open(log_path, "wb")
14530
+ exit_path = log_path[:-4] + ".exit"
14299
14531
  # gu as a co-author on the commits it makes (task #289) — after every check
14300
14532
  # above, which judged the command as the model wrote it.
14301
14533
  if _use_shell:
14302
14534
  _cmd_text, _attrib_note = _with_attribution_text(
14303
14535
  command, _scan, _attribution_settings(wd))
14304
- 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)]
14305
14542
  else:
14306
14543
  argv, _attrib_note = _with_attribution(argv, _attribution_settings(wd))
14307
14544
  if _attrib_note:
@@ -14318,6 +14555,25 @@ def run_agent_chat(cfg):
14318
14555
  _INFLIGHT_CMDS.add(proc.pid) # killed with the agent until it is classified
14319
14556
  start = _t.time()
14320
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
+
14321
14577
  def _read_log() -> str:
14322
14578
  log_fh.flush()
14323
14579
  try:
@@ -14413,17 +14669,20 @@ def run_agent_chat(cfg):
14413
14669
  + (f"\nStartup output:\n{tail}" if tail.strip() else ""))
14414
14670
  _last_obs["text"] = result
14415
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.
14416
14681
  if _t.time() - start >= t:
14417
- try:
14418
- _ws_kill_tree(proc.pid, force=True)
14419
- except OSError:
14420
- pass
14421
- tail = _ws_distill_output(_read_log())
14422
- msg = (f"Error: command neither exited nor started a server within "
14423
- f"{t}s — killed. Output so far:\n{tail}")
14682
+ msg = _to_background(f"still running after {t}s")
14424
14683
  _last_obs["text"] = msg
14425
14684
  return msg
14426
- _t.sleep(2)
14685
+ _t.sleep(1 if background else 2)
14427
14686
  except Exception as ex: # noqa: BLE001
14428
14687
  msg = f"Error: run_command failed: {type(ex).__name__}: {ex}"
14429
14688
  _last_obs["text"] = msg
@@ -14439,22 +14698,44 @@ def run_agent_chat(cfg):
14439
14698
  pass
14440
14699
 
14441
14700
  # The description says what the command line may contain, which depends on the machine:
14442
- # 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.
14443
14703
  if _run_shell_bin():
14444
14704
  run_command.__doc__ = run_command.__doc__.replace(_RUN_DOC_ARGV, _RUN_DOC_SHELL)
14445
14705
  run_command = tool(run_command)
14446
14706
 
14447
14707
  @tool
14448
14708
  def stop_server(port_or_pid: str = "") -> str:
14449
- """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.
14450
14710
 
14451
14711
  Args:
14452
- 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).
14453
14713
  """
14454
14714
  try:
14455
14715
  import os
14456
14716
  import signal
14457
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
14458
14739
  servers = _ws_load_servers()
14459
14740
  if not servers:
14460
14741
  msg = "No background servers are running."
@@ -14493,6 +14774,60 @@ def run_agent_chat(cfg):
14493
14774
  _last_obs["text"] = msg
14494
14775
  return msg
14495
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
+
14496
14831
  @tool
14497
14832
  def deploy_web(local_dir: str, host: str, user: str, remote_path: str,
14498
14833
  domain: str = "") -> str:
@@ -14636,7 +14971,7 @@ def run_agent_chat(cfg):
14636
14971
  # tools below it is always available wherever a workspace is (task #221).
14637
14972
  agent_tools += [symbol_context, grep_repo, read_file_lines, edit_lines, edit_file,
14638
14973
  create_file, create_folder, run_command, stop_server,
14639
- deploy_web]
14974
+ shell_output, deploy_web]
14640
14975
  # Workspace RAG (task #133): semantic search over the PROJECT itself, not just a
14641
14976
  # ZIP-at-URL. Needs both a workspace to index and a working embedder, so it's
14642
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.825",
3
+ "version": "1.0.827",
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
+ }