@tiens.nguyen/gu-cli 1.0.829 → 1.0.831
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gonext_agent_chat.py +288 -17
- package/package.json +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -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,123 @@ _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
|
+
# A PIPE HIDES THE EXIT CODE OF EVERYTHING BEFORE IT (#360 review, turn 3c06654e). The model ran
|
|
8003
|
+
# `python3 -m pytest … | tail -5 && git add … && git commit …`; pytest was not installed, `tail`
|
|
8004
|
+
# exited 0, and the commit went in untested. Global `set -o pipefail` would fix that and break
|
|
8005
|
+
# `git log | head -5` (SIGPIPE → 141) everywhere, so the line runs as written and gu READS the
|
|
8006
|
+
# output instead: a pipe line that exited 0 while printing an unmistakable failure is reported
|
|
8007
|
+
# as one. Markers are line-anchored and specific, so ordinary output containing "error" passes.
|
|
8008
|
+
_PIPE_FAIL_RE = re.compile(
|
|
8009
|
+
r"^.*(?:No module named \S+|ModuleNotFoundError: .*|: command not found|: not found$"
|
|
8010
|
+
r"|^Traceback \(most recent call last\):|^fatal: .*|^error: .*|^npm ERR! .*"
|
|
8011
|
+
r"|^E: Unable to locate package .*|: No such file or directory$)",
|
|
8012
|
+
re.M)
|
|
8013
|
+
|
|
8014
|
+
|
|
8015
|
+
def _pipe_hidden_failure(scan, out: str) -> str:
|
|
8016
|
+
"""The failure line a pipe hid, or "" — only for a line that has a `|`."""
|
|
8017
|
+
if not scan or not any(t[0] == "op" and t[1] == "|" for t in scan.get("tokens") or []):
|
|
8018
|
+
return ""
|
|
8019
|
+
m = _PIPE_FAIL_RE.search(out or "")
|
|
8020
|
+
return m.group(0).strip()[:200] if m else ""
|
|
8021
|
+
|
|
8022
|
+
|
|
8023
|
+
def _pipe_hidden_failure_note(line: str) -> str:
|
|
8024
|
+
return ("\n\nWARNING — A FAILURE WAS HIDDEN BY A PIPE: the output shows "
|
|
8025
|
+
f"'{line}', but a pipeline reports only its LAST command's exit code, so this "
|
|
8026
|
+
"line counted as success and every part after `&&` RAN ANYWAY. Check what those "
|
|
8027
|
+
"parts did before relying on them. To chain on a check, run it without a pipe, or "
|
|
8028
|
+
"start the line with `set -o pipefail;`.")
|
|
8029
|
+
|
|
8030
|
+
|
|
8031
|
+
# THE PROJECT'S OWN TEST COMMANDS (#360 review). The model guessed `python3 -m pytest` in a repo
|
|
8032
|
+
# whose package.json says `python3 -m unittest discover tests -v && node --test` — pytest was not
|
|
8033
|
+
# even installed. The manifests already say how each part of the project is tested; reading them
|
|
8034
|
+
# costs no tool call. Depth ≤ 3 (a monorepo's packages), at most 8, nearest first.
|
|
8035
|
+
_TEST_SKIP_DIRS = {"node_modules", "dist", "build", "out", "target", "vendor", "venv", ".venv",
|
|
8036
|
+
"__pycache__", "server-bundle", "coverage"}
|
|
8037
|
+
|
|
8038
|
+
|
|
8039
|
+
def _project_test_commands(root: str, max_depth: int = 3, limit: int = 8) -> list:
|
|
8040
|
+
"""[(relative folder, command)] from package.json scripts.test, a Makefile `test:` target,
|
|
8041
|
+
pytest config, Cargo.toml and go.mod. Never raises."""
|
|
8042
|
+
import json as _json
|
|
8043
|
+
found = []
|
|
8044
|
+
try:
|
|
8045
|
+
root = os.path.abspath(root)
|
|
8046
|
+
except Exception: # noqa: BLE001
|
|
8047
|
+
return []
|
|
8048
|
+
queue = [(root, 0)]
|
|
8049
|
+
while queue and len(found) < limit:
|
|
8050
|
+
d, depth = queue.pop(0)
|
|
8051
|
+
try:
|
|
8052
|
+
names = set(os.listdir(d))
|
|
8053
|
+
except OSError:
|
|
8054
|
+
continue
|
|
8055
|
+
rel = os.path.relpath(d, root)
|
|
8056
|
+
rel = "." if rel == "." else rel.replace(os.sep, "/")
|
|
8057
|
+
cmd = ""
|
|
8058
|
+
if "package.json" in names:
|
|
8059
|
+
try:
|
|
8060
|
+
with open(os.path.join(d, "package.json"), encoding="utf-8") as fh:
|
|
8061
|
+
t = ((_json.load(fh) or {}).get("scripts") or {}).get("test") or ""
|
|
8062
|
+
if t and "no test specified" not in t:
|
|
8063
|
+
cmd = f"npm test ({_clip(t, 110)})"
|
|
8064
|
+
except Exception: # noqa: BLE001
|
|
8065
|
+
pass
|
|
8066
|
+
if not cmd and "Makefile" in names:
|
|
8067
|
+
try:
|
|
8068
|
+
with open(os.path.join(d, "Makefile"), encoding="utf-8", errors="replace") as fh:
|
|
8069
|
+
if re.search(r"(?m)^test\s*:", fh.read(200_000)):
|
|
8070
|
+
cmd = "make test"
|
|
8071
|
+
except OSError:
|
|
8072
|
+
pass
|
|
8073
|
+
if not cmd and ("pytest.ini" in names or "conftest.py" in names):
|
|
8074
|
+
cmd = "python3 -m pytest"
|
|
8075
|
+
if not cmd and "pyproject.toml" in names:
|
|
8076
|
+
try:
|
|
8077
|
+
with open(os.path.join(d, "pyproject.toml"), encoding="utf-8", errors="replace") as fh:
|
|
8078
|
+
if "[tool.pytest" in fh.read(200_000):
|
|
8079
|
+
cmd = "python3 -m pytest"
|
|
8080
|
+
except OSError:
|
|
8081
|
+
pass
|
|
8082
|
+
if not cmd and "Cargo.toml" in names:
|
|
8083
|
+
cmd = "cargo test"
|
|
8084
|
+
if not cmd and "go.mod" in names:
|
|
8085
|
+
cmd = "go test ./..."
|
|
8086
|
+
if cmd:
|
|
8087
|
+
found.append((rel, cmd))
|
|
8088
|
+
if depth < max_depth:
|
|
8089
|
+
for n in sorted(names):
|
|
8090
|
+
if n.startswith(".") or n in _TEST_SKIP_DIRS:
|
|
8091
|
+
continue
|
|
8092
|
+
p = os.path.join(d, n)
|
|
8093
|
+
if os.path.isdir(p) and not os.path.islink(p):
|
|
8094
|
+
queue.append((p, depth + 1))
|
|
8095
|
+
return found
|
|
8096
|
+
|
|
8097
|
+
|
|
8098
|
+
def _test_commands_line(root: str) -> str:
|
|
8099
|
+
cmds = _project_test_commands(root) if root else []
|
|
8100
|
+
if not cmds:
|
|
8101
|
+
return ""
|
|
8102
|
+
return (" TEST COMMANDS, from this project's own manifests — use these, never guess a runner "
|
|
8103
|
+
"(a missing pytest fails silently behind a pipe): "
|
|
8104
|
+
+ "; ".join(f"{d} → {c}" for d, c in cmds) + "\n")
|
|
8105
|
+
|
|
8106
|
+
|
|
8107
|
+
# bash's words for a line whose quotes do not pair up.
|
|
8108
|
+
_SHELL_QUOTE_ERR_RE = re.compile(
|
|
8109
|
+
r"unexpected EOF while looking for matching|syntax error near unexpected token|"
|
|
8110
|
+
r"syntax error: unexpected end of file|unterminated quoted string")
|
|
8111
|
+
|
|
7953
8112
|
_RUN_DOC_ARGV = ("No other shell syntax (no pipes/redirects/;). sudo/su are blocked.")
|
|
7954
8113
|
_RUN_DOC_SHELL = ("It runs in bash, so pipes, redirects, ;, ||, $( ), globs and heredocs work too "
|
|
7955
8114
|
"(e.g. 'npm test 2>&1 | tail -40'). sudo/su are blocked.")
|
|
@@ -8941,6 +9100,70 @@ def _shell_find(shells: list, key: str):
|
|
|
8941
9100
|
return None
|
|
8942
9101
|
|
|
8943
9102
|
|
|
9103
|
+
_EPHEMERAL_FLOOR = None
|
|
9104
|
+
|
|
9105
|
+
|
|
9106
|
+
def _ws_ephemeral_floor() -> int:
|
|
9107
|
+
"""First port of the OS's ephemeral range — where a bind to port 0 lands. Linux reads
|
|
9108
|
+
ip_local_port_range (32768 by default), macOS its sysctl (49152); Windows and anything
|
|
9109
|
+
unreadable use the IANA 49152."""
|
|
9110
|
+
global _EPHEMERAL_FLOOR
|
|
9111
|
+
if _EPHEMERAL_FLOOR is None:
|
|
9112
|
+
floor = 49152
|
|
9113
|
+
try:
|
|
9114
|
+
if sys.platform.startswith("linux"):
|
|
9115
|
+
with open("/proc/sys/net/ipv4/ip_local_port_range", encoding="utf-8") as fh:
|
|
9116
|
+
floor = int(fh.read().split()[0])
|
|
9117
|
+
elif sys.platform == "darwin":
|
|
9118
|
+
import subprocess
|
|
9119
|
+
out = subprocess.run(["sysctl", "-n", "net.inet.ip.portrange.first"],
|
|
9120
|
+
capture_output=True, text=True, timeout=3).stdout.strip()
|
|
9121
|
+
floor = int(out) if out.isdigit() else floor
|
|
9122
|
+
except Exception: # noqa: BLE001
|
|
9123
|
+
pass
|
|
9124
|
+
_EPHEMERAL_FLOOR = max(1024, floor)
|
|
9125
|
+
return _EPHEMERAL_FLOOR
|
|
9126
|
+
|
|
9127
|
+
|
|
9128
|
+
def _ws_is_ephemeral_port(port) -> bool:
|
|
9129
|
+
try:
|
|
9130
|
+
return int(port) >= _ws_ephemeral_floor()
|
|
9131
|
+
except (TypeError, ValueError):
|
|
9132
|
+
return False
|
|
9133
|
+
|
|
9134
|
+
|
|
9135
|
+
def _server_as_shell(srv: dict) -> dict:
|
|
9136
|
+
"""A left-running server/app in the shape shell_output reads, so a command that was filed as
|
|
9137
|
+
a server (wrongly, or not) can still be waited on and read (#360)."""
|
|
9138
|
+
import time as _t
|
|
9139
|
+
started = srv.get("started")
|
|
9140
|
+
if started is None:
|
|
9141
|
+
try:
|
|
9142
|
+
started = _t.mktime(_t.strptime(str(srv.get("started_at")), "%Y-%m-%d %H:%M:%S"))
|
|
9143
|
+
except (ValueError, OverflowError):
|
|
9144
|
+
started = _t.time()
|
|
9145
|
+
return {"id": str(srv.get("pid")), "pid": srv.get("pid"), "pgid": srv.get("pgid") or srv.get("pid"),
|
|
9146
|
+
"command": srv.get("command"), "workdir": srv.get("workdir"), "log": srv.get("log"),
|
|
9147
|
+
"exit_file": "", "started": started, "read_offset": srv.get("read_offset", 0),
|
|
9148
|
+
"server": True}
|
|
9149
|
+
|
|
9150
|
+
|
|
9151
|
+
def _shell_or_server(shells: list, servers: list, key: str):
|
|
9152
|
+
"""shell_output's target: a shell by id, else a server by pid or port; with no key, the newest
|
|
9153
|
+
of both — the model asking about "the" background job means the one it just started."""
|
|
9154
|
+
key = (key or "").strip().lstrip("#")
|
|
9155
|
+
if key:
|
|
9156
|
+
sh = _shell_find(shells, key)
|
|
9157
|
+
if sh is not None:
|
|
9158
|
+
return sh
|
|
9159
|
+
for srv in reversed(servers):
|
|
9160
|
+
if key == str(srv.get("pid")) or any(key == str(p) for p in srv.get("ports") or []):
|
|
9161
|
+
return _server_as_shell(srv)
|
|
9162
|
+
return None
|
|
9163
|
+
cands = list(shells) + [_server_as_shell(x) for x in servers]
|
|
9164
|
+
return max(cands, key=lambda x: float(x.get("started") or 0)) if cands else None
|
|
9165
|
+
|
|
9166
|
+
|
|
8944
9167
|
def _shell_kill(s: dict) -> None:
|
|
8945
9168
|
"""Stop a shell's whole process group and mark it killed (unless it already finished)."""
|
|
8946
9169
|
import time as _t
|
|
@@ -9020,8 +9243,19 @@ def _range_is_covered(served, start: int, end: int):
|
|
|
9020
9243
|
return None
|
|
9021
9244
|
|
|
9022
9245
|
|
|
9023
|
-
|
|
9024
|
-
|
|
9246
|
+
_DISTILL_FLOOR = 5200 # 1200 head + 4000 tail — what every window got before #360
|
|
9247
|
+
|
|
9248
|
+
|
|
9249
|
+
def _ws_distill_output(out: str, cap_head: int = None, cap_tail: int = None) -> str:
|
|
9250
|
+
"""Cap huge build/test output: failures live at the tail, context at the head.
|
|
9251
|
+
|
|
9252
|
+
THE CAP SCALES WITH THE WINDOW, like a file read (#360 follow-up, issue #49). A fixed 5,200
|
|
9253
|
+
chars cut a `git diff` in a commit turn and the model spent a step re-reading it with sed;
|
|
9254
|
+
a command's output is a read too. Never below the old size, so a small window is unchanged."""
|
|
9255
|
+
if cap_head is None or cap_tail is None:
|
|
9256
|
+
total = max(_DISTILL_FLOOR, int(_OBS_CAPS.get("read") or 0))
|
|
9257
|
+
cap_head = cap_head if cap_head is not None else int(total * 0.23)
|
|
9258
|
+
cap_tail = cap_tail if cap_tail is not None else total - cap_head
|
|
9025
9259
|
if len(out) <= cap_head + cap_tail + 200:
|
|
9026
9260
|
return out
|
|
9027
9261
|
return (out[:cap_head] + f"\n…[{len(out) - cap_head - cap_tail} chars omitted]…\n"
|
|
@@ -10289,9 +10523,16 @@ def run_agent_chat(cfg):
|
|
|
10289
10523
|
_ws_servers_line += _shells_context_line(_shells_load())
|
|
10290
10524
|
except Exception as _e: # noqa: BLE001
|
|
10291
10525
|
_log(f"shell context skip: {_e}")
|
|
10526
|
+
_ws_tests_line = ""
|
|
10527
|
+
if _WS_AVAILABLE and _WS_ACTIVE:
|
|
10528
|
+
try:
|
|
10529
|
+
_ws_tests_line = _test_commands_line(_WS_ACTIVE)
|
|
10530
|
+
except Exception as _e: # noqa: BLE001
|
|
10531
|
+
_log(f"test command scan skip: {_e}")
|
|
10292
10532
|
_ws_context_block = (
|
|
10293
10533
|
_ws_current_line
|
|
10294
10534
|
+ f" Other registered workspaces (use only if the user names them): {_ws_names}\n"
|
|
10535
|
+
+ _ws_tests_line
|
|
10295
10536
|
+ _ws_servers_line
|
|
10296
10537
|
+ f" Current folder contents:\n"
|
|
10297
10538
|
+ "\n".join(f" {line}" for line in _ws_overview.splitlines()) + "\n"
|
|
@@ -14356,7 +14597,8 @@ def run_agent_chat(cfg):
|
|
|
14356
14597
|
_scan = _shell_scan(command) if _shell_bin else None
|
|
14357
14598
|
if _scan and _scan["error"]:
|
|
14358
14599
|
msg = (f"Error: that command did NOT run — {_scan['error']}. Nothing was "
|
|
14359
|
-
"executed; fix the quoting and send it again."
|
|
14600
|
+
"executed; fix the quoting and send it again."
|
|
14601
|
+
+ (_HEREDOC_HINT if "quote" in str(_scan["error"]) else ""))
|
|
14360
14602
|
_last_obs["text"] = msg
|
|
14361
14603
|
return msg
|
|
14362
14604
|
_use_shell = bool(_scan) and _shell_needed(_scan)
|
|
@@ -14620,6 +14862,17 @@ def run_agent_chat(cfg):
|
|
|
14620
14862
|
# denied" is the part the reader already has; who we were and what we
|
|
14621
14863
|
# were allowed to be is the part they need, and without it both the
|
|
14622
14864
|
# model and the user are guessing at the next step.
|
|
14865
|
+
# A QUOTING ERROR NAMES THE FORM THAT WORKS (#360 follow-up). The model's
|
|
14866
|
+
# `git commit -m '…turn'\''s…'` lost its backslash to Python and bash
|
|
14867
|
+
# stopped on the quote; the next step was spent guessing. A heredoc in a
|
|
14868
|
+
# triple-quoted string has no quoting to get wrong.
|
|
14869
|
+
_hidden = _pipe_hidden_failure(_scan, out) if (rc == 0 and _use_shell) else ""
|
|
14870
|
+
if _hidden:
|
|
14871
|
+
result += _pipe_hidden_failure_note(_hidden)
|
|
14872
|
+
_emit({"type": "step", "text":
|
|
14873
|
+
f"Command failed behind a pipe → {_trim_words(_hidden)}"})
|
|
14874
|
+
if rc != 0 and _use_shell and _SHELL_QUOTE_ERR_RE.search(out or ""):
|
|
14875
|
+
result += _HEREDOC_HINT
|
|
14623
14876
|
if rc != 0 and _WS_PERM_ERR_RE.search(out or ""):
|
|
14624
14877
|
_f = _ws_access_facts()
|
|
14625
14878
|
result = f"{result}\n\n{_ws_access_report(_f, _ws_paths_in(command))}"
|
|
@@ -14629,10 +14882,16 @@ def run_agent_chat(cfg):
|
|
|
14629
14882
|
return result
|
|
14630
14883
|
if _t.time() - start >= 3:
|
|
14631
14884
|
ports = _ws_listening_ports(proc.pid)
|
|
14885
|
+
# A port the OS PICKED (bind to 0) is a test run's own helper — the UI
|
|
14886
|
+
# suite's fake API on :52192 was filed as a "server", so the model could
|
|
14887
|
+
# not wait for the run and spent 3 steps polling it by hand (#360). Keep
|
|
14888
|
+
# waiting for it like any command; it hands off at the deadline if needed.
|
|
14889
|
+
if ports and all(_ws_is_ephemeral_port(p_) for p_ in ports):
|
|
14890
|
+
ports = []
|
|
14632
14891
|
if ports:
|
|
14633
14892
|
servers = _ws_load_servers()
|
|
14634
14893
|
servers.append({
|
|
14635
|
-
"pgid": proc.pid, "pid": proc.pid, "command": command,
|
|
14894
|
+
"pgid": proc.pid, "pid": proc.pid, "command": command, "started": start,
|
|
14636
14895
|
"workdir": wd, "ports": ports, "log": log_path,
|
|
14637
14896
|
"started_at": _t.strftime("%Y-%m-%d %H:%M:%S"),
|
|
14638
14897
|
})
|
|
@@ -14642,7 +14901,9 @@ def run_agent_chat(cfg):
|
|
|
14642
14901
|
tail = _ws_distill_output(_read_log())
|
|
14643
14902
|
result = (f"RUNNING: '{command}' is up and listening — {urls} "
|
|
14644
14903
|
f"(pid {proc.pid}, log {log_path}). It stays running in "
|
|
14645
|
-
"the background; use stop_server to stop it. "
|
|
14904
|
+
"the background; use stop_server to stop it. If it is "
|
|
14905
|
+
"NOT meant to stay up (a test run that opened a port), "
|
|
14906
|
+
f"shell_output('{proc.pid}', wait_seconds=…) waits for it. "
|
|
14646
14907
|
f"Startup output:\n{tail}")
|
|
14647
14908
|
_last_obs["text"] = result
|
|
14648
14909
|
return result
|
|
@@ -14654,7 +14915,7 @@ def run_agent_chat(cfg):
|
|
|
14654
14915
|
if _ws_gui_pids(proc.pid):
|
|
14655
14916
|
servers = _ws_load_servers()
|
|
14656
14917
|
servers.append({
|
|
14657
|
-
"pgid": proc.pid, "pid": proc.pid, "command": command,
|
|
14918
|
+
"pgid": proc.pid, "pid": proc.pid, "command": command, "started": start,
|
|
14658
14919
|
"workdir": wd, "ports": [], "kind": "gui", "log": log_path,
|
|
14659
14920
|
"started_at": _t.strftime("%Y-%m-%d %H:%M:%S"),
|
|
14660
14921
|
})
|
|
@@ -14785,11 +15046,13 @@ def run_agent_chat(cfg):
|
|
|
14785
15046
|
try:
|
|
14786
15047
|
import time as _t
|
|
14787
15048
|
shells = _shells_load()
|
|
14788
|
-
|
|
15049
|
+
servers = _ws_load_servers()
|
|
15050
|
+
sh = _shell_or_server(shells, servers, shell_id)
|
|
14789
15051
|
if sh is None:
|
|
14790
|
-
|
|
14791
|
-
|
|
14792
|
-
|
|
15052
|
+
ids = [str(x.get("id")) for x in shells] + [str(x.get("pid")) for x in servers]
|
|
15053
|
+
msg = ("No background shell or server matches '" + (shell_id or "") + "'. "
|
|
15054
|
+
+ ("Running: " + ", ".join(ids) if ids
|
|
15055
|
+
else "Nothing was left running in the background."))
|
|
14793
15056
|
_last_obs["text"] = msg
|
|
14794
15057
|
return msg
|
|
14795
15058
|
deadline = _t.time() + max(0, min(int(wait_seconds or 0), 540))
|
|
@@ -14804,14 +15067,22 @@ def run_agent_chat(cfg):
|
|
|
14804
15067
|
except OSError:
|
|
14805
15068
|
chunk = b""
|
|
14806
15069
|
# Re-read the file before writing: the terminal may have killed it meanwhile.
|
|
14807
|
-
|
|
14808
|
-
|
|
14809
|
-
|
|
14810
|
-
|
|
14811
|
-
|
|
14812
|
-
|
|
15070
|
+
if sh.get("server"):
|
|
15071
|
+
servers = _ws_load_servers(prune=False)
|
|
15072
|
+
for x in servers:
|
|
15073
|
+
if str(x.get("pid")) == str(sh.get("id")):
|
|
15074
|
+
x["read_offset"] = int(sh.get("read_offset") or 0) + len(chunk)
|
|
15075
|
+
_ws_save_servers(servers)
|
|
15076
|
+
else:
|
|
15077
|
+
shells = _shells_load()
|
|
15078
|
+
cur = _shell_find(shells, str(sh.get("id"))) or sh
|
|
15079
|
+
cur["read_offset"] = int(sh.get("read_offset") or 0) + len(chunk)
|
|
15080
|
+
if st["state"] != "running":
|
|
15081
|
+
cur["seen"] = True
|
|
15082
|
+
_shells_save(shells)
|
|
14813
15083
|
new = _ws_distill_output(chunk.decode("utf-8", errors="replace"))
|
|
14814
|
-
head = (f"
|
|
15084
|
+
head = (f"{'server' if sh.get('server') else 'shell'} {sh.get('id')}: "
|
|
15085
|
+
f"{_shell_status_text(st).upper()} after "
|
|
14815
15086
|
f"{_fmt_runtime(st['runtime'])} — '{_clip(str(sh.get('command')), 200)}' "
|
|
14816
15087
|
f"(log {sh.get('log')})")
|
|
14817
15088
|
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.
|
|
3
|
+
"version": "1.0.831",
|
|
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",
|