@tiens.nguyen/gu-cli 1.0.829 → 1.0.830

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.
Files changed (2) hide show
  1. package/gonext_agent_chat.py +171 -17
  2. package/package.json +1 -1
@@ -5109,6 +5109,46 @@ def _repair_unterminated_string(code: str) -> str:
5109
5109
  return code
5110
5110
 
5111
5111
 
5112
+ # A COMMIT MESSAGE IN A SHELL LINE IN A PYTHON STRING (#360 follow-up). The model passes run_command
5113
+ # a whole `git commit -F - <<'EOF' … EOF` (or -m "…") as ONE normal "…" string. The message has
5114
+ # line breaks AND, often, the string's own quote ('"node /opt/…/gu"', "turn's") — so the literal
5115
+ # ends early and escaping newlines cannot help. Measured 2026-10-14: 2 of the last 2 commit turns
5116
+ # lost a step to exactly this. The command argument runs from the call's opening quote to the
5117
+ # LAST matching quote that is followed by the next argument or the closing paren; re-quoted as a
5118
+ # triple-quoted string it is the text the model meant. Kept only when the block then compiles.
5119
+ _RUN_CMD_OPEN_RE = re.compile(r"\brun_command\(\s*(?:command\s*=\s*)?(['\"])(?!\1\1)")
5120
+
5121
+
5122
+ def _repair_run_command_string(code: str) -> str:
5123
+ try:
5124
+ compile(code, "<gu-code>", "exec")
5125
+ return code
5126
+ except SyntaxError:
5127
+ pass
5128
+ except Exception: # noqa: BLE001
5129
+ return code
5130
+ m = _RUN_CMD_OPEN_RE.search(code)
5131
+ if not m:
5132
+ return code
5133
+ q, start = m.group(1), m.end()
5134
+ closer = re.compile(re.escape(q) + r"(?=\s*(?:,\s*(?:workdir|timeout_seconds|background)\s*=|,\s*['\"]|\)))")
5135
+ next_arg = re.compile(re.escape(q) + r"\s*,\s*(?:workdir|timeout_seconds|background)\s*=")
5136
+ for end in reversed([x.start() for x in closer.finditer(code, start)]):
5137
+ body = code[start:end]
5138
+ if next_arg.search(body):
5139
+ continue # it swallowed the next argument — the command ends earlier
5140
+ for wrap in ('"""', "'''"):
5141
+ if wrap in body or body.endswith(wrap[0]):
5142
+ continue
5143
+ fixed = code[:m.start(1)] + wrap + body + wrap + code[end + 1:]
5144
+ try:
5145
+ compile(fixed, "<gu-code>", "exec")
5146
+ return fixed
5147
+ except SyntaxError:
5148
+ continue
5149
+ return code
5150
+
5151
+
5112
5152
  def _repair_code_block_strings(text: str) -> str:
