@tiens.nguyen/gu-cli 1.0.787 → 1.0.789
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/clear-logs.mjs +352 -0
- package/gonext_agent_chat.py +252 -14
- package/gu-repl.mjs +311 -32
- package/package.json +2 -1
- package/terminal-layout.mjs +171 -2
package/gonext_agent_chat.py
CHANGED
|
@@ -5183,6 +5183,90 @@ def _same_embed_model(a: str, b: str) -> bool:
|
|
|
5183
5183
|
_RUNTOOL_PREFIX = "__RUNTOOL__::"
|
|
5184
5184
|
|
|
5185
5185
|
|
|
5186
|
+
# ---- the command that is running, WHOLE (task #331) ------------------------------------------
|
|
5187
|
+
# "Running → <cmd>" carries the first 70 characters, which is right for a history line and wrong
|
|
5188
|
+
# for the one moment someone is watching: a heredoc or a long pipeline was cut at the source, so
|
|
5189
|
+
# the terminal could not show the rest whatever its layout. Sent beside that line (which stays,
|
|
5190
|
+
# for the web app and for older terminals) as ONE line of JSON — a command can span lines.
|
|
5191
|
+
_RUNCMD_PREFIX = "__RUNCMD__::"
|
|
5192
|
+
# How much it has printed so far: "__RUNOUT__::<lines>". From the wait loop, only when it changed.
|
|
5193
|
+
_RUNOUT_PREFIX = "__RUNOUT__::"
|
|
5194
|
+
_RUNCMD_MAX_CHARS = 4000
|
|
5195
|
+
|
|
5196
|
+
# SECRETS. The 70-character cut hid most credentials by accident; showing the whole command must
|
|
5197
|
+
# not undo that. Conservative and by SHAPE — a value after a name that says it is a secret, a
|
|
5198
|
+
# bearer header, user:password@ in a URL, sshpass -p, and the key formats gu itself hands out.
|
|
5199
|
+
# This is what goes to the SCREEN and the job stream; the command that runs is untouched.
|
|
5200
|
+
# "pass" and "token" only as whole words: --passes=3, bypass=true and --max-tokens 100 are not secrets.
|
|
5201
|
+
_SECRET_NAME = (r"(?:password|passwd|passphrase|pwd|(?<![a-z])pass(?![a-z])|secret|token(?![a-z])"
|
|
5202
|
+
r"|api[_-]?key|access[_-]?key)")
|
|
5203
|
+
_CMD_SECRET_RES = (
|
|
5204
|
+
(re.compile(r"(?i)(\b[\w.-]*" + _SECRET_NAME + r"[\w.-]*\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
|
|
5205
|
+
(re.compile(r"(?i)(--?[\w-]*" + _SECRET_NAME + r"[\w-]*[ =])(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
|
|
5206
|
+
(re.compile(r"(?i)(\bsshpass\s+-p\s*)(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
|
|
5207
|
+
(re.compile(r"(?i)(authorization:\s*(?:bearer|basic)\s+)[^\s\"']+"), r"\1•••"),
|
|
5208
|
+
(re.compile(r"(?i)(\b[a-z][a-z0-9+.-]*://[^\s/:@]+:)[^\s/@]+(@)"), r"\1•••\2"),
|
|
5209
|
+
(re.compile(r"\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|wk_[A-Za-z0-9_-]{16,}|gu1\.[A-Za-z0-9_.-]{20,})"), "•••"),
|
|
5210
|
+
)
|
|
5211
|
+
|
|
5212
|
+
|
|
5213
|
+
def _mask_command_secrets(command: str) -> str:
|
|
5214
|
+
"""The command as it may be SHOWN: credentials replaced by •••, everything else as written."""
|
|
5215
|
+
text = str(command or "")
|
|
5216
|
+
for rx, sub in _CMD_SECRET_RES:
|
|
5217
|
+
text = rx.sub(sub, text)
|
|
5218
|
+
return text
|
|
5219
|
+
|
|
5220
|
+
|
|
5221
|
+
def _command_label(command: str, limit: int = 70) -> str:
|
|
5222
|
+
"""A command as ONE LINE of a step label: masked, its first line only, cut to `limit`.
|
|
5223
|
+
|
|
5224
|
+
A step is a line of the stream. A heredoc's newlines made "Command finished → python3 - <<'EOF'"
|
|
5225
|
+
followed by every line of the script as a bullet of its own — and, on the way in, ended the
|
|
5226
|
+
live status row the moment the "Running →" line was read (#332). " …" says there is more.
|
|
5227
|
+
"""
|
|
5228
|
+
lines = [ln.strip() for ln in _mask_command_secrets(command).replace("\r", "").split("\n") if ln.strip()]
|
|
5229
|
+
if not lines:
|
|
5230
|
+
return ""
|
|
5231
|
+
return lines[0][:limit] + (" …" if len(lines) > 1 else "")
|
|
5232
|
+
|
|
5233
|
+
|
|
5234
|
+
def _running_command_line(command: str) -> str:
|
|
5235
|
+
"""The stream line that carries the whole command to the terminal (masked, capped)."""
|
|
5236
|
+
shown = _mask_command_secrets(command)
|
|
5237
|
+
if len(shown) > _RUNCMD_MAX_CHARS:
|
|
5238
|
+
shown = shown[:_RUNCMD_MAX_CHARS] + "…"
|
|
5239
|
+
return _RUNCMD_PREFIX + json.dumps({"cmd": shown}, ensure_ascii=False)
|
|
5240
|
+
|
|
5241
|
+
|
|
5242
|
+
def _count_output_lines(path: str, state: dict) -> int:
|
|
5243
|
+
"""Lines a running command has written to its log so far. Reads only what is NEW since the
|
|
5244
|
+
last call (`state` keeps the offset), so a chatty ten-minute build is not re-read every tick."""
|
|
5245
|
+
try:
|
|
5246
|
+
with open(path, "rb") as fh:
|
|
5247
|
+
fh.seek(state.get("off", 0))
|
|
5248
|
+
chunk = fh.read()
|
|
5249
|
+
except OSError:
|
|
5250
|
+
return state.get("lines", 0)
|
|
5251
|
+
if chunk:
|
|
5252
|
+
state["off"] = state.get("off", 0) + len(chunk)
|
|
5253
|
+
state["lines"] = state.get("lines", 0) + chunk.count(b"\n")
|
|
5254
|
+
state["partial"] = not chunk.endswith(b"\n")
|
|
5255
|
+
# a last line with no newline yet is still a line someone could read
|
|
5256
|
+
return state.get("lines", 0) + (1 if state.get("partial") else 0)
|
|
5257
|
+
|
|
5258
|
+
|
|
5259
|
+
def _emit_command_progress(path: str, state: dict) -> None:
|
|
5260
|
+
"""Tell the terminal how much the running command has printed — only when that changed."""
|
|
5261
|
+
try:
|
|
5262
|
+
n = _count_output_lines(path, state)
|
|
5263
|
+
if n != state.get("sent", 0):
|
|
5264
|
+
state["sent"] = n
|
|
5265
|
+
_emit({"type": "step", "text": f"{_RUNOUT_PREFIX}{n}"})
|
|
5266
|
+
except Exception: # noqa: BLE001 — a progress figure must never fail a command
|
|
5267
|
+
pass
|
|
5268
|
+
|
|
5269
|
+
|
|
5186
5270
|
def _fmt_call(name: str, **kw) -> str:
|
|
5187
5271
|
"""A tool call rendered the way the model writes it. Skips empty/default arguments."""
|
|
5188
5272
|
parts = []
|
|
@@ -6391,6 +6475,114 @@ def _and_chain_parts(command):
|
|
|
6391
6475
|
return parts
|
|
6392
6476
|
|
|
6393
6477
|
|
|
6478
|
+
def _parse_heredoc(command):
|
|
6479
|
+
"""A heredoc or here-string in `command` → what to run and what to feed it. (task #332)
|
|
6480
|
+
|
|
6481
|
+
THE BUG THIS EXISTS FOR. run_command has no shell, so
|
|
6482
|
+
|
|
6483
|
+
python3 - <<'EOF'
|
|
6484
|
+
print("hello")
|
|
6485
|
+
EOF
|
|
6486
|
+
|
|
6487
|
+
became argv ["python3", "-", "<<EOF", "print(hello)", "EOF"]: python read its script from an
|
|
6488
|
+
EMPTY stdin and exited 0 with no output. Nothing ran and the step looked like a success —
|
|
6489
|
+
the #204 failure again, in the one shape #204's check did not cover (its redirect pattern
|
|
6490
|
+
matches "<" and ">", not "<<"). Seen live: the model lost two steps rewriting the work as
|
|
6491
|
+
`python3 -c`. Heredocs are how a strong model runs a multi-line script without a file, so
|
|
6492
|
+
this is the common case, not an edge.
|
|
6493
|
+
|
|
6494
|
+
SUPPORTED, still with no shell: ONE program fed a body.
|
|
6495
|
+
PROG ARGS <<'TAG' … TAG the body becomes the program's stdin
|
|
6496
|
+
PROG ARGS <<-TAG … TAG same, leading tabs stripped
|
|
6497
|
+
PROG ARGS <<< 'text' a here-string: text + newline on stdin
|
|
6498
|
+
REFUSED, each with the one change that fixes it — a guess costs a model step:
|
|
6499
|
+
an unquoted TAG (or a double-quoted here-string) whose text has $ or ` — a shell would
|
|
6500
|
+
expand those and we cannot; anything after the closing TAG; no closing TAG; two heredocs.
|
|
6501
|
+
|
|
6502
|
+
Returns None when there is no heredoc (`echo "a << b"` and `python3 -c "print(1 << 3)"` are
|
|
6503
|
+
not: the operator only counts OUTSIDE quotes), else {"command", "stdin"} or {"error"}.
|
|
6504
|
+
Pure, so every shape is testable without spawning anything.
|
|
6505
|
+
"""
|
|
6506
|
+
s = command or ""
|
|
6507
|
+
q = None
|
|
6508
|
+
i = 0
|
|
6509
|
+
at = -1
|
|
6510
|
+
while i < len(s):
|
|
6511
|
+
c = s[i]
|
|
6512
|
+
if q:
|
|
6513
|
+
if c == q:
|
|
6514
|
+
q = None
|
|
6515
|
+
elif c == "\\" and q == '"':
|
|
6516
|
+
i += 1
|
|
6517
|
+
elif c in "'\"":
|
|
6518
|
+
q = c
|
|
6519
|
+
elif c == "\\":
|
|
6520
|
+
i += 1
|
|
6521
|
+
elif s.startswith("<<", i):
|
|
6522
|
+
at = i
|
|
6523
|
+
break
|
|
6524
|
+
i += 1
|
|
6525
|
+
if at < 0:
|
|
6526
|
+
return None
|
|
6527
|
+
_why = "Error: that command did NOT run — nothing was executed. "
|
|
6528
|
+
before = s[:at]
|
|
6529
|
+
if s.startswith("<<<", at):
|
|
6530
|
+
# HERE-STRING: the next word is the input; anything after it stays an argument.
|
|
6531
|
+
rest = s[at + 3:]
|
|
6532
|
+
import shlex as _shlex
|
|
6533
|
+
try:
|
|
6534
|
+
words = _shlex.split(rest)
|
|
6535
|
+
except ValueError as e:
|
|
6536
|
+
return {"error": _why + f"The here-string is not closed: {e}."}
|
|
6537
|
+
if not words:
|
|
6538
|
+
return {"error": _why + "'<<<' needs the text to feed the program: PROG <<< 'text'."}
|
|
6539
|
+
lead = rest.lstrip()
|
|
6540
|
+
if not lead.startswith("'") and re.search(r"[$`]", words[0]):
|
|
6541
|
+
return {"error": _why + "run_command has NO shell, so $variables and `commands` in a "
|
|
6542
|
+
"here-string are not expanded. Put the text in SINGLE quotes to pass it "
|
|
6543
|
+
"exactly as written, or write the final text yourself."}
|
|
6544
|
+
tail = " ".join(_shlex.quote(w) for w in words[1:])
|
|
6545
|
+
return {"command": (before.rstrip() + (" " + tail if tail else "")).strip(),
|
|
6546
|
+
"stdin": words[0] + "\n"}
|
|
6547
|
+
m = re.match(r"<<(-?)[ \t]*(?:'([^'\n]+)'|\"([^\"\n]+)\"|\\?([A-Za-z_][\w.-]*))", s[at:])
|
|
6548
|
+
if not m:
|
|
6549
|
+
return {"error": _why + "'<<' must be followed by a tag: PROG <<'EOF', the text, then "
|
|
6550
|
+
"EOF on a line of its own."}
|
|
6551
|
+
strip_tabs = bool(m.group(1))
|
|
6552
|
+
tag = m.group(2) or m.group(3) or m.group(4)
|
|
6553
|
+
quoted = bool(m.group(2) or m.group(3)) or s[at + m.end() - len(tag) - 1] == "\\"
|
|
6554
|
+
after = s[at + m.end():]
|
|
6555
|
+
line_rest, nl, body_and_more = after.partition("\n")
|
|
6556
|
+
if "<<" in line_rest and _parse_heredoc(line_rest) is not None:
|
|
6557
|
+
return {"error": _why + "Only one heredoc per command is supported. Write the second "
|
|
6558
|
+
"text with create_file, or run the two commands in separate calls."}
|
|
6559
|
+
lines = body_and_more.split("\n") if nl else []
|
|
6560
|
+
end = -1
|
|
6561
|
+
for k, ln in enumerate(lines):
|
|
6562
|
+
if (ln.lstrip("\t") if strip_tabs else ln).rstrip("\r") == tag:
|
|
6563
|
+
end = k
|
|
6564
|
+
break
|
|
6565
|
+
if end < 0:
|
|
6566
|
+
return {"error": _why + f"The heredoc is never closed: put {tag} on a line of its own "
|
|
6567
|
+
"after the text."}
|
|
6568
|
+
body_lines = [(ln.lstrip("\t") if strip_tabs else ln).rstrip("\r") for ln in lines[:end]]
|
|
6569
|
+
trailing = "\n".join(lines[end + 1:]).strip()
|
|
6570
|
+
if trailing:
|
|
6571
|
+
return {"error": _why + f"There is more after the closing {tag}, and run_command runs "
|
|
6572
|
+
"ONE command per call (only && chains continue). Run what follows in a "
|
|
6573
|
+
"separate run_command call."}
|
|
6574
|
+
cmdline = (before.rstrip() + " " + line_rest.strip()).strip()
|
|
6575
|
+
body = "\n".join(body_lines) + ("\n" if body_lines else "")
|
|
6576
|
+
if not quoted and re.search(r"[$`]", body):
|
|
6577
|
+
return {"error": _why + "run_command has NO shell, so the $variables and `commands` in "
|
|
6578
|
+
f"this heredoc would not be expanded. Quote the tag — <<'{tag}' — to pass the "
|
|
6579
|
+
"text exactly as written (that is what a script wants), or write the final "
|
|
6580
|
+
"text yourself."}
|
|
6581
|
+
if not cmdline:
|
|
6582
|
+
return {"error": _why + "The heredoc has no program to feed: PROG <<'EOF' … EOF."}
|
|
6583
|
+
return {"command": cmdline, "stdin": body}
|
|
6584
|
+
|
|
6585
|
+
|
|
6394
6586
|
def _shell_syntax_error(command, argv):
|
|
6395
6587
|
"""Why this command cannot do what it says, or None. (task #204)
|
|
6396
6588
|
|
|
@@ -6963,7 +7155,7 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
|
|
|
6963
7155
|
_srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
|
|
6964
7156
|
_emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
|
|
6965
7157
|
else:
|
|
6966
|
-
_emit({"type": "step", "text": f"Awaiting your approval to run: {command
|
|
7158
|
+
_emit({"type": "step", "text": f"Awaiting your approval to run: {_command_label(command, 70)}"})
|
|
6967
7159
|
deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
|
|
6968
7160
|
while _t.time() < deadline:
|
|
6969
7161
|
_t.sleep(1.2)
|
|
@@ -7341,6 +7533,12 @@ def _ws_scrubbed_env() -> dict:
|
|
|
7341
7533
|
# CI=1 makes watch-mode test runners (CRA's `npm test`, jest --watch) run ONCE and
|
|
7342
7534
|
# exit instead of sitting in interactive watch mode until the timeout kills them.
|
|
7343
7535
|
env["CI"] = "1"
|
|
7536
|
+
# WINDOWS: a python child reads and writes in the ANSI code page unless told otherwise, so a
|
|
7537
|
+
# script fed on stdin (a heredoc, #332) with "é" or "→" in it is mis-decoded, and printing
|
|
7538
|
+
# one raises UnicodeEncodeError. The worker's own python is started with PYTHONUTF8=1 for
|
|
7539
|
+
# exactly this reason; the whitelist above dropped it for the commands it runs.
|
|
7540
|
+
if _IS_WINDOWS:
|
|
7541
|
+
env["PYTHONUTF8"] = "1"
|
|
7344
7542
|
return env
|
|
7345
7543
|
|
|
7346
7544
|
|
|
@@ -12501,7 +12699,7 @@ def run_agent_chat(cfg):
|
|
|
12501
12699
|
"""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.
|
|
12502
12700
|
|
|
12503
12701
|
Args:
|
|
12504
|
-
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.
|
|
12702
|
+
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. To run a multi-line script without a file, feed ONE program a heredoc, tag quoted: "python3 - <<'EOF'\n...\nEOF". No other shell syntax (no pipes/redirects/;). sudo/su are blocked.
|
|
12505
12703
|
workdir: directory to run in. Empty = first registered workspace.
|
|
12506
12704
|
timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
|
|
12507
12705
|
"""
|
|
@@ -12546,7 +12744,24 @@ def run_agent_chat(cfg):
|
|
|
12546
12744
|
"gu-local-worker workspace add <path> --allow-run)")
|
|
12547
12745
|
_last_obs["text"] = msg
|
|
12548
12746
|
return msg
|
|
12549
|
-
|
|
12747
|
+
# A HEREDOC / HERE-STRING (task #332): the text is the program's stdin, and the
|
|
12748
|
+
# command line is what is left. `command` stays the whole text for the risk gate,
|
|
12749
|
+
# the status row and the log; `_cmdline` is what is split and judged as argv.
|
|
12750
|
+
_here = _parse_heredoc(command)
|
|
12751
|
+
if _here and _here.get("error"):
|
|
12752
|
+
_log(f"run_command refused heredoc: {_c(_clip(command, 120))}")
|
|
12753
|
+
_last_obs["text"] = _here["error"]
|
|
12754
|
+
return _here["error"]
|
|
12755
|
+
_cmdline = _here["command"] if _here else (command or "")
|
|
12756
|
+
_stdin_text = _here["stdin"] if _here else None
|
|
12757
|
+
if _here and _and_chain_parts(_cmdline):
|
|
12758
|
+
msg = ("Error: that command did NOT run — nothing was executed. A heredoc "
|
|
12759
|
+
"cannot be part of an && chain here. Run the program that reads it in "
|
|
12760
|
+
"its own run_command call, and pass the folder as the cwd argument "
|
|
12761
|
+
"instead of `cd X &&`.")
|
|
12762
|
+
_last_obs["text"] = msg
|
|
12763
|
+
return msg
|
|
12764
|
+
argv = shlex.split(_cmdline)
|
|
12550
12765
|
if not argv:
|
|
12551
12766
|
msg = "Error: empty command."
|
|
12552
12767
|
_last_obs["text"] = msg
|
|
@@ -12554,7 +12769,7 @@ def run_agent_chat(cfg):
|
|
|
12554
12769
|
# AN `&&` CHAIN IS ONE STEP (task #274): each part runs through THIS function —
|
|
12555
12770
|
# allowlist, risk gate, approval, server detection, all of it — in order, and
|
|
12556
12771
|
# the first failure stops the rest. `cd DIR` sets the folder for what follows.
|
|
12557
|
-
_chain = _and_chain_parts(command)
|
|
12772
|
+
_chain = None if _here else _and_chain_parts(command)
|
|
12558
12773
|
if _chain:
|
|
12559
12774
|
_chain_start = _t.time()
|
|
12560
12775
|
_cur_wd = wd
|
|
@@ -12604,7 +12819,7 @@ def run_agent_chat(cfg):
|
|
|
12604
12819
|
# executes the `cd` PROGRAM — which exits 0 with no output — and the step is
|
|
12605
12820
|
# reported as a success that did nothing. Refusing costs one step; the observed
|
|
12606
12821
|
# alternative cost two hour-long turns and a false report to the user.
|
|
12607
|
-
_shell_err = _shell_syntax_error(
|
|
12822
|
+
_shell_err = _shell_syntax_error(_cmdline, argv)
|
|
12608
12823
|
if _shell_err:
|
|
12609
12824
|
_log(f"run_command refused shell syntax: {_c(_clip(command, 120))}")
|
|
12610
12825
|
_last_obs["text"] = _shell_err
|
|
@@ -12622,7 +12837,10 @@ def run_agent_chat(cfg):
|
|
|
12622
12837
|
# Risk gate: privilege escalation / denylisted / destructive. When the
|
|
12623
12838
|
# client can approve interactively (terminal), PAUSE and ask the user;
|
|
12624
12839
|
# otherwise (web) keep hard-blocking the privilege/denylist cases.
|
|
12625
|
-
|
|
12840
|
+
# Judged on the COMMAND LINE, not on a heredoc's text: the text is data for the
|
|
12841
|
+
# program (a script that prints "sudo …" is not a sudo), and what a script does
|
|
12842
|
+
# is no more visible here than it is for `python3 -c`.
|
|
12843
|
+
_hard_reason, _ask_reason = _ws_command_risk(_cmdline, argv, _WS_RUN_DENYLIST, wd)
|
|
12626
12844
|
# Shift+Tab may have switched it since the turn started (#47).
|
|
12627
12845
|
global _accept_edits
|
|
12628
12846
|
if _interactive_approval and _job_id:
|
|
@@ -12638,7 +12856,7 @@ def run_agent_chat(cfg):
|
|
|
12638
12856
|
# states it. "this needs root and sudo needs a password we cannot type" is a
|
|
12639
12857
|
# different question from "sudo is passwordless here", and the user can only
|
|
12640
12858
|
# answer the one they are shown.
|
|
12641
|
-
_priv = _ws_priv_reason(
|
|
12859
|
+
_priv = _ws_priv_reason(_cmdline)
|
|
12642
12860
|
_facts = _ws_access_facts() if _priv else None
|
|
12643
12861
|
if _priv and _facts and not _facts["can_elevate"]:
|
|
12644
12862
|
# REFUSE BEFORE SPAWNING (requirement 5). With no tty, `sudo x` dies with
|
|
@@ -12689,7 +12907,8 @@ def run_agent_chat(cfg):
|
|
|
12689
12907
|
_emit({"type": "step", "text": f"Server already up → {_urls} (reused)"})
|
|
12690
12908
|
_last_obs["text"] = msg
|
|
12691
12909
|
return msg
|
|
12692
|
-
_emit({"type": "step", "text": f"Running → {command
|
|
12910
|
+
_emit({"type": "step", "text": f"Running → {_command_label(command, 70)}"})
|
|
12911
|
+
_emit({"type": "step", "text": _running_command_line(command)}) # whole (#331)
|
|
12693
12912
|
t = max(5, min(int(timeout_seconds or 180), 600))
|
|
12694
12913
|
# Own process group (start_new_session) + output to a log file. This is
|
|
12695
12914
|
# what lets a server OUTLIVE this job process, and what lets a timeout
|
|
@@ -12705,13 +12924,30 @@ def run_agent_chat(cfg):
|
|
|
12705
12924
|
if _attrib_note:
|
|
12706
12925
|
_log(f"run_command: {_attrib_note}")
|
|
12707
12926
|
# Scrubbed env: repo scripts must never see worker secrets.
|
|
12708
|
-
|
|
12709
|
-
|
|
12710
|
-
|
|
12711
|
-
|
|
12712
|
-
|
|
12927
|
+
# The heredoc's text as stdin, from an unnamed temp file: no pipe to keep fed (a
|
|
12928
|
+
# pipe needs a writer thread or it deadlocks past 64 KB), and nothing left on
|
|
12929
|
+
# disk — the file goes when the last handle on it closes.
|
|
12930
|
+
_stdin_fh = None
|
|
12931
|
+
if _stdin_text is not None:
|
|
12932
|
+
import tempfile
|
|
12933
|
+
_stdin_fh = tempfile.TemporaryFile()
|
|
12934
|
+
_stdin_fh.write(_stdin_text.encode("utf-8"))
|
|
12935
|
+
_stdin_fh.seek(0)
|
|
12936
|
+
try:
|
|
12937
|
+
# stdin is passed ONLY for a heredoc — every other command keeps exactly
|
|
12938
|
+
# the stdin it has always had.
|
|
12939
|
+
proc = subprocess.Popen(
|
|
12940
|
+
argv, cwd=wd, env=_ws_scrubbed_env(),
|
|
12941
|
+
stdout=log_fh, stderr=subprocess.STDOUT,
|
|
12942
|
+
**({"stdin": _stdin_fh} if _stdin_fh is not None else {}),
|
|
12943
|
+
**_ws_spawn_kwargs(),
|
|
12944
|
+
)
|
|
12945
|
+
finally:
|
|
12946
|
+
if _stdin_fh is not None:
|
|
12947
|
+
_stdin_fh.close() # the child holds its own copy
|
|
12713
12948
|
_INFLIGHT_CMDS.add(proc.pid) # killed with the agent until it is classified
|
|
12714
12949
|
start = _t.time()
|
|
12950
|
+
_out_seen: dict = {} # how much of its output the terminal has been told about
|
|
12715
12951
|
|
|
12716
12952
|
def _read_log() -> str:
|
|
12717
12953
|
log_fh.flush()
|
|
@@ -12744,7 +12980,7 @@ def run_agent_chat(cfg):
|
|
|
12744
12980
|
if rc == 0 else f"FAILED (exit {rc})"
|
|
12745
12981
|
)
|
|
12746
12982
|
_emit({"type": "step", "text":
|
|
12747
|
-
f"Command {'finished' if rc == 0 else 'failed'} → {command
|
|
12983
|
+
f"Command {'finished' if rc == 0 else 'failed'} → {_command_label(command, 50)}"})
|
|
12748
12984
|
# SAY "no output" RATHER THAN SHOW NOTHING (task #204). An empty result
|
|
12749
12985
|
# was indistinguishable from a command that never ran — which is exactly
|
|
12750
12986
|
# what `cd X && Y` used to do — and the model read the silence as a
|
|
@@ -12818,6 +13054,8 @@ def run_agent_chat(cfg):
|
|
|
12818
13054
|
f"{t}s — killed. Output so far:\n{tail}")
|
|
12819
13055
|
_last_obs["text"] = msg
|
|
12820
13056
|
return msg
|
|
13057
|
+
log_fh.flush()
|
|
13058
|
+
_emit_command_progress(log_path, _out_seen)
|
|
12821
13059
|
_t.sleep(2)
|
|
12822
13060
|
except Exception as ex: # noqa: BLE001
|
|
12823
13061
|
msg = f"Error: run_command failed: {type(ex).__name__}: {ex}"
|