@tiens.nguyen/gu-cli 1.0.818 → 1.0.821
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 +525 -23
- package/gu-repl.mjs +38 -0
- package/package.json +2 -1
- package/terminal-title.mjs +165 -0
package/gonext_agent_chat.py
CHANGED
|
@@ -7346,6 +7346,477 @@ def _shell_syntax_error(command, argv):
|
|
|
7346
7346
|
"later read of it finds no such file. You already RECEIVE the command's output, "
|
|
7347
7347
|
"so just run it; use create_file if you genuinely need the text on disk.")
|
|
7348
7348
|
return None
|
|
7349
|
+
|
|
7350
|
+
|
|
7351
|
+
# ---- a REAL SHELL for run_command, checked per command (task #359, GitHub #139) ----------------
|
|
7352
|
+
# The no-shell rule above cost more than it protected. Over 21 days the model wrote a pipe ~207
|
|
7353
|
+
# times, `||` 60, `;` 30 — each refused and retried as another full model step — and 51 turns wrote
|
|
7354
|
+
# a body file only because `gh … --body` could not take multi-line text. It was never a sandbox
|
|
7355
|
+
# either: `python3 -c "import os; os.system('…')"` always ran anything. So on macOS/Linux a command
|
|
7356
|
+
# that NEEDS a shell now runs through `bash -c`, the way Claude Code's Bash tool does, and the
|
|
7357
|
+
# safety moves to where it can actually work: the line is scanned into its simple commands —
|
|
7358
|
+
# including those inside $( ), backticks and heredoc-fed commands — and EACH one goes through the
|
|
7359
|
+
# same checks an argv command gets (privilege, deny/allow lists, delete outside the project), plus
|
|
7360
|
+
# the two things only a shell can do: write outside the project with a redirect, and pipe text into
|
|
7361
|
+
# an interpreter. A command with no shell syntax still runs as argv, exactly as before.
|
|
7362
|
+
# Windows keeps the argv path (no bash to rely on there yet).
|
|
7363
|
+
_SHELL_OPS = ("&&", "||", ";;", "|&", "<<<", "<<-", "<<", "&>>", "&>", ">>", ">&", "<&", "<>",
|
|
7364
|
+
">|", ";", "|", "&", "(", ")", ">", "<")
|
|
7365
|
+
_SHELL_SEPARATORS = {"&&", "||", ";", ";;", "|", "|&", "&", "\n", "(", ")"}
|
|
7366
|
+
_SHELL_WRITE_REDIRECTS = {">", ">>", ">|", "&>", "&>>", "<>"}
|
|
7367
|
+
# Runners that execute TEXT as code. Piping into one with no script argument (`curl … | sh`) is the
|
|
7368
|
+
# shape a prompt injection takes, so it is blocked when nobody can be asked.
|
|
7369
|
+
_SHELL_INTERPRETERS = {"sh", "bash", "zsh", "dash", "ksh", "fish", "python", "python3", "node",
|
|
7370
|
+
"perl", "ruby", "php", "pwsh", "powershell"}
|
|
7371
|
+
_SHELL_EVAL_RUNNERS = {"eval", "source", "."}
|
|
7372
|
+
_SHELL_HARMLESS_TARGETS = {"/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty"}
|
|
7373
|
+
|
|
7374
|
+
|
|
7375
|
+
def _shell_scan(command):
|
|
7376
|
+
"""Tokenise a shell line the way bash reads it. PURE. → dict:
|
|
7377
|
+
|
|
7378
|
+
tokens — ("word", unquoted text, start, end, quoted) / ("op", text, start, end, False);
|
|
7379
|
+
start/end index the ORIGINAL string, so a word can be edited in place
|
|
7380
|
+
substs — [{"text", "tokens"}] for every $( ) and backtick body (scanned the same way)
|
|
7381
|
+
heredocs — how many heredocs were read (their bodies are DATA and appear in no token)
|
|
7382
|
+
expands — True when an unquoted $, glob or leading ~ needs the shell to expand it
|
|
7383
|
+
error — why the line cannot be run (unbalanced quote, heredoc never closed), or None
|
|
7384
|
+
"""
|
|
7385
|
+
out = {"tokens": [], "substs": [], "heredocs": 0, "expands": False, "error": None}
|
|
7386
|
+
try:
|
|
7387
|
+
_shell_scan_into(command or "", 0, out, out["tokens"], nested=False)
|
|
7388
|
+
except ValueError as e:
|
|
7389
|
+
out["error"] = str(e)
|
|
7390
|
+
return out
|
|
7391
|
+
|
|
7392
|
+
|
|
7393
|
+
def _shell_scan_into(s, i, out, toks, nested):
|
|
7394
|
+
"""Scan from `i` into `toks`. nested=True stops at the `)` closing a $( and returns past it."""
|
|
7395
|
+
n = len(s)
|
|
7396
|
+
st = {"word": None, "start": 0, "quoted": False, "delim_op": None}
|
|
7397
|
+
pending = [] # heredocs whose body starts after the next newline: (delimiter, op)
|
|
7398
|
+
depth = 0
|
|
7399
|
+
|
|
7400
|
+
def start_word(at):
|
|
7401
|
+
if st["word"] is None:
|
|
7402
|
+
st["word"], st["start"] = [], at
|
|
7403
|
+
|
|
7404
|
+
def end_word(at):
|
|
7405
|
+
if st["word"] is None:
|
|
7406
|
+
return
|
|
7407
|
+
text = "".join(st["word"])
|
|
7408
|
+
if st["delim_op"]:
|
|
7409
|
+
pending.append((text, st["delim_op"]))
|
|
7410
|
+
st["delim_op"] = None
|
|
7411
|
+
out["heredocs"] += 1
|
|
7412
|
+
toks.append(("word", text, st["start"], at, st["quoted"]))
|
|
7413
|
+
st["word"], st["quoted"] = None, False
|
|
7414
|
+
|
|
7415
|
+
def subst(j): # j = index just past "$("
|
|
7416
|
+
inner = []
|
|
7417
|
+
k = _shell_scan_into(s, j, out, inner, nested=True)
|
|
7418
|
+
out["substs"].append({"text": s[j:k - 1], "tokens": inner})
|
|
7419
|
+
out["expands"] = True
|
|
7420
|
+
return k
|
|
7421
|
+
|
|
7422
|
+
def backtick(j): # j = index of the opening `
|
|
7423
|
+
k = j + 1
|
|
7424
|
+
while k < n and s[k] != "`":
|
|
7425
|
+
k += 2 if s[k] == "\\" else 1
|
|
7426
|
+
if k >= n:
|
|
7427
|
+
raise ValueError("unbalanced backtick")
|
|
7428
|
+
inner = []
|
|
7429
|
+
_shell_scan_into(s[j + 1:k], 0, out, inner, nested=False)
|
|
7430
|
+
out["substs"].append({"text": s[j + 1:k], "tokens": inner})
|
|
7431
|
+
out["expands"] = True
|
|
7432
|
+
return k + 1
|
|
7433
|
+
|
|
7434
|
+
def closing(j, opener, closer): # j = index of opener's first char → index past its closer
|
|
7435
|
+
k, d = j + len(opener), 1
|
|
7436
|
+
while k < n and d:
|
|
7437
|
+
if s.startswith(opener, k):
|
|
7438
|
+
d, k = d + 1, k + len(opener)
|
|
7439
|
+
elif s.startswith(closer, k):
|
|
7440
|
+
d, k = d - 1, k + len(closer)
|
|
7441
|
+
else:
|
|
7442
|
+
k += 1
|
|
7443
|
+
if d:
|
|
7444
|
+
raise ValueError(f"unbalanced {opener}")
|
|
7445
|
+
return k
|
|
7446
|
+
|
|
7447
|
+
def dollar(j): # a `$` at j, outside single quotes → index past the expansion, or None
|
|
7448
|
+
if s.startswith("$((", j):
|
|
7449
|
+
out["expands"] = True
|
|
7450
|
+
return _past(j + 1, "(", ")")
|
|
7451
|
+
if s.startswith("$(", j):
|
|
7452
|
+
return subst(j + 2)
|
|
7453
|
+
if s.startswith("${", j):
|
|
7454
|
+
out["expands"] = True
|
|
7455
|
+
return closing(j + 1, "{", "}")
|
|
7456
|
+
if j + 1 < n and (s[j + 1].isalnum() or s[j + 1] in "_@*#?$!-"):
|
|
7457
|
+
out["expands"] = True
|
|
7458
|
+
return None
|
|
7459
|
+
|
|
7460
|
+
def _past(j, o, c): # j = index of the first o of a balanced o…c run
|
|
7461
|
+
k, d = j, 0
|
|
7462
|
+
while k < n:
|
|
7463
|
+
if s[k] == o:
|
|
7464
|
+
d += 1
|
|
7465
|
+
elif s[k] == c:
|
|
7466
|
+
d -= 1
|
|
7467
|
+
if d == 0:
|
|
7468
|
+
return k + 1
|
|
7469
|
+
k += 1
|
|
7470
|
+
raise ValueError("unbalanced $((")
|
|
7471
|
+
|
|
7472
|
+
while i < n:
|
|
7473
|
+
c = s[i]
|
|
7474
|
+
w = st["word"]
|
|
7475
|
+
if c in " \t":
|
|
7476
|
+
end_word(i)
|
|
7477
|
+
i += 1
|
|
7478
|
+
continue
|
|
7479
|
+
if c == "\n":
|
|
7480
|
+
end_word(i)
|
|
7481
|
+
toks.append(("op", "\n", i, i + 1, False))
|
|
7482
|
+
i += 1
|
|
7483
|
+
if pending:
|
|
7484
|
+
i = _shell_heredoc_bodies(s, i, pending)
|
|
7485
|
+
pending.clear()
|
|
7486
|
+
continue
|
|
7487
|
+
if c == "#" and w is None:
|
|
7488
|
+
j = s.find("\n", i)
|
|
7489
|
+
i = n if j < 0 else j
|
|
7490
|
+
continue
|
|
7491
|
+
if c == "\\":
|
|
7492
|
+
if i + 1 < n and s[i + 1] == "\n":
|
|
7493
|
+
i += 2
|
|
7494
|
+
continue
|
|
7495
|
+
start_word(i)
|
|
7496
|
+
if i + 1 < n:
|
|
7497
|
+
st["word"].append(s[i + 1])
|
|
7498
|
+
st["quoted"] = True
|
|
7499
|
+
i += 2
|
|
7500
|
+
continue
|
|
7501
|
+
if c == "'":
|
|
7502
|
+
start_word(i)
|
|
7503
|
+
j = s.find("'", i + 1)
|
|
7504
|
+
if j < 0:
|
|
7505
|
+
raise ValueError("unbalanced single quote")
|
|
7506
|
+
st["word"].extend(s[i + 1:j])
|
|
7507
|
+
st["quoted"] = True
|
|
7508
|
+
i = j + 1
|
|
7509
|
+
continue
|
|
7510
|
+
if c == '"':
|
|
7511
|
+
start_word(i)
|
|
7512
|
+
st["quoted"] = True
|
|
7513
|
+
i += 1
|
|
7514
|
+
while True:
|
|
7515
|
+
if i >= n:
|
|
7516
|
+
raise ValueError("unbalanced double quote")
|
|
7517
|
+
d = s[i]
|
|
7518
|
+
if d == '"':
|
|
7519
|
+
i += 1
|
|
7520
|
+
break
|
|
7521
|
+
if d == "\\" and i + 1 < n and s[i + 1] in '"\\$`\n':
|
|
7522
|
+
if s[i + 1] != "\n":
|
|
7523
|
+
st["word"].append(s[i + 1])
|
|
7524
|
+
i += 2
|
|
7525
|
+
continue
|
|
7526
|
+
if d == "$":
|
|
7527
|
+
k = dollar(i)
|
|
7528
|
+
if k is not None:
|
|
7529
|
+
st["word"].extend(s[i:k])
|
|
7530
|
+
i = k
|
|
7531
|
+
continue
|
|
7532
|
+
if d == "`":
|
|
7533
|
+
k = backtick(i)
|
|
7534
|
+
st["word"].extend(s[i:k])
|
|
7535
|
+
i = k
|
|
7536
|
+
continue
|
|
7537
|
+
st["word"].append(d)
|
|
7538
|
+
i += 1
|
|
7539
|
+
continue
|
|
7540
|
+
if c == "$":
|
|
7541
|
+
k = dollar(i)
|
|
7542
|
+
if k is not None:
|
|
7543
|
+
start_word(i)
|
|
7544
|
+
st["word"].extend(s[i:k])
|
|
7545
|
+
i = k
|
|
7546
|
+
continue
|
|
7547
|
+
if c == "`":
|
|
7548
|
+
start_word(i)
|
|
7549
|
+
k = backtick(i)
|
|
7550
|
+
st["word"].extend(s[i:k])
|
|
7551
|
+
i = k
|
|
7552
|
+
continue
|
|
7553
|
+
if nested and c == ")" and depth == 0:
|
|
7554
|
+
end_word(i)
|
|
7555
|
+
if pending:
|
|
7556
|
+
raise ValueError(f"heredoc '{pending[0][0]}' has no closing {pending[0][0]} line")
|
|
7557
|
+
return i + 1
|
|
7558
|
+
op = next((o for o in _SHELL_OPS if s.startswith(o, i)), None)
|
|
7559
|
+
if op:
|
|
7560
|
+
if op[0] in "<>" and w is not None and not st["quoted"] and "".join(w).isdigit():
|
|
7561
|
+
st["word"] = None # `2>`: the fd number belongs to the redirect, not the argv
|
|
7562
|
+
end_word(i)
|
|
7563
|
+
depth += 1 if op == "(" else -1 if op == ")" else 0
|
|
7564
|
+
toks.append(("op", op, i, i + len(op), False))
|
|
7565
|
+
if op in ("<<", "<<-"):
|
|
7566
|
+
st["delim_op"] = op
|
|
7567
|
+
i += len(op)
|
|
7568
|
+
continue
|
|
7569
|
+
start_word(i)
|
|
7570
|
+
if c in "*?[" or (c == "~" and not st["word"]):
|
|
7571
|
+
out["expands"] = True
|
|
7572
|
+
st["word"].append(c)
|
|
7573
|
+
i += 1
|
|
7574
|
+
end_word(n)
|
|
7575
|
+
if nested:
|
|
7576
|
+
raise ValueError("unbalanced $(")
|
|
7577
|
+
if pending:
|
|
7578
|
+
raise ValueError(f"heredoc '{pending[0][0]}' has no closing {pending[0][0]} line")
|
|
7579
|
+
return n
|
|
7580
|
+
|
|
7581
|
+
|
|
7582
|
+
def _shell_heredoc_bodies(s, i, pending):
|
|
7583
|
+
"""Skip the heredoc bodies that start at `i`, in order. → index past the last delimiter line."""
|
|
7584
|
+
n = len(s)
|
|
7585
|
+
for delim, op in pending:
|
|
7586
|
+
while True:
|
|
7587
|
+
if i >= n:
|
|
7588
|
+
raise ValueError(f"heredoc '{delim}' has no closing {delim} line")
|
|
7589
|
+
j = s.find("\n", i)
|
|
7590
|
+
line = s[i:] if j < 0 else s[i:j]
|
|
7591
|
+
i = n if j < 0 else j + 1
|
|
7592
|
+
if (line.lstrip("\t") if op == "<<-" else line) == delim:
|
|
7593
|
+
break
|
|
7594
|
+
return i
|
|
7595
|
+
|
|
7596
|
+
|
|
7597
|
+
def _shell_commands(tokens):
|
|
7598
|
+
"""Tokens → the simple commands: [{"argv", "spans", "sep", "redirects"}]. Leading VAR=value
|
|
7599
|
+
assignments and redirects (with their target) are not part of argv; `sep` is the operator
|
|
7600
|
+
that came before the command ("" for the first)."""
|
|
7601
|
+
cmds, cur, sep, i = [], None, "", 0
|
|
7602
|
+
while i < len(tokens):
|
|
7603
|
+
kind, text, a, b, quoted = tokens[i]
|
|
7604
|
+
if kind == "op" and text in _SHELL_SEPARATORS:
|
|
7605
|
+
if cur and (cur["argv"] or cur["redirects"]):
|
|
7606
|
+
cmds.append(cur)
|
|
7607
|
+
cur, sep = None, text
|
|
7608
|
+
i += 1
|
|
7609
|
+
continue
|
|
7610
|
+
if cur is None:
|
|
7611
|
+
cur = {"argv": [], "spans": [], "sep": sep, "redirects": []}
|
|
7612
|
+
if kind == "op":
|
|
7613
|
+
nxt = tokens[i + 1] if i + 1 < len(tokens) else None
|
|
7614
|
+
tgt = nxt[1] if nxt and nxt[0] == "word" else ""
|
|
7615
|
+
cur["redirects"].append((text, tgt))
|
|
7616
|
+
i += 2 if tgt or (nxt and nxt[0] == "word") else 1
|
|
7617
|
+
continue
|
|
7618
|
+
if not cur["argv"] and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", text):
|
|
7619
|
+
i += 1
|
|
7620
|
+
continue
|
|
7621
|
+
cur["argv"].append(text)
|
|
7622
|
+
cur["spans"].append((a, b))
|
|
7623
|
+
i += 1
|
|
7624
|
+
if cur and (cur["argv"] or cur["redirects"]):
|
|
7625
|
+
cmds.append(cur)
|
|
7626
|
+
return cmds
|
|
7627
|
+
|
|
7628
|
+
|
|
7629
|
+
def _shell_needed(scan):
|
|
7630
|
+
"""Does this line need a shell to mean what it says? False → run it as argv, as before."""
|
|
7631
|
+
return bool(scan["heredocs"] or scan["substs"] or scan["expands"]
|
|
7632
|
+
or any(t[0] == "op" for t in scan["tokens"]))
|
|
7633
|
+
|
|
7634
|
+
|
|
7635
|
+
def _shell_all_commands(scan):
|
|
7636
|
+
"""(top-level commands, commands inside $( ) / backticks)."""
|
|
7637
|
+
top = _shell_commands(scan["tokens"])
|
|
7638
|
+
inner = [c for sub in scan["substs"] for c in _shell_commands(sub["tokens"])]
|
|
7639
|
+
return top, inner
|
|
7640
|
+
|
|
7641
|
+
|
|
7642
|
+
def _shell_background_error(scan):
|
|
7643
|
+
"""A line that puts a command in the background (`npm run dev &`), or None. bash would exit at
|
|
7644
|
+
once and leave that process running where stop_server cannot see it, and the model would read
|
|
7645
|
+
"EXITED 0" as the server having started. run_command already keeps a server running."""
|
|
7646
|
+
if any(t[0] == "op" and t[1] == "&" for t in scan["tokens"]):
|
|
7647
|
+
return ("Error: that command did NOT run. Do not use `&` to background it — run_command "
|
|
7648
|
+
"already leaves a server or app RUNNING when it starts listening or opens a window, "
|
|
7649
|
+
"and reports its URL. Send the same command without the `&`.")
|
|
7650
|
+
return None
|
|
7651
|
+
|
|
7652
|
+
|
|
7653
|
+
def _shell_priv_reason(scan):
|
|
7654
|
+
"""_ws_priv_reason over every simple command — NOT the raw line, so a heredoc body that merely
|
|
7655
|
+
mentions sudo (an issue comment about it) is data, while `bash -c "sudo …"` is still caught."""
|
|
7656
|
+
top, inner = _shell_all_commands(scan)
|
|
7657
|
+
for c in top + inner:
|
|
7658
|
+
r = _ws_priv_reason(" ".join(c["argv"]))
|
|
7659
|
+
if r:
|
|
7660
|
+
return r
|
|
7661
|
+
return None
|
|
7662
|
+
|
|
7663
|
+
|
|
7664
|
+
def _ws_write_outside_reason(redirects, wd, roots=None):
|
|
7665
|
+
"""Why a redirect needs the user's yes: it writes a file OUTSIDE every workspace and the temp
|
|
7666
|
+
folder. The shell's counterpart of _ws_delete_outside_reason."""
|
|
7667
|
+
import tempfile
|
|
7668
|
+
targets = [t for op, t in redirects
|
|
7669
|
+
if op in _SHELL_WRITE_REDIRECTS and t and not t.startswith("&")
|
|
7670
|
+
and t not in _SHELL_HARMLESS_TARGETS]
|
|
7671
|
+
if not targets:
|
|
7672
|
+
return None
|
|
7673
|
+
ws = [os.path.realpath(r["path"]) for r in (_WS_ROOTS if roots is None else roots) if r.get("path")]
|
|
7674
|
+
if not ws:
|
|
7675
|
+
return None
|
|
7676
|
+
temp = [os.path.realpath(p) for p in {tempfile.gettempdir(), "/tmp", "/var/tmp"} if p]
|
|
7677
|
+
outside = []
|
|
7678
|
+
for t in targets:
|
|
7679
|
+
p = os.path.expanduser(t)
|
|
7680
|
+
p = os.path.realpath(p if os.path.isabs(p) else os.path.join(wd or os.getcwd(), p))
|
|
7681
|
+
if not (_ws_is_inside(p, ws) or _ws_is_inside(p, temp)):
|
|
7682
|
+
outside.append(t)
|
|
7683
|
+
if not outside:
|
|
7684
|
+
return None
|
|
7685
|
+
return "it writes a file OUTSIDE the project (" + ", ".join(outside[:3]) + ")"
|
|
7686
|
+
|
|
7687
|
+
|
|
7688
|
+
def _ws_shell_command_risk(scan, denyset, wd="", allowset=None, roots=None):
|
|
7689
|
+
"""The run policy for a SHELL line, applied to each simple command in it.
|
|
7690
|
+
|
|
7691
|
+
→ (refuse, hard_reason, ask_reason): `refuse` is a finished error message (allowlist — never
|
|
7692
|
+
asked, same as argv); hard/ask follow _ws_command_risk's contract (hard = block when nobody can
|
|
7693
|
+
be asked, ask = confirm when someone can)."""
|
|
7694
|
+
top, inner = _shell_all_commands(scan)
|
|
7695
|
+
every = top + inner
|
|
7696
|
+
if allowset:
|
|
7697
|
+
for c in every:
|
|
7698
|
+
r = c["argv"][0] if c["argv"] else ""
|
|
7699
|
+
if r and r not in allowset and os.path.basename(r) not in allowset:
|
|
7700
|
+
return (f"Error: '{r}' is not in your run allowlist. Add it in web Settings → Agent, "
|
|
7701
|
+
"or clear the allowlist to allow all commands.", None, None)
|
|
7702
|
+
priv = _shell_priv_reason(scan)
|
|
7703
|
+
if priv:
|
|
7704
|
+
return (None, priv, priv)
|
|
7705
|
+
for c in every:
|
|
7706
|
+
r = c["argv"][0] if c["argv"] else ""
|
|
7707
|
+
if r and denyset and (r in denyset or os.path.basename(r) in denyset):
|
|
7708
|
+
msg = f"'{r}' is on your blocked-commands list"
|
|
7709
|
+
return (None, msg, msg)
|
|
7710
|
+
# Deletes and redirect-writes outside the project, following `cd` the way the shell will.
|
|
7711
|
+
cur = wd
|
|
7712
|
+
for c in top:
|
|
7713
|
+
a = c["argv"]
|
|
7714
|
+
if a and a[0] == "cd" and len(a) >= 2 and c["sep"] not in ("|", "|&"):
|
|
7715
|
+
cur = os.path.normpath(os.path.join(cur or os.getcwd(), os.path.expanduser(a[1])))
|
|
7716
|
+
continue
|
|
7717
|
+
why = (_ws_delete_outside_reason(a, cur, roots)
|
|
7718
|
+
or _ws_write_outside_reason(c["redirects"], cur, roots))
|
|
7719
|
+
if why:
|
|
7720
|
+
return (None, why, why)
|
|
7721
|
+
for c in inner:
|
|
7722
|
+
why = _ws_delete_outside_reason(c["argv"], wd, roots)
|
|
7723
|
+
if why:
|
|
7724
|
+
return (None, why, why)
|
|
7725
|
+
for c in top:
|
|
7726
|
+
a = c["argv"]
|
|
7727
|
+
r = os.path.basename(a[0]).lower() if a else ""
|
|
7728
|
+
if c["sep"] in ("|", "|&") and r in _SHELL_INTERPRETERS \
|
|
7729
|
+
and all(x.startswith("-") for x in a[1:]):
|
|
7730
|
+
msg = f"it pipes text into {r}, which runs that text as code"
|
|
7731
|
+
return (None, msg, msg)
|
|
7732
|
+
for c in every:
|
|
7733
|
+
a = c["argv"]
|
|
7734
|
+
if not a:
|
|
7735
|
+
continue
|
|
7736
|
+
if a[0] in _SHELL_EVAL_RUNNERS:
|
|
7737
|
+
return (None, None, f"it runs text as code ({a[0]})")
|
|
7738
|
+
if "$" in a[0] or "`" in a[0]:
|
|
7739
|
+
return (None, None, f"the program to run is only known at run time ({a[0][:40]})")
|
|
7740
|
+
for c in every:
|
|
7741
|
+
if _WS_DESTRUCTIVE_RE.search(" ".join(c["argv"])):
|
|
7742
|
+
return (None, None, "a destructive command (deletes files / wipes disk / powers off)")
|
|
7743
|
+
return (None, None, None)
|
|
7744
|
+
|
|
7745
|
+
|
|
7746
|
+
def _with_attribution_text(command, scan, settings):
|
|
7747
|
+
"""_with_attribution for a SHELL line: the co-author trailer is inserted into the text right
|
|
7748
|
+
after each top-level `git … commit` word. → (command, note). A gh pr create body written as a
|
|
7749
|
+
heredoc cannot be appended to safely, so it is left alone and said so in the note."""
|
|
7750
|
+
import shlex as _shlex
|
|
7751
|
+
name = str(settings.get("name") or "").strip()
|
|
7752
|
+
email = str(settings.get("email") or "").strip()
|
|
7753
|
+
cmds = _shell_commands(scan["tokens"])
|
|
7754
|
+
notes = []
|
|
7755
|
+
if settings.get("commit") and name and email and email.lower() not in command.lower():
|
|
7756
|
+
at = []
|
|
7757
|
+
for c in cmds:
|
|
7758
|
+
i, sub = _git_subcommand(c["argv"])
|
|
7759
|
+
if sub == "commit":
|
|
7760
|
+
at.append(c["spans"][i][1])
|
|
7761
|
+
if at:
|
|
7762
|
+
trailer = " --trailer " + _shlex.quote(f"Co-Authored-By: {name} <{email}>")
|
|
7763
|
+
for pos in sorted(at, reverse=True):
|
|
7764
|
+
command = command[:pos] + trailer + command[pos:]
|
|
7765
|
+
notes.append(f"co-author trailer added ({name})")
|
|
7766
|
+
if settings.get("pr") and any(c["argv"][:3] == ["gh", "pr", "create"] for c in cmds):
|
|
7767
|
+
notes.append("PR footer not added (shell form)")
|
|
7768
|
+
return command, "; ".join(notes)
|
|
7769
|
+
|
|
7770
|
+
|
|
7771
|
+
_RUN_SHELL_BIN = None
|
|
7772
|
+
|
|
7773
|
+
|
|
7774
|
+
def _run_shell_bin():
|
|
7775
|
+
"""bash (else sh) for run_command's shell lines, or "" for the argv-only path: Windows, or
|
|
7776
|
+
GU_RUN_NO_SHELL=1 (the way back if a shell line ever misbehaves for a user)."""
|
|
7777
|
+
global _RUN_SHELL_BIN
|
|
7778
|
+
if os.name == "nt" or os.environ.get("GU_RUN_NO_SHELL") == "1":
|
|
7779
|
+
return ""
|
|
7780
|
+
if _RUN_SHELL_BIN is None:
|
|
7781
|
+
import shutil as _sh
|
|
7782
|
+
_RUN_SHELL_BIN = next((p for p in (_sh.which("bash"), "/bin/bash", "/bin/sh")
|
|
7783
|
+
if p and os.path.exists(p)), "")
|
|
7784
|
+
return _RUN_SHELL_BIN
|
|
7785
|
+
|
|
7786
|
+
|
|
7787
|
+
def _run_command_prompt_line(shell):
|
|
7788
|
+
"""The system prompt's run_command rule. With a shell it carries the git and GitHub recipes,
|
|
7789
|
+
the way Claude Code's Bash tool description does: told how to batch, the model does (#359)."""
|
|
7790
|
+
if not shell:
|
|
7791
|
+
return (
|
|
7792
|
+
" run_command CHAINS: put dependent commands in ONE call with && — e.g. "
|
|
7793
|
+
"run_command(\"cd app && ./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk\") "
|
|
7794
|
+
"— they run in order, `cd` sets the folder for the rest, and the first failure stops the "
|
|
7795
|
+
"chain. There is NO shell otherwise: pipes `|`, redirects `>`, `;`, `||` and backticks do "
|
|
7796
|
+
"NOT work (use python3 for anything that composes). `sudo`/`su` are blocked; an "
|
|
7797
|
+
"interactive input() CLI fails with EOFError. A server or GUI app is left RUNNING — "
|
|
7798
|
+
"stop_server closes it.\n")
|
|
7799
|
+
return (
|
|
7800
|
+
" run_command IS A SHELL (bash): pipes, redirects, `;`, `||`, `$( )`, globs and heredocs "
|
|
7801
|
+
"work, so put a whole sequence in ONE call — dependent steps with &&, independent ones with "
|
|
7802
|
+
"`;` (e.g. `cd app && npm test 2>&1 | tail -40`); a multi-line command goes in a "
|
|
7803
|
+
"triple-quoted string.\n"
|
|
7804
|
+
" GIT in one call each: look (`git status -sb; git diff --stat; git log --oneline -5`), "
|
|
7805
|
+
"commit (`git add a.py b.py && git commit -m \"msg\" && git log --oneline -1`).\n"
|
|
7806
|
+
" GITHUB: use `gh`. Multi-line text goes as a heredoc on stdin — "
|
|
7807
|
+
"`gh issue comment 12 --body-file - <<'EOF'` … `EOF` (same for gh issue create / gh pr "
|
|
7808
|
+
"create) — never write a body file first, and do not run `gh auth status` first.\n"
|
|
7809
|
+
" Read and search FILES with read_file_lines / grep_repo, not cat/grep/sed. `sudo`/`su` "
|
|
7810
|
+
"are blocked; piping into sh/python and writing or deleting outside the project ask the "
|
|
7811
|
+
"user first; an interactive input() CLI fails with EOFError. A server or GUI app is left "
|
|
7812
|
+
"RUNNING — stop_server closes it.\n")
|
|
7813
|
+
|
|
7814
|
+
|
|
7815
|
+
_RUN_DOC_ARGV = ("No other shell syntax (no pipes/redirects/;). sudo/su are blocked.")
|
|
7816
|
+
_RUN_DOC_SHELL = ("It runs in bash, so pipes, redirects, ;, ||, $( ), globs and heredocs work too "
|
|
7817
|
+
"(e.g. 'npm test 2>&1 | tail -40'). sudo/su are blocked.")
|
|
7818
|
+
|
|
7819
|
+
|
|
7349
7820
|
# The terminal's "accept edits" toggle (task #179). True = today's behaviour, where only risky
|
|
7350
7821
|
# commands are confirmed. False = confirm every command. Module-level default so the tool body
|
|
7351
7822
|
# reads a real boolean even on a path where run() never set it.
|
|
@@ -9579,13 +10050,7 @@ def run_agent_chat(cfg):
|
|
|
9579
10050
|
"and reads do not pile up here and are not re-sent on every later step. Then read only "
|
|
9580
10051
|
"the exact ranges you will edit. Use grep_repo/read_file_lines directly when you already "
|
|
9581
10052
|
"know the file or the exact string.\n"
|
|
9582
|
-
|
|
9583
|
-
"run_command(\"cd app && ./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk\") "
|
|
9584
|
-
"— they run in order, `cd` sets the folder for the rest, and the first failure stops the "
|
|
9585
|
-
"chain. There is NO shell otherwise: pipes `|`, redirects `>`, `;`, `||` and backticks do "
|
|
9586
|
-
"NOT work (use python3 for anything that composes). `sudo`/`su` are blocked; an "
|
|
9587
|
-
"interactive input() CLI fails with EOFError. A server or GUI app is left RUNNING — "
|
|
9588
|
-
"stop_server closes it.\n"
|
|
10053
|
+
+ _run_command_prompt_line(bool(_run_shell_bin())) +
|
|
9589
10054
|
" DEPLOY: prefer deploy_web(local_dir, host, user, remote_path, domain) over "
|
|
9590
10055
|
"hand-written scp/ssh/rsync (it rsyncs over your SSH key and finishes nginx when it "
|
|
9591
10056
|
"can, else writes deploy.sh and returns the exact commands). KEY auth ONLY "
|
|
@@ -13529,7 +13994,6 @@ def run_agent_chat(cfg):
|
|
|
13529
13994
|
_last_obs["text"] = msg
|
|
13530
13995
|
return msg
|
|
13531
13996
|
|
|
13532
|
-
@tool
|
|
13533
13997
|
def run_command(command: str, workdir: str = "", timeout_seconds: int = 180) -> str:
|
|
13534
13998
|
"""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.
|
|
13535
13999
|
|
|
@@ -13579,7 +14043,21 @@ def run_agent_chat(cfg):
|
|
|
13579
14043
|
"gu-local-worker workspace add <path> --allow-run)")
|
|
13580
14044
|
_last_obs["text"] = msg
|
|
13581
14045
|
return msg
|
|
13582
|
-
|
|
14046
|
+
# A REAL SHELL WHEN THE LINE NEEDS ONE (task #359). Scanned BEFORE shlex.split,
|
|
14047
|
+
# which raises on a heredoc body with an apostrophe in it ("don't").
|
|
14048
|
+
_shell_bin = _run_shell_bin()
|
|
14049
|
+
_scan = _shell_scan(command) if _shell_bin else None
|
|
14050
|
+
if _scan and _scan["error"]:
|
|
14051
|
+
msg = (f"Error: that command did NOT run — {_scan['error']}. Nothing was "
|
|
14052
|
+
"executed; fix the quoting and send it again.")
|
|
14053
|
+
_last_obs["text"] = msg
|
|
14054
|
+
return msg
|
|
14055
|
+
_use_shell = bool(_scan) and _shell_needed(_scan)
|
|
14056
|
+
_bg_err = _shell_background_error(_scan) if _use_shell else None
|
|
14057
|
+
if _bg_err:
|
|
14058
|
+
_last_obs["text"] = _bg_err
|
|
14059
|
+
return _bg_err
|
|
14060
|
+
argv =[_shell_bin, "-c", command] if _use_shell else shlex.split(command or "")
|
|
13583
14061
|
if not argv:
|
|
13584
14062
|
msg = "Error: empty command."
|
|
13585
14063
|
_last_obs["text"] = msg
|
|
@@ -13637,7 +14115,7 @@ def run_agent_chat(cfg):
|
|
|
13637
14115
|
# executes the `cd` PROGRAM — which exits 0 with no output — and the step is
|
|
13638
14116
|
# reported as a success that did nothing. Refusing costs one step; the observed
|
|
13639
14117
|
# alternative cost two hour-long turns and a false report to the user.
|
|
13640
|
-
_shell_err = _shell_syntax_error(command, argv)
|
|
14118
|
+
_shell_err = None if _use_shell else _shell_syntax_error(command, argv)
|
|
13641
14119
|
if _shell_err:
|
|
13642
14120
|
_log(f"run_command refused shell syntax: {_c(_clip(command, 120))}")
|
|
13643
14121
|
_last_obs["text"] = _shell_err
|
|
@@ -13646,16 +14124,26 @@ def run_agent_chat(cfg):
|
|
|
13646
14124
|
# `/usr/bin/<x>` can't slip past a bare-name rule.
|
|
13647
14125
|
_runner = argv[0]
|
|
13648
14126
|
_rbase = os.path.basename(_runner)
|
|
13649
|
-
|
|
13650
|
-
|
|
13651
|
-
|
|
13652
|
-
|
|
13653
|
-
|
|
13654
|
-
|
|
13655
|
-
|
|
13656
|
-
|
|
13657
|
-
|
|
13658
|
-
|
|
14127
|
+
if _use_shell:
|
|
14128
|
+
# Every simple command in the line, including those inside $( ), gets the
|
|
14129
|
+
# checks an argv command gets — allowlist, privilege, denylist, deletes —
|
|
14130
|
+
# plus the shell-only ones (redirect outside the project, `| sh`).
|
|
14131
|
+
_refuse, _hard_reason, _ask_reason = _ws_shell_command_risk(
|
|
14132
|
+
_scan, _WS_RUN_DENYLIST, wd, _WS_RUN_ALLOWLIST)
|
|
14133
|
+
if _refuse:
|
|
14134
|
+
_last_obs["text"] = _refuse
|
|
14135
|
+
return _refuse
|
|
14136
|
+
else:
|
|
14137
|
+
# Opt-in allowlist lockdown: outside the set = hard refuse, never asked.
|
|
14138
|
+
if _WS_RUN_ALLOWLIST and _runner not in _WS_RUN_ALLOWLIST and _rbase not in _WS_RUN_ALLOWLIST:
|
|
14139
|
+
msg = (f"Error: '{_runner}' is not in your run allowlist. Add it in web "
|
|
14140
|
+
"Settings → Agent, or clear the allowlist to allow all commands.")
|
|
14141
|
+
_last_obs["text"] = msg
|
|
14142
|
+
return msg
|
|
14143
|
+
# Risk gate: privilege escalation / denylisted / destructive. When the
|
|
14144
|
+
# client can approve interactively (terminal), PAUSE and ask the user;
|
|
14145
|
+
# otherwise (web) keep hard-blocking the privilege/denylist cases.
|
|
14146
|
+
_hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST, wd)
|
|
13659
14147
|
# Shift+Tab may have switched it since the turn started (#47).
|
|
13660
14148
|
global _accept_edits
|
|
13661
14149
|
if _interactive_approval and _job_id:
|
|
@@ -13671,7 +14159,7 @@ def run_agent_chat(cfg):
|
|
|
13671
14159
|
# states it. "this needs root and sudo needs a password we cannot type" is a
|
|
13672
14160
|
# different question from "sudo is passwordless here", and the user can only
|
|
13673
14161
|
# answer the one they are shown.
|
|
13674
|
-
_priv = _ws_priv_reason(command)
|
|
14162
|
+
_priv = _shell_priv_reason(_scan) if _use_shell else _ws_priv_reason(command)
|
|
13675
14163
|
_facts = _ws_access_facts() if _priv else None
|
|
13676
14164
|
if _priv and _facts and not _facts["can_elevate"]:
|
|
13677
14165
|
# REFUSE BEFORE SPAWNING (requirement 5). With no tty, `sudo x` dies with
|
|
@@ -13734,13 +14222,21 @@ def run_agent_chat(cfg):
|
|
|
13734
14222
|
log_fh = open(log_path, "wb")
|
|
13735
14223
|
# gu as a co-author on the commits it makes (task #289) — after every check
|
|
13736
14224
|
# above, which judged the command as the model wrote it.
|
|
13737
|
-
|
|
14225
|
+
if _use_shell:
|
|
14226
|
+
_cmd_text, _attrib_note = _with_attribution_text(
|
|
14227
|
+
command, _scan, _attribution_settings(wd))
|
|
14228
|
+
argv = [_shell_bin, "-c", _cmd_text]
|
|
14229
|
+
else:
|
|
14230
|
+
argv, _attrib_note = _with_attribution(argv, _attribution_settings(wd))
|
|
13738
14231
|
if _attrib_note:
|
|
13739
14232
|
_log(f"run_command: {_attrib_note}")
|
|
13740
|
-
# Scrubbed env: repo scripts must never see worker secrets.
|
|
14233
|
+
# Scrubbed env: repo scripts must never see worker secrets. A shell line gets an
|
|
14234
|
+
# empty stdin: a heredoc feeds its own, and `--body-file -` without one must
|
|
14235
|
+
# end at EOF rather than wait on the worker's pipe.
|
|
13741
14236
|
proc = subprocess.Popen(
|
|
13742
14237
|
argv, cwd=wd, env=_ws_scrubbed_env(),
|
|
13743
14238
|
stdout=log_fh, stderr=subprocess.STDOUT,
|
|
14239
|
+
**({"stdin": subprocess.DEVNULL} if _use_shell else {}),
|
|
13744
14240
|
**_ws_spawn_kwargs(),
|
|
13745
14241
|
)
|
|
13746
14242
|
_INFLIGHT_CMDS.add(proc.pid) # killed with the agent until it is classified
|
|
@@ -13866,6 +14362,12 @@ def run_agent_chat(cfg):
|
|
|
13866
14362
|
except Exception: # noqa: BLE001
|
|
13867
14363
|
pass
|
|
13868
14364
|
|
|
14365
|
+
# The description says what the command line may contain, which depends on the machine:
|
|
14366
|
+
# bash here, argv only on Windows (#359). Set before tool() reads the docstring.
|
|
14367
|
+
if _run_shell_bin():
|
|
14368
|
+
run_command.__doc__ = run_command.__doc__.replace(_RUN_DOC_ARGV, _RUN_DOC_SHELL)
|
|
14369
|
+
run_command = tool(run_command)
|
|
14370
|
+
|
|
13869
14371
|
@tool
|
|
13870
14372
|
def stop_server(port_or_pid: str = "") -> str:
|
|
13871
14373
|
"""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.
|
package/gu-repl.mjs
CHANGED
|
@@ -154,6 +154,7 @@ import {
|
|
|
154
154
|
parseModelFallback,
|
|
155
155
|
} from "./model-fallback.mjs";
|
|
156
156
|
import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
|
|
157
|
+
import { TITLE_POP, TITLE_PUSH, idleTitle, needsSummary, summariseForTitle, titleSeq, trimTitle } from "./terminal-title.mjs";
|
|
157
158
|
import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
|
|
158
159
|
import {
|
|
159
160
|
isDoubleClick,
|
|
@@ -1603,6 +1604,40 @@ if (IS_TTY) {
|
|
|
1603
1604
|
process.on("exit", () => _stdoutWrite(PASTE_MODE_OFF));
|
|
1604
1605
|
}
|
|
1605
1606
|
|
|
1607
|
+
// THE TAB SAYS WHAT IS BEING WORKED ON (task #357) — see terminal-title.mjs. Until the first
|
|
1608
|
+
// question it names the folder, which is already more than the `node /opt/homebrew/…/gu` a
|
|
1609
|
+
// terminal shows for a program that never names itself.
|
|
1610
|
+
const TITLE_ON = IS_TTY && process.env.GU_NO_TITLE !== "1";
|
|
1611
|
+
let _titleSeq = 0; // which question the title belongs to; a late summary for an older one is dropped
|
|
1612
|
+
const writeTitle = (text) => {
|
|
1613
|
+
if (TITLE_ON) ui(titleSeq(text));
|
|
1614
|
+
};
|
|
1615
|
+
if (TITLE_ON) {
|
|
1616
|
+
ui(TITLE_PUSH);
|
|
1617
|
+
writeTitle(idleTitle(process.cwd()));
|
|
1618
|
+
// Hand the title back: restore the one that was there where the terminal keeps a stack, and
|
|
1619
|
+
// clear ours where it does not, so the shell is not left wearing the last question.
|
|
1620
|
+
process.on("exit", () => _stdoutWrite(titleSeq("") + TITLE_POP));
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* A new question: the trimmed text now, and — only when it had to be cut — the agent model's
|
|
1625
|
+
* few-word summary whenever it arrives. Fire-and-forget by design: it never delays the turn,
|
|
1626
|
+
* never prints, and a summary that lands after the NEXT question is thrown away.
|
|
1627
|
+
*/
|
|
1628
|
+
function showQuestionInTitle(question) {
|
|
1629
|
+
if (!TITLE_ON) return;
|
|
1630
|
+
const mine = ++_titleSeq;
|
|
1631
|
+
writeTitle(trimTitle(question));
|
|
1632
|
+
if (!needsSummary(question)) return;
|
|
1633
|
+
void (async () => {
|
|
1634
|
+
const settings = await fetchWorkerSettings();
|
|
1635
|
+
if (!settings || mine !== _titleSeq) return;
|
|
1636
|
+
const title = await summariseForTitle(question, settings, { fetchImpl: doctorFetch });
|
|
1637
|
+
if (title && mine === _titleSeq) writeTitle(title);
|
|
1638
|
+
})().catch(() => {});
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1606
1641
|
// ">>" is a MARGIN MARKER like the ● bullets — it hangs in the gutter (no leading gutter) so
|
|
1607
1642
|
// the user's typed input lands on the SAME text column (GUTTER+1) as the agent's bullets and
|
|
1608
1643
|
// answer. ">> " is exactly GUTTER-wide, so input aligns; the ">>" right-edge sits at the same
|
|
@@ -14348,6 +14383,9 @@ async function main() {
|
|
|
14348
14383
|
history.push({ role: "user", content: taskLine });
|
|
14349
14384
|
}
|
|
14350
14385
|
await saveSession(cwd, history, { unfinishedTask: isResumeWord(taskLine) ? "" : taskLine });
|
|
14386
|
+
// #357: the tab is this question until the next one. A bare "continue" with nothing to
|
|
14387
|
+
// resume says nothing about the work, so the title it follows stays.
|
|
14388
|
+
if (!isResumeWord(taskLine)) showQuestionInTitle(taskLine);
|
|
14351
14389
|
try {
|
|
14352
14390
|
const turnStartMs = Date.now();
|
|
14353
14391
|
// #323: priced by the coder this turn STARTS on — a /model switch afterwards must not put
|
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.821",
|
|
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",
|
|
@@ -115,6 +115,7 @@
|
|
|
115
115
|
"model-doctor.mjs",
|
|
116
116
|
"coding-key-prompt.mjs",
|
|
117
117
|
"model-fallback.mjs",
|
|
118
|
+
"terminal-title.mjs",
|
|
118
119
|
"server-keys.mjs",
|
|
119
120
|
"add-server.mjs",
|
|
120
121
|
"powershell-alias.mjs",
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TERMINAL'S TITLE IS THE QUESTION BEING WORKED ON (task #357).
|
|
3
|
+
*
|
|
4
|
+
* Reported: the tab said `node /opt/homebrew/…/gu` — where the gu bin lives, which is what a
|
|
5
|
+
* terminal shows when the program inside never names itself. With several gu tabs open that is
|
|
6
|
+
* the same useless string on every one. The ask: show the current turn's question instead,
|
|
7
|
+
* shortened, and keep it there until the next question.
|
|
8
|
+
*
|
|
9
|
+
* TWO STEPS, AND THE FIRST NEVER WAITS FOR THE SECOND. The trimmed question goes up the moment
|
|
10
|
+
* the turn starts. Only a question too long to fit is then sent to the agent model for a
|
|
11
|
+
* few-word summary, which replaces the trimmed one if — and only if — it arrives usable and the
|
|
12
|
+
* user has not asked something else in the meantime. Every way that call can fail (no model
|
|
13
|
+
* configured, a provider that wants a key this side does not hold, a timeout, a model that
|
|
14
|
+
* answers with an essay) ends the same way: the trimmed question stays.
|
|
15
|
+
*
|
|
16
|
+
* WHY SHORT QUESTIONS ARE NOT SUMMARISED. The agent model is the one the turn itself is about to
|
|
17
|
+
* use, and a single-GPU Ollama queues requests rather than running them side by side — a summary
|
|
18
|
+
* of "fix the login test" would cost the turn its first seconds to say the same thing in fewer
|
|
19
|
+
* words. A question that already fits is its own best title.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no terminal and no network of its own. `fetchImpl` is injected, and the caller writes
|
|
22
|
+
* the bytes.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** What fits on a tab next to its neighbours. Terminals clip a title; they do not wrap it. */
|
|
26
|
+
export const TITLE_MAX = 60;
|
|
27
|
+
|
|
28
|
+
/** How much of a long question the summary is given — the ask is in the opening lines. */
|
|
29
|
+
export const SUMMARY_INPUT_MAX = 1200;
|
|
30
|
+
|
|
31
|
+
const ELLIPSIS = "…";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One line of plain text, safe to put inside an escape sequence.
|
|
35
|
+
*
|
|
36
|
+
* NOT COSMETIC. The title is written as `ESC ] 0 ; <text> BEL`, so a BEL or ESC inside the text
|
|
37
|
+
* ends the sequence early and the rest is read by the terminal as commands — and the text is
|
|
38
|
+
* whatever was typed or PASTED as a question. Every C0/C1 control character goes, newlines
|
|
39
|
+
* included (as spaces: a pasted stack trace is one title, not twenty).
|
|
40
|
+
*/
|
|
41
|
+
export function cleanTitleText(text) {
|
|
42
|
+
return String(text ?? "")
|
|
43
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
|
|
44
|
+
.replace(/\s+/g, " ")
|
|
45
|
+
.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The question cut to fit, on a word boundary when there is one in the last third. */
|
|
49
|
+
export function trimTitle(text, max = TITLE_MAX) {
|
|
50
|
+
const s = cleanTitleText(text);
|
|
51
|
+
const chars = Array.from(s); // by code point: never half of an emoji or a Vietnamese letter
|
|
52
|
+
if (chars.length <= max) return s;
|
|
53
|
+
const cut = chars.slice(0, max - 1).join("");
|
|
54
|
+
const space = cut.lastIndexOf(" ");
|
|
55
|
+
const head = space >= Math.floor((max * 2) / 3) ? cut.slice(0, space) : cut;
|
|
56
|
+
return head.replace(/[\s,;:.\-–—]+$/u, "") + ELLIPSIS;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Only a question that had to be cut is worth a model call (see the header). */
|
|
60
|
+
export function needsSummary(text, max = TITLE_MAX) {
|
|
61
|
+
return Array.from(cleanTitleText(text)).length > max;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The bytes that set the window and tab title. "" hands the title back to the terminal. */
|
|
65
|
+
export function titleSeq(text) {
|
|
66
|
+
return `\x1b]0;${cleanTitleText(text)}\x07`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** xterm's title stack: remember the title that was there, and put it back on the way out.
|
|
70
|
+
* Terminals that do not have one ignore both. */
|
|
71
|
+
export const TITLE_PUSH = "\x1b[22;0t";
|
|
72
|
+
export const TITLE_POP = "\x1b[23;0t";
|
|
73
|
+
|
|
74
|
+
/** What the tab says before anything has been asked. */
|
|
75
|
+
export function idleTitle(folder) {
|
|
76
|
+
const name = cleanTitleText(String(folder ?? "").split(/[\\/]/).filter(Boolean).pop() ?? "");
|
|
77
|
+
return name ? `gu · ${trimTitle(name, TITLE_MAX - 5)}` : "gu";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Where the agent model answers chat, from a base that may or may not already end in /v1. */
|
|
81
|
+
export function chatEndpoint(url) {
|
|
82
|
+
const root = String(url ?? "").trim().replace(/\/+$/, "");
|
|
83
|
+
if (!root) return "";
|
|
84
|
+
return /\/v1$/i.test(root) ? `${root}/chat/completions` : `${root}/v1/chat/completions`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The agent model this machine is set to use, from the settings row.
|
|
89
|
+
*
|
|
90
|
+
* With a direct URL and no name the API itself sends the literal "default_model" (an MLX server
|
|
91
|
+
* answers to anything), so the same is sent here rather than skipping the summary on every MLX
|
|
92
|
+
* host.
|
|
93
|
+
*/
|
|
94
|
+
export function agentModelFromSettings(settings) {
|
|
95
|
+
const str = (v) => String(v ?? "").trim();
|
|
96
|
+
const url = str(settings?.agentModelUrl);
|
|
97
|
+
if (!url) return null;
|
|
98
|
+
return { url, model: str(settings?.agentModelName) || "default_model" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function summaryRequest(model, question) {
|
|
102
|
+
const q = Array.from(cleanTitleText(question)).slice(0, SUMMARY_INPUT_MAX).join("");
|
|
103
|
+
return {
|
|
104
|
+
model,
|
|
105
|
+
stream: false,
|
|
106
|
+
max_tokens: 200, // room for a reasoning model to think briefly and still answer
|
|
107
|
+
messages: [
|
|
108
|
+
{
|
|
109
|
+
role: "system",
|
|
110
|
+
content:
|
|
111
|
+
"You write window titles. Reply with ONLY a title of at most 8 words that says what " +
|
|
112
|
+
"the user's request is about, in the language the request is written in. No quotes, " +
|
|
113
|
+
"no trailing punctuation, no explanation. Do not answer or carry out the request.",
|
|
114
|
+
},
|
|
115
|
+
{ role: "user", content: `Request:\n${q}` },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The title out of the model's reply, or "" when the reply is not one.
|
|
122
|
+
*
|
|
123
|
+
* A model that ignores the instruction and starts answering the question must not get its first
|
|
124
|
+
* paragraph into the tab, so anything that is not a single short line is refused, and the caller
|
|
125
|
+
* keeps the trimmed question.
|
|
126
|
+
*/
|
|
127
|
+
export function pickSummary(body, max = TITLE_MAX) {
|
|
128
|
+
let raw = body?.choices?.[0]?.message?.content;
|
|
129
|
+
if (typeof raw !== "string") return "";
|
|
130
|
+
raw = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
|
131
|
+
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
132
|
+
if (lines.length !== 1) return "";
|
|
133
|
+
const line = cleanTitleText(lines[0])
|
|
134
|
+
.replace(/^title\s*:\s*/i, "")
|
|
135
|
+
.replace(/^["'“”‘’`*]+|["'“”‘’`*]+$/gu, "")
|
|
136
|
+
.replace(/[.。!?]+$/u, "")
|
|
137
|
+
.trim();
|
|
138
|
+
const n = Array.from(line).length;
|
|
139
|
+
return n > 0 && n <= max ? line : "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Ask the agent model for a title. → the title, or "" for every reason there is not one.
|
|
144
|
+
* Never throws: this runs beside a turn and must not be able to disturb it.
|
|
145
|
+
*/
|
|
146
|
+
export async function summariseForTitle(
|
|
147
|
+
question,
|
|
148
|
+
settings,
|
|
149
|
+
{ fetchImpl = fetch, timeoutMs = 20_000 } = {},
|
|
150
|
+
) {
|
|
151
|
+
const agent = agentModelFromSettings(settings);
|
|
152
|
+
if (!agent || !needsSummary(question)) return "";
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetchImpl(chatEndpoint(agent.url), {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify(summaryRequest(agent.model, question)),
|
|
158
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
159
|
+
});
|
|
160
|
+
if (!res?.ok) return "";
|
|
161
|
+
return pickSummary(await res.json());
|
|
162
|
+
} catch {
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
}
|