5113
5153
  """Apply _repair_unterminated_string to the python inside a <code>…</code> block
5114
5154
  (leaving the surrounding Thought prose untouched). No-op when there's no block or the
@@ -5134,6 +5174,8 @@ def _repair_code_block_strings(text: str) -> str:
5134
5174
  close_start = open_end + cm.start()
5135
5175
  inner = text[open_end:close_start]
5136
5176
  fixed = _repair_unterminated_string(inner)
5177
+ if fixed == inner:
5178
+ fixed = _repair_run_command_string(inner)
5137
5179
  if fixed != inner:
5138
5180
  return text[:open_end] + fixed + text[close_start:]
5139
5181
  # Stage 2 — only worth trying when there's MORE than one </code> (extra tags in content).
@@ -7950,6 +7992,18 @@ _BG_LINE = ("A LONG job (build, deploy, big test run) → run_command(…, backg
7950
7992
  "timeout moves to the background by itself instead of being killed.\n")
7951
7993
 
7952
7994
 
7995
+ # What to do instead, said where quoting broke — in gu's own pre-check (an unbalanced quote never
7996
+ # reaches bash) and in bash's error. In a commit turn the next step was otherwise a guess.
7997
+ _HEREDOC_HINT = ("\n\nSHELL QUOTING broke this line. For text with quotes or line breaks (a commit "
7998
+ "message, an issue body) use a heredoc inside a triple-quoted string: "
7999
+ "run_command(\"\"\"git commit -F - <<'EOF'\n<message>\nEOF\"\"\") — nothing in the "
8000
+ "message needs escaping.")
8001
+
8002
+ # bash's words for a line whose quotes do not pair up.
8003
+ _SHELL_QUOTE_ERR_RE = re.compile(
8004
+ r"unexpected EOF while looking for matching|syntax error near unexpected token|"
8005
+ r"syntax error: unexpected end of file|unterminated quoted string")
8006
+
7953
8007
  _RUN_DOC_ARGV = ("No other shell syntax (no pipes/redirects/;). sudo/su are blocked.")
7954
8008
  _RUN_DOC_SHELL = ("It runs in bash, so pipes, redirects, ;, ||, $( ), globs and heredocs work too "
7955
8009
  "(e.g. 'npm test 2>&1 | tail -40'). sudo/su are blocked.")
@@ -8941,6 +8995,70 @@ def _shell_find(shells: list, key: str):
8941
8995
  return None
8942
8996
 
8943
8997
 
8998
+ _EPHEMERAL_FLOOR = None
8999
+
9000
+
9001
+ def _ws_ephemeral_floor() -> int:
9002
+ """First port of the OS's ephemeral range — where a bind to port 0 lands. Linux reads
9003
+ ip_local_port_range (32768 by default), macOS its sysctl (49152); Windows and anything
9004
+ unreadable use the IANA 49152."""
9005
+ global _EPHEMERAL_FLOOR
9006
+ if _EPHEMERAL_FLOOR is None:
9007
+ floor = 49152
9008
+ try:
9009
+ if sys.platform.startswith("linux"):
9010
+ with open("/proc/sys/net/ipv4/ip_local_port_range", encoding="utf-8") as fh:
9011
+ floor = int(fh.read().split()[0])
9012
+ elif sys.platform == "darwin":
9013
+ import subprocess
9014
+ out = subprocess.run(["sysctl", "-n", "net.inet.ip.portrange.first"],
9015
+ capture_output=True, text=True, timeout=3).stdout.strip()
9016
+ floor = int(out) if out.isdigit() else floor
9017
+ except Exception: # noqa: BLE001
9018
+ pass
9019
+ _EPHEMERAL_FLOOR = max(1024, floor)
9020
+ return _EPHEMERAL_FLOOR
9021
+
9022
+
9023
+ def _ws_is_ephemeral_port(port) -> bool:
9024
+ try:
9025
+ return int(port) >= _ws_ephemeral_floor()
9026
+ except (TypeError, ValueError):
9027
+ return False
9028
+
9029
+
9030
+ def _server_as_shell(srv: dict) -> dict:
9031
+ """A left-running server/app in the shape shell_output reads, so a command that was filed as
9032
+ a server (wrongly, or not) can still be waited on and read (#360)."""
9033
+ import time as _t
9034
+ started = srv.get("started")
9035
+ if started is None:
9036
+ try:
9037
+ started = _t.mktime(_t.strptime(str(srv.get("started_at")), "%Y-%m-%d %H:%M:%S"))
9038
+ except (ValueError, OverflowError):
9039
+ started = _t.time()
9040
+ return {"id": str(srv.get("pid")), "pid": srv.get("pid"), "pgid": srv.get("pgid") or srv.get("pid"),
9041
+ "command": srv.get("command"), "workdir": srv.get("workdir"), "log": srv.get("log"),
9042
+ "exit_file": "", "started": started, "read_offset": srv.get("read_offset", 0),
9043
+ "server": True}
9044
+
9045
+
9046
+ def _shell_or_server(shells: list, servers: list, key: str):
9047
+ """shell_output's target: a shell by id, else a server by pid or port; with no key, the newest
9048
+ of both — the model asking about "the" background job means the one it just started."""
9049
+ key = (key or "").strip().lstrip("#")
9050
+ if key:
9051
+ sh = _shell_find(shells, key)
9052
+ if sh is not None:
9053
+ return sh
9054
+ for srv in reversed(servers):
9055
+ if key == str(srv.get("pid")) or any(key == str(p) for p in srv.get("ports") or []):
9056
+ return _server_as_shell(srv)
9057
+ return None
9058
+ cands = list(shells) + [_server_as_shell(x) for x in servers]
9059
+ return max(cands, key=lambda x: float(x.get("started") or 0)) if cands else None
9060
+
9061
+
8944
9062
  def _shell_kill(s: dict) -> None:
8945
9063
  """Stop a shell's whole process group and mark it killed (unless it already finished)."""
8946
9064
  import time as _t
@@ -9020,8 +9138,19 @@ def _range_is_covered(served, start: int, end: int):
9020
9138
  return None
9021
9139
 
9022
9140
 
9023
- def _ws_distill_output(out: str, cap_head: int = 1200, cap_tail: int = 4000) -> str:
9024
- """Cap huge build/test output: failures live at the tail, context at the head."""
9141
+ _DISTILL_FLOOR = 5200 # 1200 head + 4000 tail — what every window got before #360
9142
+
9143
+
9144
+ def _ws_distill_output(out: str, cap_head: int = None, cap_tail: int = None) -> str:
9145
+ """Cap huge build/test output: failures live at the tail, context at the head.
9146
+
9147
+ THE CAP SCALES WITH THE WINDOW, like a file read (#360 follow-up, issue #49). A fixed 5,200
9148
+ chars cut a `git diff` in a commit turn and the model spent a step re-reading it with sed;
9149
+ a command's output is a read too. Never below the old size, so a small window is unchanged."""
9150
+ if cap_head is None or cap_tail is None:
9151
+ total = max(_DISTILL_FLOOR, int(_OBS_CAPS.get("read") or 0))
9152
+ cap_head = cap_head if cap_head is not None else int(total * 0.23)
9153
+ cap_tail = cap_tail if cap_tail is not None else total - cap_head
9025
9154
  if len(out) <= cap_head + cap_tail + 200:
9026
9155
  return out
9027
9156
  return (out[:cap_head] + f"\n…[{len(out) - cap_head - cap_tail} chars omitted]…\n"
@@ -14356,7 +14485,8 @@ def run_agent_chat(cfg):
14356
14485
  _scan = _shell_scan(command) if _shell_bin else None
14357
14486
  if _scan and _scan["error"]:
14358
14487
  msg = (f"Error: that command did NOT run — {_scan['error']}. Nothing was "
14359
- "executed; fix the quoting and send it again.")
14488
+ "executed; fix the quoting and send it again."
14489
+ + (_HEREDOC_HINT if "quote" in str(_scan["error"]) else ""))
14360
14490
  _last_obs["text"] = msg
14361
14491
  return msg
14362
14492
  _use_shell = bool(_scan) and _shell_needed(_scan)
@@ -14620,6 +14750,12 @@ def run_agent_chat(cfg):
14620
14750
  # denied" is the part the reader already has; who we were and what we
14621
14751
  # were allowed to be is the part they need, and without it both the
14622
14752
  # model and the user are guessing at the next step.
14753
+ # A QUOTING ERROR NAMES THE FORM THAT WORKS (#360 follow-up). The model's
14754
+ # `git commit -m '…turn'\''s…'` lost its backslash to Python and bash
14755
+ # stopped on the quote; the next step was spent guessing. A heredoc in a
14756
+ # triple-quoted string has no quoting to get wrong.
14757
+ if rc != 0 and _use_shell and _SHELL_QUOTE_ERR_RE.search(out or ""):
14758
+ result += _HEREDOC_HINT
14623
14759
  if rc != 0 and _WS_PERM_ERR_RE.search(out or ""):
14624
14760
  _f = _ws_access_facts()
14625
14761
  result = f"{result}\n\n{_ws_access_report(_f, _ws_paths_in(command))}"
@@ -14629,10 +14765,16 @@ def run_agent_chat(cfg):
14629
14765
  return result
14630
14766
  if _t.time() - start >= 3:
14631
14767
  ports = _ws_listening_ports(proc.pid)
14768
+ # A port the OS PICKED (bind to 0) is a test run's own helper — the UI
14769
+ # suite's fake API on :52192 was filed as a "server", so the model could
14770
+ # not wait for the run and spent 3 steps polling it by hand (#360). Keep
14771
+ # waiting for it like any command; it hands off at the deadline if needed.
14772
+ if ports and all(_ws_is_ephemeral_port(p_) for p_ in ports):
14773
+ ports = []
14632
14774
  if ports:
14633
14775
  servers = _ws_load_servers()
14634
14776
  servers.append({
14635
- "pgid": proc.pid, "pid": proc.pid, "command": command,
14777
+ "pgid": proc.pid, "pid": proc.pid, "command": command, "started": start,
14636
14778
  "workdir": wd, "ports": ports, "log": log_path,
14637
14779
  "started_at": _t.strftime("%Y-%m-%d %H:%M:%S"),
14638
14780
  })
@@ -14642,7 +14784,9 @@ def run_agent_chat(cfg):
14642
14784
  tail = _ws_distill_output(_read_log())
14643
14785
  result = (f"RUNNING: '{command}' is up and listening — {urls} "
14644
14786
  f"(pid {proc.pid}, log {log_path}). It stays running in "
14645
- "the background; use stop_server to stop it. "
14787
+ "the background; use stop_server to stop it. If it is "
14788
+ "NOT meant to stay up (a test run that opened a port), "
14789
+ f"shell_output('{proc.pid}', wait_seconds=…) waits for it. "
14646
14790
  f"Startup output:\n{tail}")
14647
14791
  _last_obs["text"] = result
14648
14792
  return result
@@ -14654,7 +14798,7 @@ def run_agent_chat(cfg):
14654
14798
  if _ws_gui_pids(proc.pid):
14655
14799
  servers = _ws_load_servers()
14656
14800
  servers.append({
14657
- "pgid": proc.pid, "pid": proc.pid, "command": command,
14801
+ "pgid": proc.pid, "pid": proc.pid, "command": command, "started": start,
14658
14802
  "workdir": wd, "ports": [], "kind": "gui", "log": log_path,
14659
14803
  "started_at": _t.strftime("%Y-%m-%d %H:%M:%S"),
14660
14804
  })
@@ -14785,11 +14929,13 @@ def run_agent_chat(cfg):
14785
14929
  try:
14786
14930
  import time as _t
14787
14931
  shells = _shells_load()
14788
- sh = _shell_find(shells, shell_id)
14932
+ servers = _ws_load_servers()
14933
+ sh = _shell_or_server(shells, servers, shell_id)
14789
14934
  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."))
14935
+ ids = [str(x.get("id")) for x in shells] + [str(x.get("pid")) for x in servers]
14936
+ msg = ("No background shell or server matches '" + (shell_id or "") + "'. "
14937
+ + ("Running: " + ", ".join(ids) if ids
14938
+ else "Nothing was left running in the background."))
14793
14939
  _last_obs["text"] = msg
14794
14940
  return msg
14795
14941
  deadline = _t.time() + max(0, min(int(wait_seconds or 0), 540))
@@ -14804,14 +14950,22 @@ def run_agent_chat(cfg):
14804
14950
  except OSError:
14805
14951
  chunk = b""
14806
14952
  # 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)
14953
+ if sh.get("server"):
14954
+ servers = _ws_load_servers(prune=False)
14955
+ for x in servers:
14956
+ if str(x.get("pid")) == str(sh.get("id")):
14957
+ x["read_offset"] = int(sh.get("read_offset") or 0) + len(chunk)
14958
+ _ws_save_servers(servers)
14959
+ else:
14960
+ shells = _shells_load()
14961
+ cur = _shell_find(shells, str(sh.get("id"))) or sh
14962
+ cur["read_offset"] = int(sh.get("read_offset") or 0) + len(chunk)
14963
+ if st["state"] != "running":
14964
+ cur["seen"] = True
14965
+ _shells_save(shells)
14813
14966
  new = _ws_distill_output(chunk.decode("utf-8", errors="replace"))
14814
- head = (f"shell {sh.get('id')}: {_shell_status_text(st).upper()} after "
14967
+ head = (f"{'server' if sh.get('server') else 'shell'} {sh.get('id')}: "
14968
+ f"{_shell_status_text(st).upper()} after "
14815
14969
  f"{_fmt_runtime(st['runtime'])} — '{_clip(str(sh.get('command')), 200)}' "
14816
14970
  f"(log {sh.get('log')})")
14817
14971
  if st["state"] == "exited":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.829",
3
+ "version": "1.0.830",
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",