@tiens.nguyen/gu-cli 1.0.751 → 1.0.753

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 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
+ }
@@ -6578,7 +6578,62 @@ _WS_DESTRUCTIVE_RE = re.compile(
6578
6578
  )
6579
6579
 
6580
6580
 
6581
- def _ws_command_risk(command, argv, denyset):
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
@@ -7002,6 +7089,107 @@ def _rag_s3_client(region: str, akid: str, secret: str):
7002
7089
  )
7003
7090
 
7004
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
+ # WHAT CLAUDE WILL TAKE (task #285). smolagents re-encodes every image as PNG, base64, before
7129
+ # sending (smolagents.utils.encode_image_base64) — so a 4 MB JPEG photo can become a PNG over the
7130
+ # 5 MB per-image limit, and then Claude rejects the WHOLE request, not just the image. Claude
7131
+ # also scales anything with a long edge over 1568 px down to that (and rejects > 8000 px), so
7132
+ # shrinking first sends the same picture Claude would see, in fewer bytes.
7133
+ _IMAGE_MAX_EDGE = 1568
7134
+ _IMAGE_MAX_B64 = 5 * 1024 * 1024 # measured on the base64 string — the stricter reading
7135
+
7136
+
7137
+ def _png_b64_len(im) -> int:
7138
+ import io
7139
+ buf = io.BytesIO()
7140
+ im.save(buf, format="PNG")
7141
+ return (len(buf.getvalue()) + 2) // 3 * 4
7142
+
7143
+
7144
+ def _prepare_image(path):
7145
+ """Open `path` and return a PIL image Claude will accept, or raise ValueError(reason)."""
7146
+ from PIL import Image
7147
+ try:
7148
+ with Image.open(path) as src:
7149
+ src.load() # a truncated or non-image file fails here, not later in the request
7150
+ im = src.copy()
7151
+ except Image.DecompressionBombError:
7152
+ raise ValueError("too many pixels to open safely") from None
7153
+ except Exception as e: # noqa: BLE001
7154
+ raise ValueError(f"not a readable image ({type(e).__name__})") from None
7155
+ # Colour modes PNG cannot hold, or holds oddly: CMYK photos, 16-bit, float, palette.
7156
+ if im.mode == "P":
7157
+ im = im.convert("RGBA" if "transparency" in im.info else "RGB")
7158
+ elif im.mode not in ("RGB", "RGBA", "L", "LA"):
7159
+ im = im.convert("RGBA" if "A" in im.mode else "RGB")
7160
+ if max(im.size) > _IMAGE_MAX_EDGE:
7161
+ im.thumbnail((_IMAGE_MAX_EDGE, _IMAGE_MAX_EDGE), Image.LANCZOS)
7162
+ while _png_b64_len(im) > _IMAGE_MAX_B64:
7163
+ if max(im.size) <= 256:
7164
+ raise ValueError("still over 5 MB after shrinking")
7165
+ im = im.resize((max(1, im.width * 3 // 4), max(1, im.height * 3 // 4)), Image.LANCZOS)
7166
+ return im
7167
+
7168
+
7169
+ def _load_turn_images(refs):
7170
+ """[(label, path)] → (PIL images ready for Claude, [(label, reason)] for the ones that failed)."""
7171
+ out, failed = [], []
7172
+ try:
7173
+ import PIL # noqa: F401
7174
+ except Exception: # noqa: BLE001
7175
+ return out, [(label, "image support (Pillow) is not installed") for label, _ in refs]
7176
+ for label, path in refs:
7177
+ try:
7178
+ out.append(_prepare_image(path))
7179
+ except ValueError as e:
7180
+ failed.append((label, str(e)))
7181
+ _log(f"image {label}: not attached — {e}")
7182
+ return out, failed
7183
+
7184
+
7185
+ def _label_failed_images(text, failed):
7186
+ """Say which images did not make it, where the model reads their labels — a bare
7187
+ "[Image #1]" with nothing attached invites the model to guess what it shows."""
7188
+ for label, reason in failed:
7189
+ text = text.replace(label, f"{label} (could not be opened: {reason})")
7190
+ return text
7191
+
7192
+
7005
7193
  def run_agent_chat(cfg):
7006
7194
  # Task #249: what this run may log — set FIRST, before anything is logged (the history and the
7007
7195
  # current task are logged early, and a real turn caught them leaking when this came later).
@@ -7031,6 +7219,7 @@ def run_agent_chat(cfg):
7031
7219
  return
7032
7220
 
7033
7221
  messages = cfg.get("messages") or []
7222
+ _turn_image_refs = _take_image_markers(messages)
7034
7223
  agent_base_url = cfg.get("agentBaseURL") or ""
7035
7224
  # THE PROXY, configured before any client is built (task #160). The allowlist is the three
7036
7225
  # model endpoints as the WORKER resolved them; the raw cfg values are used rather than the
@@ -11893,6 +12082,7 @@ def run_agent_chat(cfg):
11893
12082
  workdir: directory to run in. Empty = first registered workspace.
11894
12083
  timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
11895
12084
  """
12085
+ proc = None # read by the `finally` below, which runs even if Popen never did
11896
12086
  try:
11897
12087
  import os
11898
12088
  import shlex
@@ -12009,7 +12199,7 @@ def run_agent_chat(cfg):
12009
12199
  # Risk gate: privilege escalation / denylisted / destructive. When the
12010
12200
  # client can approve interactively (terminal), PAUSE and ask the user;
12011
12201
  # otherwise (web) keep hard-blocking the privilege/denylist cases.
12012
- _hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST)
12202
+ _hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST, wd)
12013
12203
  # Shift+Tab may have switched it since the turn started (#47).
12014
12204
  global _accept_edits
12015
12205
  if _interactive_approval and _job_id:
@@ -12092,6 +12282,7 @@ def run_agent_chat(cfg):
12092
12282
  stdout=log_fh, stderr=subprocess.STDOUT,
12093
12283
  **_ws_spawn_kwargs(),
12094
12284
  )
12285
+ _INFLIGHT_CMDS.add(proc.pid) # killed with the agent until it is classified
12095
12286
  start = _t.time()
12096
12287
 
12097
12288
  def _read_log() -> str:
@@ -12205,6 +12396,10 @@ def run_agent_chat(cfg):
12205
12396
  _last_obs["text"] = msg
12206
12397
  return msg
12207
12398
  finally:
12399
+ # Exited, killed at the deadline, or left running ON PURPOSE as a server/app
12400
+ # (tracked in the servers file for stop_server): no longer in flight either way.
12401
+ if proc is not None:
12402
+ _INFLIGHT_CMDS.discard(proc.pid)
12208
12403
  try:
12209
12404
  log_fh.close()
12210
12405
  except Exception: # noqa: BLE001
@@ -12655,8 +12850,25 @@ def run_agent_chat(cfg):
12655
12850
  _RAG_STATE["s"] = "no-index"
12656
12851
  except Exception: # noqa: BLE001
12657
12852
  pass
12853
+ # The question's pasted images (task #284): a Claude coder sees them; any other coder is
12854
+ # told they were not sent rather than left to guess from a bare "[Image #1]".
12855
+ _turn_images = []
12856
+ if _turn_image_refs:
12857
+ if _is_anthropic_url(coding_base_url):
12858
+ _turn_images, _img_failed = _load_turn_images(_turn_image_refs)
12859
+ task_with_hint = _label_failed_images(task_with_hint, _img_failed)
12860
+ _log(f"attached {len(_turn_images)} image(s) to the question"
12861
+ + (f", {len(_img_failed)} could not be opened" if _img_failed else ""))
12862
+ if _turn_images:
12863
+ _emit({"type": "step", "text": f"Sending {len(_turn_images)} image(s) with the question"})
12864
+ for _lbl, _why in _img_failed:
12865
+ _emit({"type": "step", "text": f"⚠ {_lbl} not sent — {_why}"})
12866
+ else:
12867
+ task_with_hint += ("\n\n(The user pasted " + ", ".join(l for l, _ in _turn_image_refs)
12868
+ + " but this coding model is not sent images — say so if the "
12869
+ "task needs them.)")
12658
12870
  with contextlib.redirect_stdout(sys.stderr):
12659
- result = agent.run(task_with_hint)
12871
+ result = agent.run(task_with_hint, images=_turn_images or None)
12660
12872
  while _maxsteps_partial.get("hit"):
12661
12873
  if _always_extend:
12662
12874
  _log(f"max-steps reached → auto-extending to {_MAXSTEP_CEILING} "
@@ -12887,9 +13099,41 @@ def _emit(obj):
12887
13099
  Lock-guarded: the model-call heartbeat thread emits concurrently with the
12888
13100
  main loop, and interleaved writes would corrupt the NDJSON stream.
12889
13101
  """
13102
+ live = isinstance(obj, dict) and obj.pop("_liveness", False)
12890
13103
  with _EMIT_LOCK:
12891
13104
  _REAL_STDOUT.write(json.dumps(obj) + "\n")
12892
13105
  _REAL_STDOUT.flush()
13106
+ if live:
13107
+ return
13108
+ _LIVENESS["last"] = time.monotonic()
13109
+ if obj.get("type") == "final":
13110
+ _LIVENESS["stop"].set()
13111
+
13112
+
13113
+ # A TURN IS ALIVE WHILE ANYTHING RUNS, NOT ONLY THE MODEL. The API fails a job after 5 minutes
13114
+ # with no update (#209), sized on the model-call heartbeat — but only a model call had one. A
13115
+ # run_command may wait up to 10 minutes (and an MCP tool as long as it likes) in silence:
13116
+ # reported 09-25, `sdkmanager` downloading a system image was "auto-failed after 5m without
13117
+ # updates" at 28% while the download carried on. This fires only when nothing else has been
13118
+ # sent for _LIVENESS_EVERY_S, so a model call's own heartbeat still speaks for itself. Same
13119
+ # "Word… (Ns)" shape: the REPL swallows it (its ticker shows progress); the job stays fresh.
13120
+ _LIVENESS_EVERY_S = int(os.environ.get("GONEXT_LIVENESS_S") or 45) # env: tests only
13121
+ _LIVENESS = {"last": time.monotonic(), "stop": threading.Event()}
13122
+
13123
+
13124
+ def _liveness_loop(every=None):
13125
+ every = every or _LIVENESS_EVERY_S
13126
+ while not _LIVENESS["stop"].wait(min(every, 15)):
13127
+ quiet = time.monotonic() - _LIVENESS["last"]
13128
+ if quiet >= every and not _LIVENESS["stop"].is_set():
13129
+ _emit({"type": "step", "text": f"Still working… ({int(quiet)}s)", "_liveness": True})
13130
+
13131
+
13132
+ def _start_liveness(every=None):
13133
+ _LIVENESS["last"] = time.monotonic()
13134
+ t = threading.Thread(target=_liveness_loop, args=(every,), daemon=True)
13135
+ t.start()
13136
+ return t
12893
13137
 
12894
13138
 
12895
13139
  def main():
@@ -12898,6 +13142,8 @@ def main():
12898
13142
  except Exception as e: # noqa: BLE001
12899
13143
  _emit({"type": "final", "text": f"[Invalid input: {e}]"})
12900
13144
  return
13145
+ _start_liveness()
13146
+ _install_sigterm_handler()
12901
13147
  run_agent_chat(cfg)
12902
13148
 
12903
13149
 
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) || 2
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.kill("SIGKILL");
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.kill("SIGKILL");
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.kill("SIGKILL");
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 cancelledNow =
3608
- res.status === 409 && snippet.includes('"jobStatus":"cancelled"');
3609
- if (cancelledNow) cancelAc.abort(); // REPL cancelled mid-run → stop the Python agent
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). Two turns run concurrently by default
3744
- // (agentConcurrency = 2), and each may have chosen a different key — so the key is
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, instead of
3989
- // only being noticed on the next chunk flush (which can be 45s apart during a long
3990
- // model call). The job-chunk 409 path (flushChunks) is the backup signal.
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 === "cancelled") {
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(`[gu-worker] agent_chat ${jobId} cancelled by user`);
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(`[gu-worker] agent_chat ${jobId} cancelled by user`);
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
- if (!text) return;
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
- const clean = expandPasteTokens(stripAnsiEscapes(submitted));
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
 
@@ -0,0 +1,235 @@
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, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readSync, rmSync, statSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import path, { join } from "node:path";
26
+
27
+ export const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
28
+ /**
29
+ * The largest file gu takes at paste time. NOT Claude's 5 MB limit: the agent shrinks every image
30
+ * to what Claude accepts before sending (task #285), so a 12 MB photo is fine. This only stops a
31
+ * file far too big to be a screenshot from being copied around.
32
+ */
33
+ export const MAX_IMAGE_BYTES = 30 * 1024 * 1024;
34
+
35
+ /**
36
+ * What the file's first bytes say it is — "png" | "jpeg" | "gif" | "webp" | "" (task #285). The
37
+ * EXTENSION is only a name: a renamed text file, or a screenshot still being written, has the
38
+ * right one and is not an image.
39
+ */
40
+ export function imageKind(bytes) {
41
+ const b = Buffer.from(bytes ?? []);
42
+ if (b.length >= 8 && b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png";
43
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "jpeg";
44
+ if (b.length >= 6 && /^GIF8[79]a$/.test(b.subarray(0, 6).toString("latin1"))) return "gif";
45
+ if (b.length >= 12 && b.subarray(0, 4).toString("latin1") === "RIFF" && b.subarray(8, 12).toString("latin1") === "WEBP") return "webp";
46
+ return "";
47
+ }
48
+
49
+ function fileImageKind(path) {
50
+ let fd;
51
+ try {
52
+ fd = openSync(path, "r");
53
+ const buf = Buffer.alloc(12);
54
+ const n = readSync(fd, buf, 0, 12, 0);
55
+ return imageKind(buf.subarray(0, n));
56
+ } catch {
57
+ return "";
58
+ } finally {
59
+ if (fd !== undefined) closeSync(fd);
60
+ }
61
+ }
62
+
63
+ const NOT_AN_IMAGE = "that file is not an image (png, jpg, gif or webp)";
64
+
65
+ export const imagesDir = (home = homedir()) => join(home, ".gonext", "images");
66
+
67
+ /** Is this coding server Claude's own API — the only one allowed images for now? */
68
+ export function isClaudeCoderUrl(url) {
69
+ try {
70
+ return new URL(String(url ?? "")).hostname.toLowerCase().replace(/\.$/, "") === "api.anthropic.com";
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ export const IMAGE_NEEDS_CLAUDE =
77
+ "image paste needs a Claude coding model — pick one with /model";
78
+
79
+ /** The key to name in hints on this platform (both work everywhere). */
80
+ export const imagePasteKey = (platform = process.platform) => (platform === "win32" ? "Alt+V" : "Ctrl+V");
81
+
82
+ /** Is this keypress the "paste an image" key? Ctrl+V, or Alt+V (readline reports Alt as meta). */
83
+ export function isImagePasteKey(key) {
84
+ if (!key || key.name !== "v") return false;
85
+ return Boolean(key.ctrl && !key.meta) || Boolean(key.meta && !key.ctrl);
86
+ }
87
+
88
+ /** A Windows path: a drive letter (C:\… or C:/…) or a UNC share (\\server\…). */
89
+ const isWindowsPath = (t) => /^[A-Za-z]:[\\/]/.test(t) || /^\\\\[^\\]/.test(t);
90
+
91
+ /**
92
+ * A pasted string → the image file it names, or "" when it is not exactly one image path.
93
+ * Handles what terminals actually paste for a dragged file: quotes, backslash-escaped spaces,
94
+ * and file:// URLs.
95
+ */
96
+ export function imagePathFromPaste(text, { cwd = process.cwd(), exists = existsSync, platform = process.platform } = {}) {
97
+ let t = String(text ?? "").trim();
98
+ if (!t || t.includes("\n")) return "";
99
+ const win = platform === "win32";
100
+ if (/^(['"]).*\1$/.test(t)) t = t.slice(1, -1);
101
+ // A backslash is an ESCAPE only in a POSIX shell's spelling (Terminal.app drags "a\ b.png").
102
+ // On Windows it is the path separator — unescaping turned C:\Users\x.png into C:Usersx.png.
103
+ else if (!win && !isWindowsPath(t)) t = t.replace(/\\(.)/g, "$1");
104
+ if (t.startsWith("file://")) {
105
+ try {
106
+ t = decodeURIComponent(new URL(t).pathname);
107
+ } catch {
108
+ return "";
109
+ }
110
+ }
111
+ if (t.startsWith("~/") || (win && t.startsWith("~\\"))) t = join(homedir(), t.slice(2));
112
+ const P = win ? path.win32 : path.posix;
113
+ if (!IMAGE_EXTS.has(P.extname(t).toLowerCase())) return "";
114
+ const p = P.resolve(cwd, t);
115
+ return exists(p) ? p : "";
116
+ }
117
+
118
+ function newImagePath(ext, home) {
119
+ const dir = imagesDir(home);
120
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
121
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..*/, "").replace("T", "-");
122
+ return join(dir, `${stamp}-${Math.random().toString(36).slice(2, 8)}${ext}`);
123
+ }
124
+
125
+ /** Copy an image file into ~/.gonext/images. → { path } or { error }. */
126
+ export function storeImageFile(src, { home = homedir() } = {}) {
127
+ let size = 0;
128
+ try {
129
+ size = statSync(src).size;
130
+ } catch {
131
+ return { error: `cannot read ${src}` };
132
+ }
133
+ if (size > MAX_IMAGE_BYTES) return { error: tooBig(size) };
134
+ const kind = fileImageKind(src);
135
+ if (!kind) return { error: NOT_AN_IMAGE };
136
+ const dest = newImagePath(kind === "jpeg" ? ".jpg" : `.${kind}`, home);
137
+ copyFileSync(src, dest);
138
+ chmodSync(dest, 0o600);
139
+ return { path: dest };
140
+ }
141
+
142
+ const tooBig = (n) => `the image is ${(n / 1048576).toFixed(1)} MB — gu takes up to ${MAX_IMAGE_BYTES / 1048576} MB`;
143
+
144
+ /** The command that writes the clipboard's image to `dest` as PNG, per platform (null = none). */
145
+ export function clipboardImageCommand(dest, platform = process.platform) {
146
+ if (platform === "darwin") {
147
+ return {
148
+ cmd: "osascript",
149
+ args: [
150
+ "-e", "set png to (the clipboard as «class PNGf»)",
151
+ "-e", `set f to open for access POSIX file ${JSON.stringify(dest)} with write permission`,
152
+ "-e", "set eof f to 0",
153
+ "-e", "write png to f",
154
+ "-e", "close access f",
155
+ ],
156
+ };
157
+ }
158
+ if (platform === "win32") {
159
+ // Windows PowerShell 5.1 (powershell.exe) is on every Windows 11; PowerShell 7 (pwsh) is not.
160
+ // The clipboard API needs an STA thread. A screenshot (Win+Shift+S, PrtScn) is a bitmap; a
161
+ // file copied in Explorer is a file-drop list — both are handled.
162
+ const d = dest.replace(/'/g, "''");
163
+ return {
164
+ cmd: "powershell.exe",
165
+ args: ["-NoProfile", "-NonInteractive", "-STA", "-Command",
166
+ "Add-Type -AssemblyName System.Windows.Forms, System.Drawing; " +
167
+ "$c = [System.Windows.Forms.Clipboard]; $i = $c::GetImage(); " +
168
+ `if ($i) { $i.Save('${d}', [System.Drawing.Imaging.ImageFormat]::Png); exit 0 }; ` +
169
+ "foreach ($p in $c::GetFileDropList()) { if ($p -match '\\.(png|jpe?g|gif|webp)$') { " +
170
+ `Copy-Item -LiteralPath $p -Destination '${d}'; exit 0 } }; exit 1`],
171
+ };
172
+ }
173
+ 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, "'\\''")}' ]`] };
174
+ }
175
+
176
+ /** Save the clipboard's image into ~/.gonext/images. → { path } | { error } | { none: true }. */
177
+ export function clipboardImageToFile({ home = homedir(), platform = process.platform, run = spawnSync } = {}) {
178
+ const dest = newImagePath(".png", home);
179
+ const { cmd, args } = clipboardImageCommand(dest, platform);
180
+ const r = run(cmd, args, { stdio: "ignore", timeout: 10_000 });
181
+ let size = 0;
182
+ try {
183
+ size = statSync(dest).size;
184
+ } catch {
185
+ /* nothing written */
186
+ }
187
+ if (r?.status !== 0 || size === 0) {
188
+ rmSync(dest, { force: true });
189
+ return { none: true };
190
+ }
191
+ if (size > MAX_IMAGE_BYTES) {
192
+ rmSync(dest, { force: true });
193
+ return { error: tooBig(size) };
194
+ }
195
+ // A file copied in Explorer is copied as-is, so it can be anything with an image's name.
196
+ if (!fileImageKind(dest)) {
197
+ rmSync(dest, { force: true });
198
+ return { error: "the clipboard's file is not an image (png, jpg, gif or webp)" };
199
+ }
200
+ chmodSync(dest, 0o600);
201
+ return { path: dest };
202
+ }
203
+
204
+ /** Placeholders shown in the input, and the files behind them. */
205
+ export function createImageStore() {
206
+ const store = new Map(); // token -> absolute path
207
+ let seq = 0;
208
+ const TOKEN_RE = /\[Image #(\d+)\]/g;
209
+ return {
210
+ get size() {
211
+ return store.size;
212
+ },
213
+ add(path) {
214
+ const token = `[Image #${++seq}]`;
215
+ store.set(token, path);
216
+ return token;
217
+ },
218
+ /** The line to SEND: each token carries its file, for the agent to attach. */
219
+ expand(line) {
220
+ const s = String(line ?? "");
221
+ if (!store.size) return s;
222
+ return s.replace(TOKEN_RE, (m, n) => (store.has(m) ? `[Image #${n}: ${store.get(m)}]` : m));
223
+ },
224
+ /** Forget images whose token is no longer in the line. */
225
+ prune(line) {
226
+ const present = new Set(String(line ?? "").match(TOKEN_RE) ?? []);
227
+ for (const t of [...store.keys()]) if (!present.has(t)) store.delete(t);
228
+ },
229
+ /** After a message is sent: the next one numbers its images from #1 again. */
230
+ clear() {
231
+ store.clear();
232
+ seq = 0;
233
+ },
234
+ };
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.751",
3
+ "version": "1.0.753",
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",
@@ -38,6 +38,8 @@
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
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",
41
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",
42
44
  "capture:turn": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-turn-fixture.mjs",
43
45
  "test:bearer": "node --test tests/bearer-routing.test.mjs tests/bearer-verify.test.mjs",
@@ -65,6 +67,9 @@
65
67
  "clear-state.mjs",
66
68
  "platform-tools.mjs",
67
69
  "job-pools.mjs",
70
+ "agent-stop.mjs",
71
+ "image-paste.mjs",
72
+ "scripts/image-paste-check.mjs",
68
73
  "rag-job-config.mjs",
69
74
  "completed-flush.mjs",
70
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, failed = 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);