@tiens.nguyen/gu-cli 1.0.750 → 1.0.752
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/agent-stop.mjs +69 -0
- package/gonext_agent_chat.py +235 -8
- package/gu-cli.mjs +39 -17
- package/gu-repl.mjs +76 -3
- package/image-paste.mjs +188 -0
- package/package.json +8 -1
- package/scripts/image-paste-check.mjs +113 -0
package/agent-stop.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When to stop a running agent, and how (runaway fix, 2026-09-25).
|
|
3
|
+
*
|
|
4
|
+
* WHAT HAPPENED. A turn ran `sdkmanager` for longer than 5 minutes without an update, so the API
|
|
5
|
+
* marked the job FAILED. The worker only ever stopped the agent for CANCELLED (Ctrl+C), so the
|
|
6
|
+
* agent carried on for 22 more steps nobody could see: 618 of its updates were rejected with 409,
|
|
7
|
+
* it booted an emulator four times and deleted two other emulators' data — and nothing would
|
|
8
|
+
* have stopped it before the 6-hour time budget. A job that is no longer `running` has nobody
|
|
9
|
+
* reading it; every further step is invisible work, invisible cost and invisible side effects.
|
|
10
|
+
*
|
|
11
|
+
* AND HOW. The agent used to get SIGKILL, which it cannot see, so a command it was in the middle
|
|
12
|
+
* of (its own process group, by design, so servers can outlive a turn) kept running with nothing
|
|
13
|
+
* tracking it. Now it gets SIGTERM first — the python kills the command in flight and exits —
|
|
14
|
+
* and SIGKILL only if it has not gone after a grace period, so a stop can never hang.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Statuses after which nobody will read the job again. */
|
|
18
|
+
const ENDED = new Set(["cancelled", "failed", "completed"]);
|
|
19
|
+
|
|
20
|
+
/** Has the job ended on the API (so the agent must stop)? */
|
|
21
|
+
export function jobEnded(status) {
|
|
22
|
+
return ENDED.has(String(status ?? "").trim().toLowerCase());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The job status named in a 409 body like {"error":"…","jobStatus":"failed"} ("" if none). */
|
|
26
|
+
export function jobStatusFrom409(body) {
|
|
27
|
+
const text = String(body ?? "");
|
|
28
|
+
try {
|
|
29
|
+
const s = JSON.parse(text)?.jobStatus;
|
|
30
|
+
if (typeof s === "string") return s;
|
|
31
|
+
} catch {
|
|
32
|
+
/* a cut or non-JSON body — fall through to the pattern */
|
|
33
|
+
}
|
|
34
|
+
return /"jobStatus"\s*:\s*"([a-z]+)"/i.exec(text)?.[1] ?? "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How long the agent gets to kill what it started before it is killed. */
|
|
38
|
+
export const STOP_GRACE_MS = 3000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Stop a child process: SIGTERM now, SIGKILL after `graceMs` unless it has exited.
|
|
42
|
+
* → a function that cancels the pending SIGKILL (call it from the child's `close`).
|
|
43
|
+
*/
|
|
44
|
+
export function stopGracefully(child, { graceMs = STOP_GRACE_MS, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
45
|
+
try {
|
|
46
|
+
child.kill("SIGTERM");
|
|
47
|
+
} catch {
|
|
48
|
+
/* already gone */
|
|
49
|
+
}
|
|
50
|
+
const t = setTimer(() => {
|
|
51
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
52
|
+
try {
|
|
53
|
+
child.kill("SIGKILL");
|
|
54
|
+
} catch {
|
|
55
|
+
/* already gone */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}, graceMs);
|
|
59
|
+
return () => clearTimer(t);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The worker-log line for an agent the API stopped. */
|
|
63
|
+
export function agentStoppedLine(jobId, stoppedBy) {
|
|
64
|
+
if (!stoppedBy || stoppedBy === "cancelled") return `[gu-worker] agent_chat ${jobId} cancelled by user`;
|
|
65
|
+
return (
|
|
66
|
+
`[gu-worker] agent_chat ${jobId} stopped: the job is ${stoppedBy} on the API, so nobody ` +
|
|
67
|
+
"would see further steps — the agent and the command it was running were stopped"
|
|
68
|
+
);
|
|
69
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -6578,7 +6578,62 @@ _WS_DESTRUCTIVE_RE = re.compile(
|
|
|
6578
6578
|
)
|
|
6579
6579
|
|
|
6580
6580
|
|
|
6581
|
-
|
|
6581
|
+
# A DELETE OUTSIDE THE PROJECT IS THE USER'S CALL (runaway fix, 09-25). The destructive pattern
|
|
6582
|
+
# above only knows `rm -r` / `rm -f`, so `rm a.img b.img` sailed through: an unattended agent,
|
|
6583
|
+
# short of disk space, deleted two OTHER emulators' userdata-qemu.img in ~/.android/avd — files
|
|
6584
|
+
# outside the project nobody had mentioned. Inside a workspace (build output, its own temp
|
|
6585
|
+
# files) and the system temp folder deleting stays free; anywhere else it asks.
|
|
6586
|
+
_WS_DELETE_RUNNERS = {"rm", "rmdir", "unlink", "del", "erase", "rd", "remove-item", "trash"}
|
|
6587
|
+
|
|
6588
|
+
|
|
6589
|
+
def _ws_delete_targets(argv):
|
|
6590
|
+
"""The paths a delete command would remove ([] = not a delete)."""
|
|
6591
|
+
if not argv:
|
|
6592
|
+
return []
|
|
6593
|
+
runner = os.path.basename(argv[0]).lower()
|
|
6594
|
+
if runner.endswith(".exe"):
|
|
6595
|
+
runner = runner[:-4]
|
|
6596
|
+
if runner in _WS_DELETE_RUNNERS:
|
|
6597
|
+
return [a for a in argv[1:] if a and not a.startswith("-") and a != "--"]
|
|
6598
|
+
if runner == "find" and any(a in ("-delete",) for a in argv[1:]):
|
|
6599
|
+
# `find <paths…> <expression>`: the paths come before the first expression token.
|
|
6600
|
+
out = []
|
|
6601
|
+
for a in argv[1:]:
|
|
6602
|
+
if a.startswith("-") or a in ("(", "!"):
|
|
6603
|
+
break
|
|
6604
|
+
out.append(a)
|
|
6605
|
+
return out or ["."]
|
|
6606
|
+
return []
|
|
6607
|
+
|
|
6608
|
+
|
|
6609
|
+
def _ws_is_inside(path, roots):
|
|
6610
|
+
return any(path == r or path.startswith(r.rstrip(os.sep) + os.sep) for r in roots)
|
|
6611
|
+
|
|
6612
|
+
|
|
6613
|
+
def _ws_delete_outside_reason(argv, wd, roots=None):
|
|
6614
|
+
"""Why this delete needs the user's yes, or None when every target is inside a workspace or
|
|
6615
|
+
the temp folder. Paths are resolved from `wd`, with ~ expanded and symlinks followed."""
|
|
6616
|
+
import tempfile
|
|
6617
|
+
targets = _ws_delete_targets(argv)
|
|
6618
|
+
if not targets:
|
|
6619
|
+
return None
|
|
6620
|
+
ws = [os.path.realpath(r["path"]) for r in (_WS_ROOTS if roots is None else roots) if r.get("path")]
|
|
6621
|
+
if not ws:
|
|
6622
|
+
return None # no workspace registered (run_command needs one) — nothing to measure against
|
|
6623
|
+
temp = [os.path.realpath(p) for p in {tempfile.gettempdir(), "/tmp", "/var/tmp"} if p]
|
|
6624
|
+
outside = []
|
|
6625
|
+
for t in targets:
|
|
6626
|
+
p = os.path.expanduser(t)
|
|
6627
|
+
p = os.path.realpath(p if os.path.isabs(p) else os.path.join(wd or os.getcwd(), p))
|
|
6628
|
+
if not (_ws_is_inside(p, ws) or _ws_is_inside(p, temp)):
|
|
6629
|
+
outside.append(t)
|
|
6630
|
+
if not outside:
|
|
6631
|
+
return None
|
|
6632
|
+
return ("it deletes files OUTSIDE the project (" + ", ".join(outside[:3])
|
|
6633
|
+
+ (" …" if len(outside) > 3 else "") + ")")
|
|
6634
|
+
|
|
6635
|
+
|
|
6636
|
+
def _ws_command_risk(command, argv, denyset, wd=""):
|
|
6582
6637
|
"""Classify a command for the run policy. Returns (hard_reason, ask_reason), either
|
|
6583
6638
|
None when N/A:
|
|
6584
6639
|
- hard_reason: block outright when we CAN'T ask (web / non-interactive) — privilege
|
|
@@ -6595,6 +6650,11 @@ def _ws_command_risk(command, argv, denyset):
|
|
|
6595
6650
|
if denyset and (runner in denyset or rbase in denyset):
|
|
6596
6651
|
r = f"'{runner}' is on your blocked-commands list"
|
|
6597
6652
|
return (r, r)
|
|
6653
|
+
# Before the destructive pattern: `rm -rf ~/x` is both, and "outside the project" is the part
|
|
6654
|
+
# the user needs to hear. Hard-blocked too when nobody can be asked (web / unattended).
|
|
6655
|
+
outside = _ws_delete_outside_reason(argv, wd)
|
|
6656
|
+
if outside:
|
|
6657
|
+
return (outside, outside)
|
|
6598
6658
|
if _WS_DESTRUCTIVE_RE.search(cmd):
|
|
6599
6659
|
return (None, "a destructive command (deletes files / wipes disk / powers off)")
|
|
6600
6660
|
return (None, None)
|
|
@@ -6722,6 +6782,33 @@ def _ws_kill_tree(pid: int, force: bool = False) -> None:
|
|
|
6722
6782
|
pass
|
|
6723
6783
|
|
|
6724
6784
|
|
|
6785
|
+
# COMMANDS IN FLIGHT DIE WITH THE AGENT (runaway fix, agent-stop.mjs). run_command gives each
|
|
6786
|
+
# command its own process group so a server can outlive the turn — which also meant a command
|
|
6787
|
+
# the agent was in the MIDDLE of (a download, a build, an emulator boot) outlived a killed agent
|
|
6788
|
+
# with nothing tracking it. The worker now sends SIGTERM before SIGKILL; this handler kills
|
|
6789
|
+
# what is still in flight and exits. Servers/apps already left running are not in this set.
|
|
6790
|
+
_INFLIGHT_CMDS: set = set()
|
|
6791
|
+
|
|
6792
|
+
|
|
6793
|
+
def _on_sigterm(signum, frame): # noqa: ARG001
|
|
6794
|
+
for pid in list(_INFLIGHT_CMDS):
|
|
6795
|
+
_ws_kill_tree(pid, force=True)
|
|
6796
|
+
_INFLIGHT_CMDS.clear()
|
|
6797
|
+
try:
|
|
6798
|
+
_log("stopped by the worker (SIGTERM) — killed the command in flight")
|
|
6799
|
+
except Exception: # noqa: BLE001
|
|
6800
|
+
pass
|
|
6801
|
+
os._exit(143)
|
|
6802
|
+
|
|
6803
|
+
|
|
6804
|
+
def _install_sigterm_handler():
|
|
6805
|
+
import signal
|
|
6806
|
+
try:
|
|
6807
|
+
signal.signal(signal.SIGTERM, _on_sigterm)
|
|
6808
|
+
except (ValueError, OSError): # not the main thread, or a platform without it
|
|
6809
|
+
pass
|
|
6810
|
+
|
|
6811
|
+
|
|
6725
6812
|
def _ws_pgroup_pids(pgid: int) -> list:
|
|
6726
6813
|
"""Live pids in a process group (the server + everything it spawned)."""
|
|
6727
6814
|
import subprocess
|
|
@@ -6948,6 +7035,35 @@ def _ws_distill_output(out: str, cap_head: int = 1200, cap_tail: int = 4000) ->
|
|
|
6948
7035
|
+ out[-cap_tail:])
|
|
6949
7036
|
|
|
6950
7037
|
|
|
7038
|
+
# READ CAPS SCALE WITH THE WINDOW (issue #49). A flat 3,000 chars (~750 tokens, ~60-80 lines,
|
|
7039
|
+
# middle cut) was right for #63's weak models with no prompt cache. Measured over 625 Claude
|
|
7040
|
+
# steps it cut a read in 23 of 37 turns (>=14.6% of steps), and each cut is a follow-up read:
|
|
7041
|
+
# d6ad7211 walked a 6,665-line file in slices for ~40 steps. With cache reads at 0.1x a bigger
|
|
7042
|
+
# read costs about what the extra step did, and steps are what we save. Claude Code's own Read
|
|
7043
|
+
# allows 25k tokens. Small or unknown windows keep 3,000 — a 16k local Ollama is unchanged.
|
|
7044
|
+
_OBS_CAP_FLOOR = 3000
|
|
7045
|
+
_OBS_CAP_READ_MAX = 24_000 # ≈ read_file_lines' own 400-line limit
|
|
7046
|
+
_OBS_CAP_SEARCH_MAX = 12_000 # ≈ grep_repo's 100 hits / list_dir's 300 entries
|
|
7047
|
+
_OBS_CAP_WINDOW_SHARE = 0.03 # one read may take ~3% of the window
|
|
7048
|
+
_OBS_CAPS = {"read": _OBS_CAP_FLOOR, "search": _OBS_CAP_FLOOR}
|
|
7049
|
+
|
|
7050
|
+
|
|
7051
|
+
def _obs_caps_for_window(window_tokens: int) -> dict:
|
|
7052
|
+
"""{read, search} character caps for a model with this context window (0 = unknown)."""
|
|
7053
|
+
if not window_tokens or window_tokens <= 0:
|
|
7054
|
+
return {"read": _OBS_CAP_FLOOR, "search": _OBS_CAP_FLOOR}
|
|
7055
|
+
read = int(window_tokens * 4 * _OBS_CAP_WINDOW_SHARE) # ~4 chars per token
|
|
7056
|
+
read = max(_OBS_CAP_FLOOR, min(_OBS_CAP_READ_MAX, read))
|
|
7057
|
+
search = max(_OBS_CAP_FLOOR, min(_OBS_CAP_SEARCH_MAX, read // 2))
|
|
7058
|
+
return {"read": read, "search": search}
|
|
7059
|
+
|
|
7060
|
+
|
|
7061
|
+
def _set_obs_caps(window_tokens: int) -> dict:
|
|
7062
|
+
"""Set this turn's caps (the worker runs one turn per process)."""
|
|
7063
|
+
_OBS_CAPS.update(_obs_caps_for_window(window_tokens))
|
|
7064
|
+
return dict(_OBS_CAPS)
|
|
7065
|
+
|
|
7066
|
+
|
|
6951
7067
|
def _ws_cap_obs(text: str, cap: int = 3000) -> str:
|
|
6952
7068
|
"""Cap a READ tool's observation (read_file_lines / list_dir / read_text_file /
|
|
6953
7069
|
grep_repo) before it's returned to the CodeAgent. Every observation stays in the
|
|
@@ -6973,6 +7089,59 @@ def _rag_s3_client(region: str, akid: str, secret: str):
|
|
|
6973
7089
|
)
|
|
6974
7090
|
|
|
6975
7091
|
|
|
7092
|
+
# PASTED IMAGES (task #284). The REPL sends "[Image #1: <file>]" in the message text (the API
|
|
7093
|
+
# passes text only); the file is in ~/.gonext/images, copied there at paste time. The CURRENT
|
|
7094
|
+
# question's images are attached to the request for a Claude coder (the adapter turns them into
|
|
7095
|
+
# Anthropic image blocks); the model reads "[Image #1]". Older messages keep only the label —
|
|
7096
|
+
# their images are not re-sent. Files are read from ~/.gonext/images and nowhere else: the
|
|
7097
|
+
# marker is text, and text can be typed.
|
|
7098
|
+
_IMAGE_MARKER_RE = re.compile(r"\[Image #(\d+): ([^\]\n]+)\]")
|
|
7099
|
+
_MAX_TURN_IMAGES = 20
|
|
7100
|
+
|
|
7101
|
+
|
|
7102
|
+
def _take_image_markers(messages, images_dir=None):
|
|
7103
|
+
"""Rewrite the markers in `messages` (in place). → the latest user message's image files,
|
|
7104
|
+
in order, as [(label, path)] — only files that exist inside the images folder."""
|
|
7105
|
+
root = os.path.realpath(images_dir or os.path.join(os.path.expanduser("~"), ".gonext", "images"))
|
|
7106
|
+
last_user = max((i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "user"),
|
|
7107
|
+
default=-1)
|
|
7108
|
+
found = []
|
|
7109
|
+
for i, m in enumerate(messages):
|
|
7110
|
+
if not isinstance(m, dict) or not isinstance(m.get("content"), str):
|
|
7111
|
+
continue
|
|
7112
|
+
|
|
7113
|
+
def _sub(mt, current=(i == last_user)):
|
|
7114
|
+
label = f"[Image #{mt.group(1)}]"
|
|
7115
|
+
if not current:
|
|
7116
|
+
return f"{label} (an earlier image — not shown again)"
|
|
7117
|
+
path = os.path.realpath(os.path.expanduser(mt.group(2).strip()))
|
|
7118
|
+
if not path.startswith(root + os.sep) or not os.path.isfile(path):
|
|
7119
|
+
return f"{label} (the image could not be read)"
|
|
7120
|
+
if len(found) >= _MAX_TURN_IMAGES:
|
|
7121
|
+
return f"{label} (not attached — at most {_MAX_TURN_IMAGES} images per message)"
|
|
7122
|
+
found.append((label, path))
|
|
7123
|
+
return label
|
|
7124
|
+
m["content"] = _IMAGE_MARKER_RE.sub(_sub, m["content"])
|
|
7125
|
+
return found
|
|
7126
|
+
|
|
7127
|
+
|
|
7128
|
+
def _load_turn_images(refs):
|
|
7129
|
+
"""[(label, path)] → PIL images, skipping any that will not open."""
|
|
7130
|
+
out = []
|
|
7131
|
+
try:
|
|
7132
|
+
from PIL import Image
|
|
7133
|
+
except Exception: # noqa: BLE001
|
|
7134
|
+
return out
|
|
7135
|
+
for label, path in refs:
|
|
7136
|
+
try:
|
|
7137
|
+
with Image.open(path) as im:
|
|
7138
|
+
im.load()
|
|
7139
|
+
out.append(im.copy())
|
|
7140
|
+
except Exception as e: # noqa: BLE001
|
|
7141
|
+
_log(f"image {label}: could not open ({type(e).__name__})")
|
|
7142
|
+
return out
|
|
7143
|
+
|
|
7144
|
+
|
|
6976
7145
|
def run_agent_chat(cfg):
|
|
6977
7146
|
# Task #249: what this run may log — set FIRST, before anything is logged (the history and the
|
|
6978
7147
|
# current task are logged early, and a real turn caught them leaking when this came later).
|
|
@@ -7002,6 +7171,7 @@ def run_agent_chat(cfg):
|
|
|
7002
7171
|
return
|
|
7003
7172
|
|
|
7004
7173
|
messages = cfg.get("messages") or []
|
|
7174
|
+
_turn_image_refs = _take_image_markers(messages)
|
|
7005
7175
|
agent_base_url = cfg.get("agentBaseURL") or ""
|
|
7006
7176
|
# THE PROXY, configured before any client is built (task #160). The allowlist is the three
|
|
7007
7177
|
# model endpoints as the WORKER resolved them; the raw cfg values are used rather than the
|
|
@@ -9007,6 +9177,8 @@ def run_agent_chat(cfg):
|
|
|
9007
9177
|
_trim_state: dict = {"next_at": _TRIM_FIRST_AT_CHARS}
|
|
9008
9178
|
_ctx_window = _model_context_window(coding_base_url, coding_model_id, coding_api_key)
|
|
9009
9179
|
_compact_at = _compact_threshold(_ctx_window)
|
|
9180
|
+
_caps = _set_obs_caps(_ctx_window)
|
|
9181
|
+
_log(f"read caps: {_caps['read']} chars per read, {_caps['search']} per search (issue #49)")
|
|
9010
9182
|
if _ctx_window:
|
|
9011
9183
|
# SAY WHICH SOURCE ANSWERED. A measured window and a remembered one are not the same
|
|
9012
9184
|
# claim, and a table entry presented as fact is how a stale number survives unquestioned.
|
|
@@ -10811,7 +10983,8 @@ def run_agent_chat(cfg):
|
|
|
10811
10983
|
entries.sort()
|
|
10812
10984
|
more = " …(truncated)" if len(entries) >= 300 else ""
|
|
10813
10985
|
out = _ws_cap_obs(
|
|
10814
|
-
f"{len(entries)} files under {root}:\n" + "\n".join(entries) + more
|
|
10986
|
+
f"{len(entries)} files under {root}:\n" + "\n".join(entries) + more,
|
|
10987
|
+
cap=_OBS_CAPS["search"])
|
|
10815
10988
|
_emit_read("Listing files", path, f"{len(entries)} file(s)")
|
|
10816
10989
|
_last_obs["text"] = out
|
|
10817
10990
|
return out
|
|
@@ -10850,7 +11023,7 @@ def run_agent_chat(cfg):
|
|
|
10850
11023
|
return _rb
|
|
10851
11024
|
with open(rp, "r", encoding="utf-8", errors="replace") as fh:
|
|
10852
11025
|
data = fh.read(max(500, min(int(max_chars or 20000), 100000)))
|
|
10853
|
-
out = _ws_cap_obs(data or "(empty file)")
|
|
11026
|
+
out = _ws_cap_obs(data or "(empty file)", cap=_OBS_CAPS["read"])
|
|
10854
11027
|
_emit_read("Reading file", path, f"{data.count(chr(10)) + 1} line(s)"
|
|
10855
11028
|
if data else "empty")
|
|
10856
11029
|
_last_obs["text"] = out
|
|
@@ -11509,7 +11682,8 @@ def run_agent_chat(cfg):
|
|
|
11509
11682
|
_last_obs["text"] = msg
|
|
11510
11683
|
return msg
|
|
11511
11684
|
more = "\n…(capped at 100 hits)" if len(hits) >= 100 else ""
|
|
11512
|
-
out = _ws_cap_obs(f"Matches under {root}:\n" + "\n".join(hits) + more
|
|
11685
|
+
out = _ws_cap_obs(f"Matches under {root}:\n" + "\n".join(hits) + more,
|
|
11686
|
+
cap=_OBS_CAPS["search"])
|
|
11513
11687
|
_last_obs["text"] = out
|
|
11514
11688
|
return out
|
|
11515
11689
|
except Exception as e: # noqa: BLE001
|
|
@@ -11595,7 +11769,7 @@ def run_agent_chat(cfg):
|
|
|
11595
11769
|
_emit({"type": "step",
|
|
11596
11770
|
"text": f"Locating symbol → {name} ({len(defs)} def, "
|
|
11597
11771
|
f"{len(calls)} call, {len(tests)} test)"})
|
|
11598
|
-
res = _ws_cap_obs("\n".join(out))
|
|
11772
|
+
res = _ws_cap_obs("\n".join(out), cap=_OBS_CAPS["search"])
|
|
11599
11773
|
_last_obs["text"] = res
|
|
11600
11774
|
return res
|
|
11601
11775
|
except Exception as ex: # noqa: BLE001
|
|
@@ -11634,7 +11808,8 @@ def run_agent_chat(cfg):
|
|
|
11634
11808
|
_last_obs["text"] = _rb
|
|
11635
11809
|
return _rb
|
|
11636
11810
|
body = "".join(f"{i}: {lines[i - 1]}" for i in range(s, e + 1))
|
|
11637
|
-
out = _ws_cap_obs(f"{rp} (lines {s}-{e} of {len(lines)}):\n{body}"
|
|
11811
|
+
out = _ws_cap_obs(f"{rp} (lines {s}-{e} of {len(lines)}):\n{body}",
|
|
11812
|
+
cap=_OBS_CAPS["read"])
|
|
11638
11813
|
_search_tally_bump("read")
|
|
11639
11814
|
_emit_read("Reading lines", f"{path}:{s}-{e}", f"of {len(lines)} line(s)")
|
|
11640
11815
|
_last_obs["text"] = out
|
|
@@ -11859,6 +12034,7 @@ def run_agent_chat(cfg):
|
|
|
11859
12034
|
workdir: directory to run in. Empty = first registered workspace.
|
|
11860
12035
|
timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
|
|
11861
12036
|
"""
|
|
12037
|
+
proc = None # read by the `finally` below, which runs even if Popen never did
|
|
11862
12038
|
try:
|
|
11863
12039
|
import os
|
|
11864
12040
|
import shlex
|
|
@@ -11975,7 +12151,7 @@ def run_agent_chat(cfg):
|
|
|
11975
12151
|
# Risk gate: privilege escalation / denylisted / destructive. When the
|
|
11976
12152
|
# client can approve interactively (terminal), PAUSE and ask the user;
|
|
11977
12153
|
# otherwise (web) keep hard-blocking the privilege/denylist cases.
|
|
11978
|
-
_hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST)
|
|
12154
|
+
_hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST, wd)
|
|
11979
12155
|
# Shift+Tab may have switched it since the turn started (#47).
|
|
11980
12156
|
global _accept_edits
|
|
11981
12157
|
if _interactive_approval and _job_id:
|
|
@@ -12058,6 +12234,7 @@ def run_agent_chat(cfg):
|
|
|
12058
12234
|
stdout=log_fh, stderr=subprocess.STDOUT,
|
|
12059
12235
|
**_ws_spawn_kwargs(),
|
|
12060
12236
|
)
|
|
12237
|
+
_INFLIGHT_CMDS.add(proc.pid) # killed with the agent until it is classified
|
|
12061
12238
|
start = _t.time()
|
|
12062
12239
|
|
|
12063
12240
|
def _read_log() -> str:
|
|
@@ -12171,6 +12348,10 @@ def run_agent_chat(cfg):
|
|
|
12171
12348
|
_last_obs["text"] = msg
|
|
12172
12349
|
return msg
|
|
12173
12350
|
finally:
|
|
12351
|
+
# Exited, killed at the deadline, or left running ON PURPOSE as a server/app
|
|
12352
|
+
# (tracked in the servers file for stop_server): no longer in flight either way.
|
|
12353
|
+
if proc is not None:
|
|
12354
|
+
_INFLIGHT_CMDS.discard(proc.pid)
|
|
12174
12355
|
try:
|
|
12175
12356
|
log_fh.close()
|
|
12176
12357
|
except Exception: # noqa: BLE001
|
|
@@ -12621,8 +12802,20 @@ def run_agent_chat(cfg):
|
|
|
12621
12802
|
_RAG_STATE["s"] = "no-index"
|
|
12622
12803
|
except Exception: # noqa: BLE001
|
|
12623
12804
|
pass
|
|
12805
|
+
# The question's pasted images (task #284): a Claude coder sees them; any other coder is
|
|
12806
|
+
# told they were not sent rather than left to guess from a bare "[Image #1]".
|
|
12807
|
+
_turn_images = []
|
|
12808
|
+
if _turn_image_refs:
|
|
12809
|
+
if _is_anthropic_url(coding_base_url):
|
|
12810
|
+
_turn_images = _load_turn_images(_turn_image_refs)
|
|
12811
|
+
_log(f"attached {len(_turn_images)} image(s) to the question")
|
|
12812
|
+
_emit({"type": "step", "text": f"Sending {len(_turn_images)} image(s) with the question"})
|
|
12813
|
+
else:
|
|
12814
|
+
task_with_hint += ("\n\n(The user pasted " + ", ".join(l for l, _ in _turn_image_refs)
|
|
12815
|
+
+ " but this coding model is not sent images — say so if the "
|
|
12816
|
+
"task needs them.)")
|
|
12624
12817
|
with contextlib.redirect_stdout(sys.stderr):
|
|
12625
|
-
result = agent.run(task_with_hint)
|
|
12818
|
+
result = agent.run(task_with_hint, images=_turn_images or None)
|
|
12626
12819
|
while _maxsteps_partial.get("hit"):
|
|
12627
12820
|
if _always_extend:
|
|
12628
12821
|
_log(f"max-steps reached → auto-extending to {_MAXSTEP_CEILING} "
|
|
@@ -12853,9 +13046,41 @@ def _emit(obj):
|
|
|
12853
13046
|
Lock-guarded: the model-call heartbeat thread emits concurrently with the
|
|
12854
13047
|
main loop, and interleaved writes would corrupt the NDJSON stream.
|
|
12855
13048
|
"""
|
|
13049
|
+
live = isinstance(obj, dict) and obj.pop("_liveness", False)
|
|
12856
13050
|
with _EMIT_LOCK:
|
|
12857
13051
|
_REAL_STDOUT.write(json.dumps(obj) + "\n")
|
|
12858
13052
|
_REAL_STDOUT.flush()
|
|
13053
|
+
if live:
|
|
13054
|
+
return
|
|
13055
|
+
_LIVENESS["last"] = time.monotonic()
|
|
13056
|
+
if obj.get("type") == "final":
|
|
13057
|
+
_LIVENESS["stop"].set()
|
|
13058
|
+
|
|
13059
|
+
|
|
13060
|
+
# A TURN IS ALIVE WHILE ANYTHING RUNS, NOT ONLY THE MODEL. The API fails a job after 5 minutes
|
|
13061
|
+
# with no update (#209), sized on the model-call heartbeat — but only a model call had one. A
|
|
13062
|
+
# run_command may wait up to 10 minutes (and an MCP tool as long as it likes) in silence:
|
|
13063
|
+
# reported 09-25, `sdkmanager` downloading a system image was "auto-failed after 5m without
|
|
13064
|
+
# updates" at 28% while the download carried on. This fires only when nothing else has been
|
|
13065
|
+
# sent for _LIVENESS_EVERY_S, so a model call's own heartbeat still speaks for itself. Same
|
|
13066
|
+
# "Word… (Ns)" shape: the REPL swallows it (its ticker shows progress); the job stays fresh.
|
|
13067
|
+
_LIVENESS_EVERY_S = int(os.environ.get("GONEXT_LIVENESS_S") or 45) # env: tests only
|
|
13068
|
+
_LIVENESS = {"last": time.monotonic(), "stop": threading.Event()}
|
|
13069
|
+
|
|
13070
|
+
|
|
13071
|
+
def _liveness_loop(every=None):
|
|
13072
|
+
every = every or _LIVENESS_EVERY_S
|
|
13073
|
+
while not _LIVENESS["stop"].wait(min(every, 15)):
|
|
13074
|
+
quiet = time.monotonic() - _LIVENESS["last"]
|
|
13075
|
+
if quiet >= every and not _LIVENESS["stop"].is_set():
|
|
13076
|
+
_emit({"type": "step", "text": f"Still working… ({int(quiet)}s)", "_liveness": True})
|
|
13077
|
+
|
|
13078
|
+
|
|
13079
|
+
def _start_liveness(every=None):
|
|
13080
|
+
_LIVENESS["last"] = time.monotonic()
|
|
13081
|
+
t = threading.Thread(target=_liveness_loop, args=(every,), daemon=True)
|
|
13082
|
+
t.start()
|
|
13083
|
+
return t
|
|
12859
13084
|
|
|
12860
13085
|
|
|
12861
13086
|
def main():
|
|
@@ -12864,6 +13089,8 @@ def main():
|
|
|
12864
13089
|
except Exception as e: # noqa: BLE001
|
|
12865
13090
|
_emit({"type": "final", "text": f"[Invalid input: {e}]"})
|
|
12866
13091
|
return
|
|
13092
|
+
_start_liveness()
|
|
13093
|
+
_install_sigterm_handler()
|
|
12867
13094
|
run_agent_chat(cfg)
|
|
12868
13095
|
|
|
12869
13096
|
|
package/gu-cli.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/p
|
|
|
19
19
|
import { execFile as execFileCallback, spawn } from "node:child_process";
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
21
|
import { createTaskPool, createFolderQueue } from "./job-pools.mjs";
|
|
22
|
+
import { agentStoppedLine, jobEnded, jobStatusFrom409, stopGracefully } from "./agent-stop.mjs";
|
|
22
23
|
import { ragJobConfig } from "./rag-job-config.mjs";
|
|
23
24
|
import { codingKeyFallbackLine, isOllamaCloudUrl } from "./coding-key-prompt.mjs";
|
|
24
25
|
// Pure layout helpers — safe to import here (no side effects on load, unlike gu-repl.mjs).
|
|
@@ -1697,9 +1698,17 @@ const localHealthConcurrency = 4;
|
|
|
1697
1698
|
// so two turns each run slower than one would alone. The user's call: "accept it be slow" — a
|
|
1698
1699
|
// folder that answers slowly beats a folder that cannot answer at all. Unbounded is still
|
|
1699
1700
|
// wrong: a dozen forgotten terminals would spawn a dozen python processes against one GPU.
|
|
1701
|
+
//
|
|
1702
|
+
// 4, up from 2 (2026-09-25, the user's call: "make it 4 by default"). The 2 was sized for ONE
|
|
1703
|
+
// local GPU shared by coder and embedder; production now targets hosted models, where the
|
|
1704
|
+
// limit is this machine's RAM/CPU and the provider's rate limit, not a GPU queue. Still
|
|
1705
|
+
// bounded, and GONEXT_AGENT_CONCURRENCY in ~/.gonext/worker.env still overrides it (set 1-2
|
|
1706
|
+
// for a local Ollama/MLX coder).
|
|
1707
|
+
const DEFAULT_AGENT_CONCURRENCY = 4;
|
|
1700
1708
|
const agentConcurrency = Math.max(
|
|
1701
1709
|
1,
|
|
1702
|
-
Number.parseInt(String(process.env.GONEXT_AGENT_CONCURRENCY ?? "").trim(), 10) ||
|
|
1710
|
+
Number.parseInt(String(process.env.GONEXT_AGENT_CONCURRENCY ?? "").trim(), 10) ||
|
|
1711
|
+
DEFAULT_AGENT_CONCURRENCY
|
|
1703
1712
|
);
|
|
1704
1713
|
|
|
1705
1714
|
const CHUNK_PATH = "/api/worker/job-chunk";
|
|
@@ -2453,6 +2462,7 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
|
|
|
2453
2462
|
let aborted = false;
|
|
2454
2463
|
let closed = false;
|
|
2455
2464
|
let timerLive = true;
|
|
2465
|
+
let cancelHardKill = () => {};
|
|
2456
2466
|
// On the time budget: with no onTimeout handler (probe/legacy) this is a hard cap —
|
|
2457
2467
|
// kill immediately. With a handler (task #81, interactive terminal) ask the user
|
|
2458
2468
|
// whether to keep waiting; the child keeps running/streaming during the ~60s prompt.
|
|
@@ -2462,7 +2472,7 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
|
|
|
2462
2472
|
if (typeof onTimeout !== "function") {
|
|
2463
2473
|
timedOut = true;
|
|
2464
2474
|
timerLive = false;
|
|
2465
|
-
child
|
|
2475
|
+
cancelHardKill = stopGracefully(child);
|
|
2466
2476
|
return;
|
|
2467
2477
|
}
|
|
2468
2478
|
let extend = false;
|
|
@@ -2478,16 +2488,18 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
|
|
|
2478
2488
|
}
|
|
2479
2489
|
timedOut = true;
|
|
2480
2490
|
timerLive = false;
|
|
2481
|
-
child
|
|
2491
|
+
cancelHardKill = stopGracefully(child);
|
|
2482
2492
|
};
|
|
2483
2493
|
const timer = setTimeout(fireTimeout, timeoutMs);
|
|
2484
2494
|
// User-requested cancel (gu Ctrl+C): kill the Python agent so it stops burning
|
|
2485
2495
|
// steps/tokens. On close we resolve cleanly (not reject) — the caller checks the
|
|
2486
2496
|
// cancel flag and leaves the job in its already-"cancelled" state.
|
|
2497
|
+
// SIGTERM first (agent-stop.mjs): the agent kills the command it is in the middle of, then
|
|
2498
|
+
// exits; SIGKILL follows after a short grace only if it has not.
|
|
2487
2499
|
const onAbort = () => {
|
|
2488
2500
|
if (aborted) return;
|
|
2489
2501
|
aborted = true;
|
|
2490
|
-
child
|
|
2502
|
+
cancelHardKill = stopGracefully(child);
|
|
2491
2503
|
};
|
|
2492
2504
|
if (abortSignal) {
|
|
2493
2505
|
if (abortSignal.aborted) onAbort();
|
|
@@ -2521,6 +2533,7 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
|
|
|
2521
2533
|
closed = true;
|
|
2522
2534
|
timerLive = false;
|
|
2523
2535
|
clearTimeout(timer);
|
|
2536
|
+
cancelHardKill();
|
|
2524
2537
|
if (abortSignal) abortSignal.removeEventListener?.("abort", onAbort);
|
|
2525
2538
|
// Drain any remaining buffered line
|
|
2526
2539
|
const remaining = lineBuffer.trim();
|
|
@@ -3556,6 +3569,9 @@ async function runAgentChatJob(job) {
|
|
|
3556
3569
|
// "cancelled" status with completed/failed.
|
|
3557
3570
|
const cancelAc = new AbortController();
|
|
3558
3571
|
let cancelled = false;
|
|
3572
|
+
// Which API status stopped the agent ("cancelled" = Ctrl+C; "failed"/"completed" = the API
|
|
3573
|
+
// ended the job while it was still working — see agent-stop.mjs). "" = it was not stopped.
|
|
3574
|
+
let stoppedBy = "";
|
|
3559
3575
|
// Task #108: the user's Coding backend setting ("ollama" | "openai" | "" = Auto).
|
|
3560
3576
|
const codingKind = ["ollama", "openai", "local"].includes(payload?.codingKind)
|
|
3561
3577
|
? payload.codingKind
|
|
@@ -3604,12 +3620,16 @@ async function runAgentChatJob(job) {
|
|
|
3604
3620
|
});
|
|
3605
3621
|
if (!res.ok && res.status !== 204) {
|
|
3606
3622
|
const snippet = (await res.text().catch(() => "")).trim().slice(0, 400);
|
|
3607
|
-
const
|
|
3608
|
-
|
|
3609
|
-
|
|
3623
|
+
const endedAs = res.status === 409 ? jobStatusFrom409(snippet) : "";
|
|
3624
|
+
const cancelledNow = endedAs === "cancelled";
|
|
3625
|
+
// The job is over on the API (cancelled, or failed/completed while still working): nobody
|
|
3626
|
+
// will read another line, so stop the Python agent instead of letting it run on unseen.
|
|
3627
|
+
if (jobEnded(endedAs) && !cancelAc.signal.aborted) {
|
|
3628
|
+
stoppedBy = stoppedBy || endedAs;
|
|
3629
|
+
cancelAc.abort();
|
|
3630
|
+
}
|
|
3610
3631
|
const benign409 =
|
|
3611
|
-
res.status === 409 &&
|
|
3612
|
-
(snippet.includes('"jobStatus":"completed"') || cancelledNow);
|
|
3632
|
+
res.status === 409 && (endedAs === "completed" || cancelledNow);
|
|
3613
3633
|
if (!benign409) {
|
|
3614
3634
|
console.error(
|
|
3615
3635
|
`[gu-worker] agent_chat job-chunk POST failed status=${res.status} jobId=${jobId}` +
|
|
@@ -3740,8 +3760,8 @@ async function runAgentChatJob(job) {
|
|
|
3740
3760
|
// key like any other paid endpoint. A local/LAN Ollama gets no key (and ignores one).
|
|
3741
3761
|
if (codingBackend === "openai" || (codingBackend === "ollama" && _isOllamaCloudUrl(payload?.codingBaseURL))) {
|
|
3742
3762
|
try {
|
|
3743
|
-
// NAME THE JOB (task #222 phase 2).
|
|
3744
|
-
// (agentConcurrency =
|
|
3763
|
+
// NAME THE JOB (task #222 phase 2). Several turns run concurrently by default
|
|
3764
|
+
// (agentConcurrency = 4), and each may have chosen a different key — so the key is
|
|
3745
3765
|
// resolved from THIS job rather than from an account-wide "current selection", which
|
|
3746
3766
|
// both turns would read and the later writer would decide for both. An API that does
|
|
3747
3767
|
// not understand the parameter ignores it and returns the account key, which is
|
|
@@ -3985,16 +4005,18 @@ async function runAgentChatJob(job) {
|
|
|
3985
4005
|
}
|
|
3986
4006
|
|
|
3987
4007
|
// Poll our own job status during the run so a REPL Ctrl+C (which marks the job
|
|
3988
|
-
// "cancelled" via the API) actually stops the Python agent within ~1.5s
|
|
3989
|
-
//
|
|
3990
|
-
//
|
|
4008
|
+
// "cancelled" via the API) actually stops the Python agent within ~1.5s — and so does ANY
|
|
4009
|
+
// end the API decides (a job expired as "failed" used to run on unseen, agent-stop.mjs),
|
|
4010
|
+
// instead of only being noticed on the next chunk flush (which can be 45s apart during a
|
|
4011
|
+
// long model call). The job-chunk 409 path (flushChunks) is the backup signal.
|
|
3991
4012
|
const cancelPoll = setInterval(async () => {
|
|
3992
4013
|
if (cancelAc.signal.aborted) return;
|
|
3993
4014
|
try {
|
|
3994
4015
|
const r = await workerFetch(`/api/worker/jobs/${jobId}`);
|
|
3995
4016
|
if (r.ok) {
|
|
3996
4017
|
const j = await r.json().catch(() => ({}));
|
|
3997
|
-
if (j?.jobStatus
|
|
4018
|
+
if (jobEnded(j?.jobStatus)) {
|
|
4019
|
+
stoppedBy = stoppedBy || String(j.jobStatus);
|
|
3998
4020
|
cancelled = true;
|
|
3999
4021
|
cancelAc.abort();
|
|
4000
4022
|
}
|
|
@@ -4212,7 +4234,7 @@ async function runAgentChatJob(job) {
|
|
|
4212
4234
|
// would resurrect a finished status) and skip the final chunk flush (it'd 409).
|
|
4213
4235
|
if (cancelled || cancelAc.signal.aborted) {
|
|
4214
4236
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
4215
|
-
console.log(
|
|
4237
|
+
console.log(agentStoppedLine(jobId, stoppedBy));
|
|
4216
4238
|
return;
|
|
4217
4239
|
}
|
|
4218
4240
|
|
|
@@ -4268,7 +4290,7 @@ async function runAgentChatJob(job) {
|
|
|
4268
4290
|
// A cancel that raced into the catch (e.g. the kill surfaced as an error before the
|
|
4269
4291
|
// aborted-close path): the job is already "cancelled" — don't overwrite with failed.
|
|
4270
4292
|
if (cancelled || cancelAc.signal.aborted) {
|
|
4271
|
-
console.log(
|
|
4293
|
+
console.log(agentStoppedLine(jobId, stoppedBy));
|
|
4272
4294
|
return;
|
|
4273
4295
|
}
|
|
4274
4296
|
await flushTail;
|
package/gu-repl.mjs
CHANGED
|
@@ -128,6 +128,7 @@ import { unapprovedProjectServers } from "./mcp-command.mjs";
|
|
|
128
128
|
import { readHooks } from "./hooks-config.mjs";
|
|
129
129
|
import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
|
|
130
130
|
import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
|
|
131
|
+
import { IMAGE_NEEDS_CLAUDE, clipboardImageToFile, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
|
|
131
132
|
import {
|
|
132
133
|
isDoubleClick,
|
|
133
134
|
clickRun,
|
|
@@ -1031,10 +1032,17 @@ rl._ttyWrite = (str, key) => {
|
|
|
1031
1032
|
if (key?.name === "paste-end") {
|
|
1032
1033
|
const text = pasteBuffer ?? "";
|
|
1033
1034
|
pasteBuffer = null;
|
|
1034
|
-
|
|
1035
|
+
// An EMPTY paste is what some terminals send for Cmd+V with only an image on the clipboard.
|
|
1036
|
+
if (!text) {
|
|
1037
|
+
pasteClipboardImage();
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1035
1040
|
// Trailing newline(s) are trimmed: a copied log almost always ends with one, and it is
|
|
1036
1041
|
// not content the user meant to include.
|
|
1037
1042
|
const trimmed = text.replace(/\n+$/, "");
|
|
1043
|
+
// A dragged or pasted image FILE arrives as its path: show it as "[Image #N]" (task #284).
|
|
1044
|
+
const imgPath = imagePathFromPaste(trimmed);
|
|
1045
|
+
if (imgPath && pasteImageFile(imgPath)) return;
|
|
1038
1046
|
const insert = shouldCollapsePaste(trimmed)
|
|
1039
1047
|
? makePasteToken(trimmed)
|
|
1040
1048
|
: trimmed.replace(/\n/g, " "); // a short paste stays literal, on one line
|
|
@@ -1056,6 +1064,14 @@ rl._ttyWrite = (str, key) => {
|
|
|
1056
1064
|
// paste token is short, so the input rarely moves at all.
|
|
1057
1065
|
return;
|
|
1058
1066
|
}
|
|
1067
|
+
// Ctrl+V / Alt+V paste an IMAGE from the clipboard (task #284) — terminals paste only text, so
|
|
1068
|
+
// Cmd+V with a copied screenshot sends nothing. Alt+V is the one that works on Windows:
|
|
1069
|
+
// Windows Terminal keeps Ctrl+V for its own paste (Claude Code uses Alt+V there for the same
|
|
1070
|
+
// reason). See image-paste.mjs.
|
|
1071
|
+
if (pasteBuffer === null && isImagePasteKey(key)) {
|
|
1072
|
+
pasteClipboardImage();
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1059
1075
|
if (pasteBuffer !== null) {
|
|
1060
1076
|
// Inside a paste: accumulate, and let NOTHING reach readline.
|
|
1061
1077
|
if (key?.name === "enter" || key?.name === "return") pasteBuffer += "\n";
|
|
@@ -1424,6 +1440,57 @@ const stripAnsiEscapes = (s) =>
|
|
|
1424
1440
|
const PASTE_MIN_CHARS = 200;
|
|
1425
1441
|
const PASTE_MIN_LINES = 3;
|
|
1426
1442
|
const pasteStore = new Map(); // token -> the real text
|
|
1443
|
+
// ---------- pasted images (task #284) ----------
|
|
1444
|
+
const imageStore = createImageStore();
|
|
1445
|
+
// Shown on the bottom row after a refused or failed image paste; clears itself.
|
|
1446
|
+
let imageNotice = "";
|
|
1447
|
+
let imageNoticeTimer = null;
|
|
1448
|
+
function showImageNotice(text) {
|
|
1449
|
+
imageNotice = text;
|
|
1450
|
+
if (imageNoticeTimer) clearTimeout(imageNoticeTimer);
|
|
1451
|
+
imageNoticeTimer = setTimeout(() => {
|
|
1452
|
+
imageNotice = "";
|
|
1453
|
+
imageNoticeTimer = null;
|
|
1454
|
+
if (barPinned()) paintBar();
|
|
1455
|
+
}, 12_000);
|
|
1456
|
+
imageNoticeTimer.unref?.();
|
|
1457
|
+
if (barPinned()) paintBar();
|
|
1458
|
+
}
|
|
1459
|
+
/** Images go to the coding model; only a Claude coder takes them for now. */
|
|
1460
|
+
const coderTakesImages = () => isClaudeCoderUrl(sessionCodingUrl || defaultCodingUrl);
|
|
1461
|
+
function insertAtCaret(text) {
|
|
1462
|
+
dropSelectedInputText();
|
|
1463
|
+
const before = rl.line.slice(0, rl.cursor);
|
|
1464
|
+
const after = rl.line.slice(rl.cursor);
|
|
1465
|
+
rl.line = before + text + after;
|
|
1466
|
+
rl.cursor = before.length + text.length;
|
|
1467
|
+
rl._refreshLine();
|
|
1468
|
+
}
|
|
1469
|
+
/** A pasted image file → "[Image #N]". → false when refused (the path then goes in as text). */
|
|
1470
|
+
function pasteImageFile(path) {
|
|
1471
|
+
if (!coderTakesImages()) {
|
|
1472
|
+
showImageNotice(IMAGE_NEEDS_CLAUDE);
|
|
1473
|
+
return false;
|
|
1474
|
+
}
|
|
1475
|
+
const r = storeImageFile(path);
|
|
1476
|
+
if (r.error) {
|
|
1477
|
+
showImageNotice(r.error);
|
|
1478
|
+
return false;
|
|
1479
|
+
}
|
|
1480
|
+
insertAtCaret(imageStore.add(r.path));
|
|
1481
|
+
return true;
|
|
1482
|
+
}
|
|
1483
|
+
/** Ctrl+V (or an empty paste): the clipboard's image → "[Image #N]". */
|
|
1484
|
+
function pasteClipboardImage() {
|
|
1485
|
+
if (!coderTakesImages()) {
|
|
1486
|
+
showImageNotice(IMAGE_NEEDS_CLAUDE);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
const r = clipboardImageToFile();
|
|
1490
|
+
if (r.none) showImageNotice(`no image on the clipboard (copy a screenshot, then ${imagePasteKey()})`);
|
|
1491
|
+
else if (r.error) showImageNotice(r.error);
|
|
1492
|
+
else insertAtCaret(imageStore.add(r.path));
|
|
1493
|
+
}
|
|
1427
1494
|
let pasteSeq = 0;
|
|
1428
1495
|
const PASTE_TOKEN_RE = /\[Pasted text #(\d+)(?: \+\d+ lines)?\]/g;
|
|
1429
1496
|
|
|
@@ -1579,7 +1646,10 @@ rl.on("line", (l) => {
|
|
|
1579
1646
|
// line is finalised, so every downstream consumer (the agent request, history, the session
|
|
1580
1647
|
// file) sees the real text — a placeholder reaching the model would silently drop what the
|
|
1581
1648
|
// user pasted.
|
|
1582
|
-
|
|
1649
|
+
// Images too (task #284): "[Image #1]" goes out as "[Image #1: <file>]" for the agent to
|
|
1650
|
+
// attach; the next message numbers its images from #1 again.
|
|
1651
|
+
const clean = imageStore.expand(expandPasteTokens(stripAnsiEscapes(submitted)));
|
|
1652
|
+
imageStore.clear();
|
|
1583
1653
|
const waiter = _lineWaiters.pop(); // innermost reader first — see _lineWaiters
|
|
1584
1654
|
if (waiter) waiter(clean);
|
|
1585
1655
|
else _lineQueue.push(clean);
|
|
@@ -1779,7 +1849,8 @@ function slashCloseSeq(line, cursor, cols) {
|
|
|
1779
1849
|
let editMode = "accept"; // "accept" | "manual"
|
|
1780
1850
|
const modeLineText = () =>
|
|
1781
1851
|
white("✎ " + (editMode === "accept" ? "accept edits on" : "manually edit")) +
|
|
1782
|
-
dim(" (shift+tab to switch)")
|
|
1852
|
+
dim(" (shift+tab to switch)") +
|
|
1853
|
+
(imageNotice ? " " + orange("⚠ " + imageNotice) : "");
|
|
1783
1854
|
// Token usage for the bar's middle row (task #104): the LAST turn's code-model input +
|
|
1784
1855
|
// output, then the coder's lifetime output total. Unlike the old per-turn print, this row is
|
|
1785
1856
|
// ALWAYS on screen — a turn that did no code-model work honestly reads "0", and the lifetime
|
|
@@ -3734,6 +3805,7 @@ if (process.stdin.isTTY) {
|
|
|
3734
3805
|
// A deleted token means its payload is no longer referenced — drop it, so a long paste
|
|
3735
3806
|
// the user removed isn't silently sent anyway.
|
|
3736
3807
|
if (pasteStore.size) prunePasteStore(rl.line);
|
|
3808
|
+
if (imageStore.size) imageStore.prune(rl.line);
|
|
3737
3809
|
// ↑/↓ while the menu is up = move the highlight IN PLACE. readline drawing is
|
|
3738
3810
|
// suppressed (slashOverlayActive) and history neutralized, so its own ↑/↓ do
|
|
3739
3811
|
// nothing — we just move the selection and re-render the overlay (bug #109).
|
|
@@ -11195,6 +11267,7 @@ async function main() {
|
|
|
11195
11267
|
rl.historyIndex = -1;
|
|
11196
11268
|
if (_lineQueue.length > 0) _lineQueue.length = 0;
|
|
11197
11269
|
prunePasteStore("");
|
|
11270
|
+
imageStore.clear();
|
|
11198
11271
|
if (barPinned()) paintBar();
|
|
11199
11272
|
};
|
|
11200
11273
|
|
package/image-paste.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pasting an image into the gu input (task #284).
|
|
3
|
+
*
|
|
4
|
+
* The image goes to the CODING model, the way Claude Code does it — no OCR step: the model sees
|
|
5
|
+
* the screenshot itself. For now only a Claude coder (api.anthropic.com) may receive one; with
|
|
6
|
+
* any other coder a paste is refused and the bar says to pick a Claude model.
|
|
7
|
+
*
|
|
8
|
+
* How it reaches the model. The API passes the message TEXT and nothing else, so the image rides
|
|
9
|
+
* in the text as a marker: the input shows "[Image #1]" (like "[Pasted text #1]"), and the line
|
|
10
|
+
* that is sent carries "[Image #1: /Users/…/.gonext/images/…png]". The agent reads the file,
|
|
11
|
+
* attaches it to the request, and puts "[Image #1]" back in the text it shows the model. Files
|
|
12
|
+
* are copied into ~/.gonext/images first; the agent reads images from there and nowhere else.
|
|
13
|
+
*
|
|
14
|
+
* Two ways in:
|
|
15
|
+
* · a key that reads the clipboard's image (a copied screenshot). Terminals paste only text, so
|
|
16
|
+
* Cmd+V with an image on the clipboard sends nothing. Ctrl+V on macOS/Linux, ALT+V on
|
|
17
|
+
* Windows — Windows Terminal takes Ctrl+V for its own paste and the app never sees it (the
|
|
18
|
+
* same reason Claude Code uses Alt+V there). Both keys work everywhere; the hint names one.
|
|
19
|
+
* · a pasted or dragged image FILE path — the terminal pastes its path as text. On Windows the
|
|
20
|
+
* most reliable way; Windows Terminal quotes a path with spaces.
|
|
21
|
+
*/
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import path, { extname, join } from "node:path";
|
|
26
|
+
|
|
27
|
+
export const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
28
|
+
/** Anthropic's limit per image. A bigger one would fail the whole request. */
|
|
29
|
+
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
export const imagesDir = (home = homedir()) => join(home, ".gonext", "images");
|
|
32
|
+
|
|
33
|
+
/** Is this coding server Claude's own API — the only one allowed images for now? */
|
|
34
|
+
export function isClaudeCoderUrl(url) {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(String(url ?? "")).hostname.toLowerCase().replace(/\.$/, "") === "api.anthropic.com";
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const IMAGE_NEEDS_CLAUDE =
|
|
43
|
+
"image paste needs a Claude coding model — pick one with /model";
|
|
44
|
+
|
|
45
|
+
/** The key to name in hints on this platform (both work everywhere). */
|
|
46
|
+
export const imagePasteKey = (platform = process.platform) => (platform === "win32" ? "Alt+V" : "Ctrl+V");
|
|
47
|
+
|
|
48
|
+
/** Is this keypress the "paste an image" key? Ctrl+V, or Alt+V (readline reports Alt as meta). */
|
|
49
|
+
export function isImagePasteKey(key) {
|
|
50
|
+
if (!key || key.name !== "v") return false;
|
|
51
|
+
return Boolean(key.ctrl && !key.meta) || Boolean(key.meta && !key.ctrl);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A Windows path: a drive letter (C:\… or C:/…) or a UNC share (\\server\…). */
|
|
55
|
+
const isWindowsPath = (t) => /^[A-Za-z]:[\\/]/.test(t) || /^\\\\[^\\]/.test(t);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A pasted string → the image file it names, or "" when it is not exactly one image path.
|
|
59
|
+
* Handles what terminals actually paste for a dragged file: quotes, backslash-escaped spaces,
|
|
60
|
+
* and file:// URLs.
|
|
61
|
+
*/
|
|
62
|
+
export function imagePathFromPaste(text, { cwd = process.cwd(), exists = existsSync, platform = process.platform } = {}) {
|
|
63
|
+
let t = String(text ?? "").trim();
|
|
64
|
+
if (!t || t.includes("\n")) return "";
|
|
65
|
+
const win = platform === "win32";
|
|
66
|
+
if (/^(['"]).*\1$/.test(t)) t = t.slice(1, -1);
|
|
67
|
+
// A backslash is an ESCAPE only in a POSIX shell's spelling (Terminal.app drags "a\ b.png").
|
|
68
|
+
// On Windows it is the path separator — unescaping turned C:\Users\x.png into C:Usersx.png.
|
|
69
|
+
else if (!win && !isWindowsPath(t)) t = t.replace(/\\(.)/g, "$1");
|
|
70
|
+
if (t.startsWith("file://")) {
|
|
71
|
+
try {
|
|
72
|
+
t = decodeURIComponent(new URL(t).pathname);
|
|
73
|
+
} catch {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (t.startsWith("~/") || (win && t.startsWith("~\\"))) t = join(homedir(), t.slice(2));
|
|
78
|
+
const P = win ? path.win32 : path.posix;
|
|
79
|
+
if (!IMAGE_EXTS.has(P.extname(t).toLowerCase())) return "";
|
|
80
|
+
const p = P.resolve(cwd, t);
|
|
81
|
+
return exists(p) ? p : "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function newImagePath(ext, home) {
|
|
85
|
+
const dir = imagesDir(home);
|
|
86
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
87
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..*/, "").replace("T", "-");
|
|
88
|
+
return join(dir, `${stamp}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Copy an image file into ~/.gonext/images. → { path } or { error }. */
|
|
92
|
+
export function storeImageFile(src, { home = homedir() } = {}) {
|
|
93
|
+
let size = 0;
|
|
94
|
+
try {
|
|
95
|
+
size = statSync(src).size;
|
|
96
|
+
} catch {
|
|
97
|
+
return { error: `cannot read ${src}` };
|
|
98
|
+
}
|
|
99
|
+
if (size > MAX_IMAGE_BYTES) return { error: tooBig(size) };
|
|
100
|
+
const dest = newImagePath(extname(src).toLowerCase() === ".jpeg" ? ".jpg" : extname(src).toLowerCase(), home);
|
|
101
|
+
copyFileSync(src, dest);
|
|
102
|
+
chmodSync(dest, 0o600);
|
|
103
|
+
return { path: dest };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const tooBig = (n) => `the image is ${(n / 1048576).toFixed(1)} MB — Claude accepts up to 5 MB`;
|
|
107
|
+
|
|
108
|
+
/** The command that writes the clipboard's image to `dest` as PNG, per platform (null = none). */
|
|
109
|
+
export function clipboardImageCommand(dest, platform = process.platform) {
|
|
110
|
+
if (platform === "darwin") {
|
|
111
|
+
return {
|
|
112
|
+
cmd: "osascript",
|
|
113
|
+
args: [
|
|
114
|
+
"-e", "set png to (the clipboard as «class PNGf»)",
|
|
115
|
+
"-e", `set f to open for access POSIX file ${JSON.stringify(dest)} with write permission`,
|
|
116
|
+
"-e", "set eof f to 0",
|
|
117
|
+
"-e", "write png to f",
|
|
118
|
+
"-e", "close access f",
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (platform === "win32") {
|
|
123
|
+
// Windows PowerShell 5.1 (powershell.exe) is on every Windows 11; PowerShell 7 (pwsh) is not.
|
|
124
|
+
// The clipboard API needs an STA thread. A screenshot (Win+Shift+S, PrtScn) is a bitmap; a
|
|
125
|
+
// file copied in Explorer is a file-drop list — both are handled.
|
|
126
|
+
const d = dest.replace(/'/g, "''");
|
|
127
|
+
return {
|
|
128
|
+
cmd: "powershell.exe",
|
|
129
|
+
args: ["-NoProfile", "-NonInteractive", "-STA", "-Command",
|
|
130
|
+
"Add-Type -AssemblyName System.Windows.Forms, System.Drawing; " +
|
|
131
|
+
"$c = [System.Windows.Forms.Clipboard]; $i = $c::GetImage(); " +
|
|
132
|
+
`if ($i) { $i.Save('${d}', [System.Drawing.Imaging.ImageFormat]::Png); exit 0 }; ` +
|
|
133
|
+
"foreach ($p in $c::GetFileDropList()) { if ($p -match '\\.(png|jpe?g|gif|webp)$') { " +
|
|
134
|
+
`Copy-Item -LiteralPath $p -Destination '${d}'; exit 0 } }; exit 1`],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { cmd: "sh", args: ["-c", `(wl-paste --type image/png 2>/dev/null || xclip -selection clipboard -t image/png -o 2>/dev/null) > '${dest.replace(/'/g, "'\\''")}' && [ -s '${dest.replace(/'/g, "'\\''")}' ]`] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Save the clipboard's image into ~/.gonext/images. → { path } | { error } | { none: true }. */
|
|
141
|
+
export function clipboardImageToFile({ home = homedir(), platform = process.platform, run = spawnSync } = {}) {
|
|
142
|
+
const dest = newImagePath(".png", home);
|
|
143
|
+
const { cmd, args } = clipboardImageCommand(dest, platform);
|
|
144
|
+
const r = run(cmd, args, { stdio: "ignore", timeout: 10_000 });
|
|
145
|
+
let size = 0;
|
|
146
|
+
try {
|
|
147
|
+
size = statSync(dest).size;
|
|
148
|
+
} catch {
|
|
149
|
+
/* nothing written */
|
|
150
|
+
}
|
|
151
|
+
if (r?.status !== 0 || size === 0) return { none: true };
|
|
152
|
+
if (size > MAX_IMAGE_BYTES) return { error: tooBig(size) };
|
|
153
|
+
chmodSync(dest, 0o600);
|
|
154
|
+
return { path: dest };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Placeholders shown in the input, and the files behind them. */
|
|
158
|
+
export function createImageStore() {
|
|
159
|
+
const store = new Map(); // token -> absolute path
|
|
160
|
+
let seq = 0;
|
|
161
|
+
const TOKEN_RE = /\[Image #(\d+)\]/g;
|
|
162
|
+
return {
|
|
163
|
+
get size() {
|
|
164
|
+
return store.size;
|
|
165
|
+
},
|
|
166
|
+
add(path) {
|
|
167
|
+
const token = `[Image #${++seq}]`;
|
|
168
|
+
store.set(token, path);
|
|
169
|
+
return token;
|
|
170
|
+
},
|
|
171
|
+
/** The line to SEND: each token carries its file, for the agent to attach. */
|
|
172
|
+
expand(line) {
|
|
173
|
+
const s = String(line ?? "");
|
|
174
|
+
if (!store.size) return s;
|
|
175
|
+
return s.replace(TOKEN_RE, (m, n) => (store.has(m) ? `[Image #${n}: ${store.get(m)}]` : m));
|
|
176
|
+
},
|
|
177
|
+
/** Forget images whose token is no longer in the line. */
|
|
178
|
+
prune(line) {
|
|
179
|
+
const present = new Set(String(line ?? "").match(TOKEN_RE) ?? []);
|
|
180
|
+
for (const t of [...store.keys()]) if (!present.has(t)) store.delete(t);
|
|
181
|
+
},
|
|
182
|
+
/** After a message is sent: the next one numbers its images from #1 again. */
|
|
183
|
+
clear() {
|
|
184
|
+
store.clear();
|
|
185
|
+
seq = 0;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
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.752",
|
|
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",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"test:cache-e2e": "GONEXT_OLLAMA1_LIVE=1 node --test --test-timeout=1800000 tests/prefix-cache-e2e.test.mjs",
|
|
38
38
|
"test:tokens": "python3 -m unittest discover tests -p \"test_cached_tokens.py\" -v && node --test tests/prefix-cache.test.mjs tests/agent-turn-shape.test.mjs",
|
|
39
39
|
"test:selection": "node --test tests/selection-scrollback.test.mjs tests/selection-while-thinking.test.mjs tests/terminal-selection.test.mjs tests/terminal-copy.test.mjs tests/terminal-viewport.test.mjs tests/hover-repaint.test.mjs",
|
|
40
|
+
"test:steps": "python3 scripts/py-tap.py tests/test_read_caps.py tests/test_trim_payback.py tests/test_explore.py tests/test_and_chains.py tests/test_edit_file_primary.py tests/test_bare_code_rescue.py tests/test_batching_rule.py tests/test_no_pdf_routing.py",
|
|
41
|
+
"check:image": "node scripts/image-paste-check.mjs",
|
|
42
|
+
"check:image-clipboard": "node scripts/image-paste-check.mjs --clipboard",
|
|
43
|
+
"test:skills": "python3 scripts/py-tap.py tests/test_skills_mcp_turn.py tests/test_hooks_turn.py tests/test_mcp_client.py && node --test tests/skills.test.mjs tests/mcp-config.test.mjs tests/hooks-config.test.mjs tests/slash-menu.test.mjs tests/conversation-archive.test.mjs",
|
|
40
44
|
"capture:turn": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-turn-fixture.mjs",
|
|
41
45
|
"test:bearer": "node --test tests/bearer-routing.test.mjs tests/bearer-verify.test.mjs",
|
|
42
46
|
"test:bearer-e2e": "bash scripts/bearer-e2e.sh"
|
|
@@ -63,6 +67,9 @@
|
|
|
63
67
|
"clear-state.mjs",
|
|
64
68
|
"platform-tools.mjs",
|
|
65
69
|
"job-pools.mjs",
|
|
70
|
+
"agent-stop.mjs",
|
|
71
|
+
"image-paste.mjs",
|
|
72
|
+
"scripts/image-paste-check.mjs",
|
|
66
73
|
"rag-job-config.mjs",
|
|
67
74
|
"completed-flush.mjs",
|
|
68
75
|
"verbose-lines.mjs",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Check image paste on THIS machine, for real (task #284). Written for Windows 11, where it can
|
|
4
|
+
* only be proven on the machine itself: the clipboard belongs to the desktop session, so an ssh
|
|
5
|
+
* session cannot reach it.
|
|
6
|
+
*
|
|
7
|
+
* node scripts/image-paste-check.mjs # paths, storage, the agent side
|
|
8
|
+
* node scripts/image-paste-check.mjs --clipboard # + the real clipboard (REPLACES what is on it)
|
|
9
|
+
*
|
|
10
|
+
* Every check says what it saw. Nothing is sent to a model.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import {
|
|
18
|
+
clipboardImageToFile,
|
|
19
|
+
imagePasteKey,
|
|
20
|
+
imagePathFromPaste,
|
|
21
|
+
imagesDir,
|
|
22
|
+
storeImageFile,
|
|
23
|
+
} from "../image-paste.mjs";
|
|
24
|
+
import { resolvePython } from "../platform-tools.mjs";
|
|
25
|
+
|
|
26
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const WORKER = dirname(HERE);
|
|
28
|
+
const withClipboard = process.argv.includes("--clipboard");
|
|
29
|
+
const PNG = Buffer.from(
|
|
30
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
31
|
+
"base64",
|
|
32
|
+
);
|
|
33
|
+
const isPng = (p) => existsSync(p) && readFileSync(p).subarray(0, 8).equals(PNG.subarray(0, 8));
|
|
34
|
+
|
|
35
|
+
let failed = 0;
|
|
36
|
+
const made = []; // images this check stored — removed at the end
|
|
37
|
+
|
|
38
|
+
const check = (name, ok, detail = "") => {
|
|
39
|
+
if (!ok) failed++;
|
|
40
|
+
console.log(` ${name} … ${ok ? "ok" : "FAILED"}${detail ? ` ${detail}` : ""}`);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
console.log(`image paste check — ${process.platform} ${process.arch}, node ${process.version}`);
|
|
44
|
+
console.log(` the image key here: ${imagePasteKey()}`);
|
|
45
|
+
|
|
46
|
+
// 1. A dropped path, spelled the way this platform's terminal pastes it.
|
|
47
|
+
const dir = mkdtempSync(join(tmpdir(), "gu img check "));
|
|
48
|
+
const shot = join(dir, "Screen Shot 1.png");
|
|
49
|
+
writeFileSync(shot, PNG);
|
|
50
|
+
const spelled = process.platform === "win32" ? `"${shot}"` : shot.replace(/ /g, "\\ ");
|
|
51
|
+
check("a dropped path with spaces is recognised", imagePathFromPaste(spelled) === shot, `${spelled} → ${imagePathFromPaste(spelled)}`);
|
|
52
|
+
|
|
53
|
+
// 2. It is copied into ~/.gonext/images.
|
|
54
|
+
const stored = storeImageFile(shot);
|
|
55
|
+
if (stored.path) made.push(stored.path);
|
|
56
|
+
check("copied into the images folder", Boolean(stored.path?.startsWith(imagesDir())) && isPng(stored.path), stored.path ?? stored.error);
|
|
57
|
+
|
|
58
|
+
// 3. The agent reads it back from the marker, and only from the images folder.
|
|
59
|
+
const py = await resolvePython();
|
|
60
|
+
const snippet = [
|
|
61
|
+
"import importlib.util, json, sys",
|
|
62
|
+
"spec = importlib.util.spec_from_file_location('a', sys.argv[1]); a = importlib.util.module_from_spec(spec); spec.loader.exec_module(a)",
|
|
63
|
+
"msgs = [{'role': 'user', 'content': 'look [Image #1: ' + sys.argv[2] + '] and [Image #2: ' + sys.argv[3] + ']'}]",
|
|
64
|
+
"refs = a._take_image_markers(msgs)",
|
|
65
|
+
"imgs = a._load_turn_images(refs)",
|
|
66
|
+
"print(json.dumps({'refs': refs, 'text': msgs[0]['content'], 'opened': len(imgs)}))",
|
|
67
|
+
].join("\n");
|
|
68
|
+
const r = spawnSync(py.cmd, [...py.args, "-c", snippet, join(WORKER, "gonext_agent_chat.py"), stored.path ?? "", shot], {
|
|
69
|
+
encoding: "utf8", timeout: 120_000, env: { ...process.env, PYTHONUTF8: "1" },
|
|
70
|
+
});
|
|
71
|
+
let out = null;
|
|
72
|
+
try {
|
|
73
|
+
out = JSON.parse(String(r.stdout).trim().split("\n").pop());
|
|
74
|
+
} catch {
|
|
75
|
+
/* reported below */
|
|
76
|
+
}
|
|
77
|
+
check("the agent attaches the stored image", out?.refs?.length === 1 && out?.opened === 1, out ? JSON.stringify(out.refs) : String(r.stderr).slice(-300));
|
|
78
|
+
check("…and refuses a file outside the images folder", Boolean(out?.text?.includes("[Image #2] (the image could not be read)")), out?.text ?? "");
|
|
79
|
+
|
|
80
|
+
// 4. The real clipboard (opt-in: it replaces what is on it).
|
|
81
|
+
if (withClipboard) {
|
|
82
|
+
if (process.platform === "win32") {
|
|
83
|
+
const ps = (script) => spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-STA", "-Command", script], { encoding: "utf8" });
|
|
84
|
+
const set = ps("Add-Type -AssemblyName System.Windows.Forms, System.Drawing; " +
|
|
85
|
+
"$b = New-Object System.Drawing.Bitmap 4, 4; [System.Windows.Forms.Clipboard]::SetImage($b); 'set'");
|
|
86
|
+
check("put a bitmap on the clipboard (like Win+Shift+S)", set.status === 0, String(set.stderr).trim().slice(0, 200));
|
|
87
|
+
const got = clipboardImageToFile();
|
|
88
|
+
if (got.path) made.push(got.path);
|
|
89
|
+
check(`${imagePasteKey()} reads a screenshot`, Boolean(got.path) && isPng(got.path), got.path ?? JSON.stringify(got));
|
|
90
|
+
const drop = ps(`Set-Clipboard -LiteralPath '${shot.replace(/'/g, "''")}'; 'set'`);
|
|
91
|
+
check("put a FILE on the clipboard (like Ctrl+C in Explorer)", drop.status === 0, String(drop.stderr).trim().slice(0, 200));
|
|
92
|
+
const got2 = clipboardImageToFile();
|
|
93
|
+
if (got2.path) made.push(got2.path);
|
|
94
|
+
check(`${imagePasteKey()} reads a copied image file`, Boolean(got2.path) && isPng(got2.path), got2.path ?? JSON.stringify(got2));
|
|
95
|
+
ps("Set-Clipboard -Value ' '");
|
|
96
|
+
check("empty clipboard is 'none', not an error", clipboardImageToFile().none === true);
|
|
97
|
+
} else if (process.platform === "darwin") {
|
|
98
|
+
const set = spawnSync("osascript", ["-e", `set the clipboard to (read (POSIX file ${JSON.stringify(shot)}) as «class PNGf»)`]);
|
|
99
|
+
check("put an image on the clipboard", set.status === 0);
|
|
100
|
+
const got = clipboardImageToFile();
|
|
101
|
+
if (got.path) made.push(got.path);
|
|
102
|
+
check(`${imagePasteKey()} reads it`, Boolean(got.path) && isPng(got.path), got.path ?? JSON.stringify(got));
|
|
103
|
+
} else {
|
|
104
|
+
console.log(" (clipboard: on Linux install wl-clipboard or xclip; not checked here)");
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
console.log(" (clipboard not checked — run with --clipboard; it replaces what is on your clipboard)");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
rmSync(dir, { recursive: true, force: true });
|
|
111
|
+
for (const p of made) rmSync(p, { force: true });
|
|
112
|
+
console.log(failed ? `\n ${failed} check(s) FAILED` : "\n all checks passed");
|
|
113
|
+
process.exit(failed ? 1 : 0);
|