@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 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
@@ -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) || 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,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.750",
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);