@tiens.nguyen/gu-cli 1.0.769 → 1.0.770

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 CHANGED
@@ -41,11 +41,28 @@ export const STOP_GRACE_MS = 3000;
41
41
  * Stop a child process: SIGTERM now, SIGKILL after `graceMs` unless it has exited.
42
42
  * → a function that cancels the pending SIGKILL (call it from the child's `close`).
43
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 */
44
+ export function stopGracefully(child, {
45
+ graceMs = STOP_GRACE_MS,
46
+ setTimer = setTimeout,
47
+ clearTimer = clearTimeout,
48
+ platform = process.platform,
49
+ // WINDOWS HAS NO SIGTERM (task #302): child.kill("SIGTERM") there IS TerminateProcess, which no
50
+ // handler sees — so a stop could land in the middle of the agent writing a file. There, ask
51
+ // first (the agent watches GONEXT_STOP_FILE and stops at a safe point) and kill after the grace.
52
+ requestStop = null,
53
+ } = {}) {
54
+ if (platform === "win32" && typeof requestStop === "function") {
55
+ try {
56
+ requestStop();
57
+ } catch {
58
+ /* could not ask — the kill after the grace still stops it */
59
+ }
60
+ } else {
61
+ try {
62
+ child.kill("SIGTERM");
63
+ } catch {
64
+ /* already gone */
65
+ }
49
66
  }
50
67
  const t = setTimer(() => {
51
68
  if (child.exitCode === null && child.signalCode === null) {
@@ -997,6 +997,13 @@ class _AgentConfigError(RuntimeError):
997
997
  worth a plain-reply degrade — the user needs the message itself to fix Settings."""
998
998
 
999
999
 
1000
+ class _AgentOffline(_AgentConfigError):
1001
+ """The network was gone for longer than the wait budget (task #302, part 3). Ends the turn
1002
+ as FAILED — never as a stop — with the work so far kept and "continue" as the way on. A
1003
+ subclass of the config error so it takes the same honest exit: no degrade to a plain reply,
1004
+ which would need the network anyway and would answer as if the work had been done."""
1005
+
1006
+
1000
1007
  class _AgentBackendOverloaded(RuntimeError):
1001
1008
  """The coding backend refused every attempt with 429/rate-limit (task #114). Carries
1002
1009
  the user-facing explanation; like a gateway outage it must NOT degrade to a plain
@@ -3539,6 +3546,78 @@ def _retry_delay(attempt: int, overloaded: bool, gateway: bool, err=None) -> flo
3539
3546
  return 1.5 * attempt
3540
3547
 
3541
3548
 
3549
+ _OFFLINE_ALWAYS = (
3550
+ # The NAME did not resolve, or there is no route at all: this machine is off the network.
3551
+ "nodename nor servname", "name or service not known", "getaddrinfo failed",
3552
+ "temporary failure in name resolution", "name resolution", "network is unreachable",
3553
+ "no route to host", "network is down", "connection error",
3554
+ )
3555
+ _OFFLINE_REMOTE = (
3556
+ # Nothing answered at the other end. For a PUBLIC host that is the network (Wi-Fi dropped, a
3557
+ # VPN reconnecting); for a local/LAN box it is the server itself being down, which the
3558
+ # gateway budget already handles — waiting ten minutes for a stopped Ollama would be wrong.
3559
+ "connection refused", "connection reset", "connection aborted",
3560
+ "failed to establish a new connection", "remote end closed connection",
3561
+ "server disconnected", "connecterror",
3562
+ )
3563
+ _OFFLINE_TYPES = ("APIConnectionError", "ConnectError", "ConnectionError", "RemoteProtocolError",
3564
+ "ConnectionRefusedError", "ConnectionResetError", "ConnectionAbortedError",
3565
+ "gaierror", "NewConnectionError")
3566
+
3567
+
3568
+ def _is_offline(err, host: str = "") -> bool:
3569
+ """Did this call fail because the NETWORK went away, rather than because a server answered?
3570
+ (task #302, part 3). Such a failure is waited out — the same step is retried when the
3571
+ connection is back — instead of costing the turn a few seconds after the Wi-Fi drops.
3572
+
3573
+ Something that answered with a status is never offline, and a timeout is its own class
3574
+ (openai's APITimeoutError subclasses APIConnectionError, so it is excluded by name)."""
3575
+ if err is None or _http_status_of(err) is not None or _is_request_timeout(err):
3576
+ return False
3577
+ local = _host_locality_is_local(host)
3578
+ cur, depth = err, 0
3579
+ while cur is not None and depth < 6:
3580
+ name = type(cur).__name__
3581
+ text = str(cur or "").lower()
3582
+ if any(w in text for w in _OFFLINE_ALWAYS):
3583
+ return True
3584
+ if not local and (name in _OFFLINE_TYPES or any(w in text for w in _OFFLINE_REMOTE)):
3585
+ return True
3586
+ cur, depth = (getattr(cur, "__cause__", None) or getattr(cur, "__context__", None)), depth + 1
3587
+ return False
3588
+
3589
+
3590
+ def _host_locality_is_local(host: str) -> bool:
3591
+ """localhost, 127.x, 10.x, 172.16-31.x, 192.168.x, *.local, a bare name → True."""
3592
+ import re as _re
3593
+ h = str(host or "").strip().lower()
3594
+ h = _re.sub(r"^https?://", "", h).split("/")[0]
3595
+ h = _re.sub(r":\d+$", "", h) if not h.startswith("[") else h
3596
+ if not h:
3597
+ return False
3598
+ if h in ("localhost", "::1", "[::1]") or h.startswith("127."):
3599
+ return True
3600
+ m = _re.match(r"^(\d+)\.(\d+)\.\d+\.\d+$", h)
3601
+ if m:
3602
+ a, b = int(m.group(1)), int(m.group(2))
3603
+ return a == 10 or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168)
3604
+ return "." not in h or h.endswith((".local", ".lan", ".internal", ".home"))
3605
+
3606
+
3607
+ def _offline_wait_budget() -> float:
3608
+ """How long a turn waits for the network before giving up (GONEXT_OFFLINE_WAIT_S, default 10 min)."""
3609
+ try:
3610
+ v = float(os.environ.get("GONEXT_OFFLINE_WAIT_S", "") or 600)
3611
+ except ValueError:
3612
+ v = 600.0
3613
+ return max(0.0, v)
3614
+
3615
+
3616
+ def _offline_delay(n: int) -> float:
3617
+ """Seconds before offline retry #n: 2 → 5 → 10 → 15 (capped). Pure."""
3618
+ return (2.0, 5.0, 10.0)[n - 1] if 1 <= n <= 3 else 15.0
3619
+
3620
+
3542
3621
  def _is_backend_unavailable(err) -> bool:
3543
3622
  s = str(err or "").lower()
3544
3623
  # An overload (429) is its own class — never let it fall into the gateway bucket, whose
@@ -3737,6 +3816,12 @@ def _stream_fallback_decision(err, first_token_seen: bool) -> str:
3737
3816
  return "raise"
3738
3817
  if _is_request_timeout(err) or _is_backend_unavailable(err):
3739
3818
  return "raise"
3819
+ # THE NETWORK WENT (task #302, part 3). Found by the mid-turn offline test: "Connection
3820
+ # error." matched nothing above, so a dropped Wi-Fi re-sent the step UNSTREAMED and marked the
3821
+ # server as unable to stream for the rest of the process. A lost connection says nothing about
3822
+ # the request's shape — raise, and the retry loop waits for the network.
3823
+ if _is_offline(err):
3824
+ return "raise"
3740
3825
  return "fallback"
3741
3826
 
3742
3827
 
@@ -6875,24 +6960,115 @@ def _ws_kill_tree(pid: int, force: bool = False) -> None:
6875
6960
  # what is still in flight and exits. Servers/apps already left running are not in this set.
6876
6961
  _INFLIGHT_CMDS: set = set()
6877
6962
 
6963
+ # A STOP NEVER LANDS INSIDE A FILE WRITE (task #302). The handler used to os._exit at once, and a
6964
+ # workspace write was open(path, "w") — which EMPTIES the file first — then write(): a Ctrl+C in
6965
+ # between left the user's file empty or cut off. Writes are now atomic (_ws_write_file), and a
6966
+ # stop that arrives DURING one only sets `requested`; the writer exits the moment it is done.
6967
+ # Anywhere else — a model call, which is most of a turn — the stop is immediate, as before.
6968
+ _STOP = {"writing": 0, "requested": False}
6878
6969
 
6879
- def _on_sigterm(signum, frame): # noqa: ARG001
6970
+
6971
+ def _stop_now():
6880
6972
  for pid in list(_INFLIGHT_CMDS):
6881
6973
  _ws_kill_tree(pid, force=True)
6882
6974
  _INFLIGHT_CMDS.clear()
6883
6975
  try:
6884
- _log("stopped by the worker (SIGTERM) — killed the command in flight")
6976
+ _log("stopped by the worker — killed the command in flight")
6885
6977
  except Exception: # noqa: BLE001
6886
6978
  pass
6887
6979
  os._exit(143)
6888
6980
 
6889
6981
 
6982
+ def _request_stop():
6983
+ if _STOP["writing"] > 0:
6984
+ _STOP["requested"] = True
6985
+ return
6986
+ _stop_now()
6987
+
6988
+
6989
+ def _on_sigterm(signum, frame): # noqa: ARG001
6990
+ _request_stop()
6991
+
6992
+
6993
+ def _watch_stop_file(path: str):
6994
+ """WINDOWS HAS NO SIGTERM: the worker's kill there is TerminateProcess, which no handler sees.
6995
+ So the worker creates this file first and kills only after a grace period — and this thread
6996
+ turns the file into the same safe stop the POSIX signal gets."""
6997
+ import threading
6998
+ import time as _t
6999
+
7000
+ def _loop():
7001
+ while True:
7002
+ try:
7003
+ if os.path.exists(path):
7004
+ _request_stop()
7005
+ return
7006
+ except Exception: # noqa: BLE001
7007
+ pass
7008
+ _t.sleep(0.2)
7009
+
7010
+ threading.Thread(target=_loop, name="gu-stop-file", daemon=True).start()
7011
+
7012
+
6890
7013
  def _install_sigterm_handler():
6891
7014
  import signal
6892
7015
  try:
6893
7016
  signal.signal(signal.SIGTERM, _on_sigterm)
6894
7017
  except (ValueError, OSError): # not the main thread, or a platform without it
6895
7018
  pass
7019
+ stop_file = os.environ.get("GONEXT_STOP_FILE", "").strip()
7020
+ if stop_file:
7021
+ _watch_stop_file(stop_file)
7022
+
7023
+
7024
+ def _ws_write_file(path: str, content: str, action: str, backup) -> None:
7025
+ """Write a workspace file SAFELY TO STOP AT ANY MOMENT (task #302).
7026
+
7027
+ 1. The change goes on the undo list FIRST — a stop right after the write is still undoable.
7028
+ 2. The content goes to a temp file beside the target and is swapped in with os.replace, so
7029
+ the file is always wholly the old version or wholly the new one, even under a hard kill.
7030
+ 3. A stop requested meanwhile waits for the swap (see _STOP), then happens.
7031
+
7032
+ A symlink is written THROUGH (its target is replaced, the link stays), as open(path, "w")
7033
+ did. Windows can refuse the swap while another program holds the file; then it falls back to
7034
+ the direct write rather than failing the edit.
7035
+ """
7036
+ import tempfile
7037
+ _ws_record_change(action, path, backup)
7038
+ target = os.path.realpath(path)
7039
+ _STOP["writing"] += 1
7040
+ try:
7041
+ fd, tmp = tempfile.mkstemp(prefix=".gu-write-", suffix=".tmp", dir=os.path.dirname(target) or ".")
7042
+ try:
7043
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
7044
+ fh.write(content)
7045
+ fh.flush()
7046
+ os.fsync(fh.fileno())
7047
+ if os.path.exists(target):
7048
+ import shutil
7049
+ shutil.copymode(target, tmp)
7050
+ else:
7051
+ mask = os.umask(0)
7052
+ os.umask(mask)
7053
+ os.chmod(tmp, 0o666 & ~mask)
7054
+ try:
7055
+ os.replace(tmp, target)
7056
+ except PermissionError:
7057
+ if not _IS_WINDOWS:
7058
+ raise
7059
+ with open(target, "w", encoding="utf-8") as fh:
7060
+ fh.write(content)
7061
+ os.unlink(tmp)
7062
+ except BaseException:
7063
+ try:
7064
+ os.unlink(tmp)
7065
+ except OSError:
7066
+ pass
7067
+ raise
7068
+ finally:
7069
+ _STOP["writing"] -= 1
7070
+ if _STOP["writing"] == 0 and _STOP["requested"]:
7071
+ _stop_now()
6896
7072
 
6897
7073
 
6898
7074
  def _ws_pgroup_pids(pgid: int) -> list:
@@ -10328,10 +10504,15 @@ def run_agent_chat(cfg):
10328
10504
  # tries a normal error gets. The attempt count is decided per-error: we start
10329
10505
  # with the generic budget and extend it the moment we see a gateway error.
10330
10506
  attempt, max_attempts = 0, 3
10507
+ # Task #302 part 3: when the network went away. None = online.
10508
+ offline = {"since": None, "n": 0, "said": 0.0}
10331
10509
  while attempt < max_attempts:
10332
10510
  try:
10333
10511
  msg = self._generate_once(*args, **kwargs)
10334
10512
  last_err = None
10513
+ if offline["since"] is not None:
10514
+ _emit({"type": "step", "text": "Connection back — continuing"})
10515
+ _log(f"connection back after {time.monotonic() - offline['since']:.0f}s")
10335
10516
  break
10336
10517
  except Exception as e: # noqa: BLE001
10337
10518
  emsg = str(e)
@@ -10395,6 +10576,37 @@ def run_agent_chat(cfg):
10395
10576
  "names (for Ollama use the tag, e.g. qwen3:14b)."
10396
10577
  ) from e
10397
10578
  last_err = e
10579
+ # THE NETWORK WENT AWAY (task #302, part 3). Not an attempt: the same
10580
+ # step waits for the connection and is sent again — everything this turn
10581
+ # did so far is still in this process, so nothing is redone and nothing is
10582
+ # undone. Only a wait past the budget ends the turn, as FAILED (never as a
10583
+ # stop), with "continue" as the way on.
10584
+ if _is_offline(e, _host_of(coding_base_url)):
10585
+ _host = _host_of(coding_base_url)
10586
+ now = time.monotonic()
10587
+ if offline["since"] is None:
10588
+ offline.update(since=now, n=0, said=now)
10589
+ _emit({"type": "step", "text": (
10590
+ f"Lost the connection to {_host} — waiting for it to come back…")})
10591
+ waited = now - offline["since"]
10592
+ budget = _offline_wait_budget()
10593
+ if waited < budget:
10594
+ offline["n"] += 1
10595
+ if now - offline["said"] >= 60:
10596
+ offline["said"] = now
10597
+ _emit({"type": "step", "text": (
10598
+ f"Still waiting for the connection to {_host}… "
10599
+ f"({waited / 60:.0f} min)")})
10600
+ delay = min(_offline_delay(offline["n"]), max(1.0, budget - waited))
10601
+ _log(f"offline ({_clip(emsg, 120)}) — waited {waited:.0f}s, "
10602
+ f"retrying the same step in {delay:.0f}s")
10603
+ time.sleep(delay)
10604
+ continue
10605
+ raise _AgentOffline(
10606
+ f"Lost the connection to {_host} for {budget / 60:.0f} minutes, so "
10607
+ "the turn could not go on. The work so far is kept — nothing was "
10608
+ "undone. Type continue to pick up once you are back online."
10609
+ ) from e
10398
10610
  # An OVERLOAD (429/rate limit, task #114) is its own class: the
10399
10611
  # server answered and told us to slow down, so it needs a PATIENT,
10400
10612
  # jittered backoff rather than the gateway policy's prompt re-send.
@@ -11997,9 +12209,7 @@ def run_agent_chat(cfg):
11997
12209
  new_lines[-1] += "\n"
11998
12210
  old_lines = lines[s - 1:e]
11999
12211
  lines[s - 1:e] = new_lines
12000
- with open(rp, "w", encoding="utf-8") as fh:
12001
- fh.writelines(lines)
12002
- _ws_record_change("edit", rp, backup)
12212
+ _ws_write_file(rp, "".join(lines), "edit", backup)
12003
12213
  _emit({"type": "step", "text": _render_edit_card(
12004
12214
  "Update", rp,
12005
12215
  f"Replaced lines {s}-{e} with {len(new_lines)} line(s)",
@@ -12046,9 +12256,7 @@ def run_agent_chat(cfg):
12046
12256
  _last_obs["text"] = msg
12047
12257
  return msg
12048
12258
  backup = _ws_snapshot(rp)
12049
- with open(rp, "w", encoding="utf-8") as fh:
12050
- fh.write(text.replace(old_string, new_string, 1))
12051
- _ws_record_change("edit", rp, backup)
12259
+ _ws_write_file(rp, text.replace(old_string, new_string, 1), "edit", backup)
12052
12260
  start_no = text[:text.index(old_string)].count("\n") + 1
12053
12261
  old_ls = old_string.splitlines() or [""]
12054
12262
  new_ls = new_string.splitlines() or [""]
@@ -12114,9 +12322,7 @@ def run_agent_chat(cfg):
12114
12322
  return msg
12115
12323
  backup = _ws_snapshot(rp) if existed else ""
12116
12324
  os.makedirs(os.path.dirname(rp) or ".", exist_ok=True)
12117
- with open(rp, "w", encoding="utf-8") as fh:
12118
- fh.write(content)
12119
- _ws_record_change("edit" if existed else "create", rp, backup or None)
12325
+ _ws_write_file(rp, content, "edit" if existed else "create", backup or None)
12120
12326
  content_ls = content.splitlines() or [""]
12121
12327
  _emit({"type": "step", "text": _render_edit_card(
12122
12328
  "Update" if existed else "Create", rp,
package/gu-cli.mjs CHANGED
@@ -17,7 +17,8 @@ import { accent } from "./accent.mjs";
17
17
  requireSupportedNode();
18
18
  import { mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
19
19
  import { execFile as execFileCallback, spawn } from "node:child_process";
20
- import { createHash } from "node:crypto";
20
+ import { createHash, randomUUID } from "node:crypto";
21
+ import { writeFileSync, unlinkSync } from "node:fs";
21
22
  import { createTaskPool, createFolderQueue } from "./job-pools.mjs";
22
23
  import { NO_STEP, foldStepTokens, withStepTokens } from "./step-tokens.mjs";
23
24
  import { agentStoppedLine, jobEnded, jobStatusFrom409, stopGracefully } from "./agent-stop.mjs";
@@ -1743,6 +1744,39 @@ async function workerFetch(path, init = {}) {
1743
1744
  return fetch(url, { ...init, headers });
1744
1745
  }
1745
1746
 
1747
+ /**
1748
+ * workerFetch that WAITS OUT A LOST NETWORK (task #302, part 3) — for the writes that must land,
1749
+ * like a finished turn's result. A remote API unreachable is usually this machine being offline for
1750
+ * a moment; the work is done and only its report is waiting, so it is retried with backoff for the
1751
+ * offline budget (GONEXT_OFFLINE_WAIT_S, default 10 min) rather than lost. Only NETWORK errors are
1752
+ * retried — an HTTP answer, even an error, is returned as it is — and a local API is not waited for
1753
+ * (unreachable there means the API process is gone).
1754
+ */
1755
+ async function workerFetchPatient(path, init = {}, { sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
1756
+ let local = false;
1757
+ try {
1758
+ const h = new URL(apiBase).hostname;
1759
+ local = h === "localhost" || h === "::1" || h.startsWith("127.");
1760
+ } catch {
1761
+ /* no base — the plain call will say so */
1762
+ }
1763
+ const budgetMs = (() => {
1764
+ const v = Number(process.env.GONEXT_OFFLINE_WAIT_S);
1765
+ return (Number.isFinite(v) && v >= 0 ? v : 600) * 1000;
1766
+ })();
1767
+ const started = Date.now();
1768
+ for (let n = 1; ; n++) {
1769
+ try {
1770
+ return await workerFetch(path, init);
1771
+ } catch (err) {
1772
+ const network = err instanceof TypeError || /fetch failed|ECONN|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up/i.test(String(err?.message ?? err));
1773
+ if (local || !network || Date.now() - started >= budgetMs) throw err;
1774
+ if (n === 1) console.log(`[gu-worker] ${path}: the API is unreachable (${String(err?.message ?? err).slice(0, 80)}) — waiting for the network`);
1775
+ await sleep(Math.min(2000 * n, 15000));
1776
+ }
1777
+ }
1778
+ }
1779
+
1746
1780
  async function ensureWorkerOk(res, context) {
1747
1781
  if (res.ok) return;
1748
1782
  const snippet = (await res.text().catch(() => "")).trim().slice(0, 500);
@@ -1887,29 +1921,12 @@ if (args[0] === "workspace") {
1887
1921
  console.error(`[gu-worker] workspace revert: no manifest for run ${runId} (${manifestPath})`);
1888
1922
  process.exit(1);
1889
1923
  }
1890
- let restored = 0, deleted = 0, failed = 0;
1891
- for (const c of manifest?.changes ?? []) {
1892
- try {
1893
- if (c.action === "edit" && c.backup && ex(c.backup)) {
1894
- await copyFile(c.backup, c.path);
1895
- restored += 1;
1896
- console.log(` restored ${c.path}`);
1897
- } else if (c.action === "create" && ex(c.path)) {
1898
- await unlink(c.path);
1899
- deleted += 1;
1900
- console.log(` deleted ${c.path}`);
1901
- } else if (c.action === "create_dir" && ex(c.path)) {
1902
- // rmdir only removes an EMPTY dir — a revert must never destroy content
1903
- // the user (or a later run) put inside the created folder afterwards.
1904
- await rmdir(c.path);
1905
- deleted += 1;
1906
- console.log(` removed ${c.path}/`);
1907
- }
1908
- } catch (err) {
1909
- failed += 1;
1910
- console.error(` FAILED ${c.path}: ${err.message}`);
1911
- }
1912
- }
1924
+ // One step per path from its EARLIEST entry (workspace-revert.mjs, #302): replaying the log
1925
+ // in order deleted a file the run created and then brought it back from its "edit" backup.
1926
+ const { planRevert, applyRevert } = await import("./workspace-revert.mjs");
1927
+ const r = await applyRevert(planRevert(manifest?.changes ?? []), { exists: ex });
1928
+ for (const l of r.lines) (l.startsWith("FAILED") ? console.error : console.log)(` ${l}`);
1929
+ const { restored, deleted, failed } = r;
1913
1930
  console.log(
1914
1931
  `[gu-worker] workspace revert ${runId}: ${restored} restored, ${deleted} created-file(s) deleted` +
1915
1932
  (failed ? `, ${failed} FAILED` : "") +
@@ -2452,7 +2469,11 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
2452
2469
  // lone surrogates and only blows up later, encoding the model request. gonext_agent_chat.py
2453
2470
  // also reconfigures its own streams; this covers the scripts that don't.
2454
2471
  const spawnOpts = { stdio: ["pipe", "pipe", "pipe"] };
2455
- spawnOpts.env = { ...process.env, PYTHONUTF8: "1", ...(extraEnv ?? {}) };
2472
+ // Task #302: where to ask the agent to stop on Windows (see stopGracefully) — a file it
2473
+ // watches, so a stop waits for a file write in progress instead of cutting it in half.
2474
+ const stopFile = join(tmpdir(), `gu-agent-stop-${randomUUID()}`);
2475
+ spawnOpts.env = { ...process.env, PYTHONUTF8: "1", GONEXT_STOP_FILE: stopFile, ...(extraEnv ?? {}) };
2476
+ const requestStop = () => writeFileSync(stopFile, "stop");
2456
2477
  const child = spawn(cmd, cmdArgs, spawnOpts);
2457
2478
  let stderr = "";
2458
2479
  let lineBuffer = "";
@@ -2470,7 +2491,7 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
2470
2491
  if (typeof onTimeout !== "function") {
2471
2492
  timedOut = true;
2472
2493
  timerLive = false;
2473
- cancelHardKill = stopGracefully(child);
2494
+ cancelHardKill = stopGracefully(child, { requestStop });
2474
2495
  return;
2475
2496
  }
2476
2497
  let extend = false;
@@ -2486,7 +2507,7 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
2486
2507
  }
2487
2508
  timedOut = true;
2488
2509
  timerLive = false;
2489
- cancelHardKill = stopGracefully(child);
2510
+ cancelHardKill = stopGracefully(child, { requestStop });
2490
2511
  };
2491
2512
  const timer = setTimeout(fireTimeout, timeoutMs);
2492
2513
  // User-requested cancel (gu Ctrl+C): kill the Python agent so it stops burning
@@ -2497,7 +2518,7 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
2497
2518
  const onAbort = () => {
2498
2519
  if (aborted) return;
2499
2520
  aborted = true;
2500
- cancelHardKill = stopGracefully(child);
2521
+ cancelHardKill = stopGracefully(child, { requestStop });
2501
2522
  };
2502
2523
  if (abortSignal) {
2503
2524
  if (abortSignal.aborted) onAbort();
@@ -2532,6 +2553,11 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
2532
2553
  timerLive = false;
2533
2554
  clearTimeout(timer);
2534
2555
  cancelHardKill();
2556
+ try {
2557
+ unlinkSync(stopFile);
2558
+ } catch {
2559
+ /* never asked to stop — no file */
2560
+ }
2535
2561
  if (abortSignal) abortSignal.removeEventListener?.("abort", onAbort);
2536
2562
  // Drain any remaining buffered line
2537
2563
  const remaining = lineBuffer.trim();
@@ -4257,7 +4283,8 @@ async function runAgentChatJob(job) {
4257
4283
  await flushChunks();
4258
4284
 
4259
4285
  const totalTimeSeconds = (Date.now() - start) / 1000;
4260
- const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
4286
+ // Patient (#302 part 3): the turn is DONE; a network blip now must not throw its result away.
4287
+ const doneRes = await workerFetchPatient(`/api/worker/jobs/${jobId}`, {
4261
4288
  method: "PATCH",
4262
4289
  body: JSON.stringify({
4263
4290
  jobStatus: "completed",
package/gu-repl.mjs CHANGED
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
27
  import { readStepTokens } from "./step-tokens.mjs";
28
+ import { snapshotInput, restoreInput, replaceStoppedQuestion, stopNoteLines } from "./turn-stop.mjs";
28
29
  import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
29
30
  import { spawn } from "node:child_process";
30
31
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
@@ -867,6 +868,7 @@ const COMMANDS = [
867
868
  // they worked, Tab-completed to nothing, and appeared in no help. Aliases is what the registry
868
869
  // already has for exactly this — /quit, /rag-local.
869
870
  { name: "/host-mode", desc: "undo client mode — run agent turns on THIS machine again" },
871
+ { name: "/undo", usage: "/undo [runId]", desc: "put back the files the last stopped turn changed (from its backups) — asks first" },
870
872
  { name: "/server", desc: "pick a deployment server (host/user) for this session" },
871
873
  { name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
872
874
  { name: "/s3", usage: "/s3 [test]", desc: "set up YOUR S3 bucket (RAG index, and the PDFs/downloads gu makes) and check the AWS keys work — without it, files are saved on this computer" },
@@ -1505,6 +1507,14 @@ const stripAnsiEscapes = (s) =>
1505
1507
  const PASTE_MIN_CHARS = 200;
1506
1508
  const PASTE_MIN_LINES = 3;
1507
1509
  const pasteStore = new Map(); // token -> the real text
1510
+ // Task #302: the last line as TYPED — placeholders and all — taken before it is expanded for the
1511
+ // model, so a turn stopped with Ctrl+C can give the question back for editing.
1512
+ let lastSubmittedInput = null;
1513
+ // Task #302: set while the input holds a stopped turn's question; the next line sent from it
1514
+ // REPLACES that question in history when it was edited. Cleared by "never mind" (Ctrl+C).
1515
+ let prefillFor = null;
1516
+ // Task #302: the backup run of the last stopped turn — what /undo reverts by default.
1517
+ let lastStoppedRun = null;
1508
1518
  // ---------- pasted images (task #284) ----------
1509
1519
  const imageStore = createImageStore();
1510
1520
  // Shown on the bottom row after a refused or failed image paste; clears itself.
@@ -1619,6 +1629,23 @@ const isNetworkError = (e) =>
1619
1629
  /fetch failed|network|ECONN|ETIMEDOUT|EAI_AGAIN|socket hang up|und_err|terminated/i.test(
1620
1630
  String(e?.message ?? e)
1621
1631
  );
1632
+ /** Is the API on this machine? Then "unreachable" means the API process, not the network (#302). */
1633
+ const apiIsLocal = () => {
1634
+ try {
1635
+ const h = new URL(apiBase).hostname;
1636
+ return h === "localhost" || h === "::1" || h === "[::1]" || h.startsWith("127.");
1637
+ } catch {
1638
+ return false;
1639
+ }
1640
+ };
1641
+ /** How long a turn waits for the network (GONEXT_OFFLINE_WAIT_S, default 10 min) — the agent's own budget. */
1642
+ const offlineWaitMs = () => {
1643
+ const v = Number(process.env.GONEXT_OFFLINE_WAIT_S);
1644
+ return (Number.isFinite(v) && v >= 0 ? v : 600) * 1000;
1645
+ };
1646
+ // #302 part 3: a turn this terminal lost contact with (the network dropped) — its job may still be
1647
+ // running, so "continue" follows it again instead of asking the question a second time.
1648
+ let lostTurn = null;
1622
1649
  /**
1623
1650
  * Did OUR OWN deadline fire, rather than the connection failing? → boolean.
1624
1651
  *
@@ -1731,6 +1758,10 @@ rl.on("line", (l) => {
1731
1758
  // user pasted.
1732
1759
  // Images too (task #284): "[Image #1]" goes out as "[Image #1: <file>]" for the agent to
1733
1760
  // attach; the next message numbers its images from #1 again.
1761
+ lastSubmittedInput = snapshotInput(stripAnsiEscapes(submitted), {
1762
+ pastes: pasteStore,
1763
+ images: new Map(imageStore.entries()),
1764
+ });
1734
1765
  const clean = imageStore.expand(expandPasteTokens(stripAnsiEscapes(submitted)));
1735
1766
  imageStore.clear();
1736
1767
  const waiter = _lineWaiters.pop(); // innermost reader first — see _lineWaiters
@@ -6646,6 +6677,103 @@ async function runSavePresetCommand(line) {
6646
6677
  * key is saved under the preset's name and made that row's default, and the embedder is set. The
6647
6678
  * user reconfirms before anything changes, like /load-settings.
6648
6679
  */
6680
+ /**
6681
+ * After Ctrl+C stopped THIS terminal's turn (task #302): say what it left behind, and give the
6682
+ * question back.
6683
+ *
6684
+ * · the command it was in the middle of, if any — that one cannot be made safe, only named
6685
+ * · the files it changed (its backup run's manifest) — kept as they are; /undo offers to put
6686
+ * them back, because part of the work may be wanted and nothing is reverted by itself
6687
+ * · the question, exactly as typed (placeholders and all), back in the input — edit it and
6688
+ * press Enter; Ctrl+C clears it. Never sent by itself.
6689
+ *
6690
+ * What the turn printed stays on screen: it is the record of what it did.
6691
+ */
6692
+ async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand = "", typed = "" }) {
6693
+ let files = [];
6694
+ try {
6695
+ const ws = await import("./workspace-revert.mjs");
6696
+ const runId = ws.runSince(turnStartMs);
6697
+ if (runId) {
6698
+ const changes = (await ws.readRunChanges(runId)) ?? [];
6699
+ files = changes.filter((c) => c?.action !== "create_dir").map((c) => c.path);
6700
+ if (files.length) lastStoppedRun = runId;
6701
+ }
6702
+ } catch {
6703
+ /* no backups readable — nothing to name */
6704
+ }
6705
+ // A bare "continue" was the typed line: giving THAT back is no help, so only the note.
6706
+ const canRestore = Boolean(submittedInput?.raw) && !isResumeWord(typed);
6707
+ for (const l of stopNoteLines({ files, command: stoppedCommand, restored: canRestore })) {
6708
+ console.log(GUTTER + dim(l));
6709
+ }
6710
+ console.log("");
6711
+ if (!canRestore) return;
6712
+ const text = restoreInput(submittedInput, { pasteStore, imageStore });
6713
+ rl.line = text;
6714
+ rl.cursor = text.length;
6715
+ rl.historyIndex = -1;
6716
+ prefillFor = { task: taskLine };
6717
+ if (barPinned()) paintBar();
6718
+ }
6719
+
6720
+ /**
6721
+ * /undo — put back what a stopped turn changed (task #302): the last stopped turn's run, or the
6722
+ * run named. Shows exactly what it will do and asks first — a file edited by hand since the turn
6723
+ * would lose those edits, and that is said too.
6724
+ */
6725
+ async function runUndoCommand(line) {
6726
+ const ws = await import("./workspace-revert.mjs");
6727
+ const named = line.trim().slice("/undo".length).trim();
6728
+ const runId = named || lastStoppedRun;
6729
+ console.log("");
6730
+ if (!runId) {
6731
+ console.log(GUTTER + dim("nothing to undo — /undo puts back what a turn you stopped with Ctrl+C changed."));
6732
+ console.log(GUTTER + dim(`(older runs: ${ws.BACKUPS_ROOT} — /undo <run>)\n`));
6733
+ return;
6734
+ }
6735
+ const changes = await ws.readRunChanges(runId);
6736
+ if (!changes) {
6737
+ console.log(GUTTER + red(`no backups for run ${runId}.\n`));
6738
+ return;
6739
+ }
6740
+ const steps = ws.planRevert(changes);
6741
+ if (!steps.length) {
6742
+ console.log(GUTTER + dim("that turn changed no files.\n"));
6743
+ return;
6744
+ }
6745
+ let manifestAt = 0;
6746
+ try {
6747
+ manifestAt = statSync(join(ws.BACKUPS_ROOT, runId, "manifest.json")).mtimeMs;
6748
+ } catch {
6749
+ /* unknown — no "edited since" warnings */
6750
+ }
6751
+ const verb = { restore: "restore", delete: "delete ", rmdir: "remove " };
6752
+ for (const st of steps) {
6753
+ let since = "";
6754
+ try {
6755
+ if (manifestAt && st.op !== "rmdir" && statSync(st.path).mtimeMs > manifestAt + 1000) {
6756
+ since = yellow(" (changed since — those edits would be lost)");
6757
+ }
6758
+ } catch {
6759
+ /* gone already */
6760
+ }
6761
+ console.log(GUTTER + `${verb[st.op]} ${st.path}` + since);
6762
+ }
6763
+ if (!(await askYesNo("Put these back?", false))) {
6764
+ console.log(GUTTER + dim("(nothing changed)\n"));
6765
+ return;
6766
+ }
6767
+ const r = await ws.applyRevert(steps);
6768
+ for (const l of r.lines) console.log(GUTTER + dim(l));
6769
+ console.log(
6770
+ GUTTER + (r.failed ? yellow : dim)(
6771
+ `${r.restored} restored, ${r.deleted} removed${r.failed ? `, ${r.failed} failed` : ""} — the backups are kept.`
6772
+ ) + "\n"
6773
+ );
6774
+ if (runId === lastStoppedRun) lastStoppedRun = null;
6775
+ }
6776
+
6649
6777
  async function runLoadPresetsCommand(line) {
6650
6778
  const pr = await import("./presets-transfer.mjs");
6651
6779
  const home = homedir();
@@ -9551,24 +9679,29 @@ async function ensureTokenForTurn() {
9551
9679
  return false;
9552
9680
  }
9553
9681
 
9554
- async function runAgentTurn(history, { attachJobId = "" } = {}) {
9682
+ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {}) {
9555
9683
  // #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
9556
9684
  // startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
9557
9685
  if (licenceBlock) {
9558
9686
  console.log(GUTTER + orange("Not sent.") + dim(" " + licenceBlock) + "\n");
9559
9687
  return;
9560
9688
  }
9561
- const attached = Boolean(attachJobId);
9689
+ // TWO THINGS "attaching" used to mean at once (task #302, part 3): the question is NOT sent
9690
+ // again (skipAsk), and the job is SOMEONE ELSE'S (attached — Ctrl+C only stops watching, its
9691
+ // tokens are theirs to count). Re-attaching to this terminal's OWN turn after a lost connection
9692
+ // is the first without the second: Ctrl+C there is a real cancel.
9693
+ const skipAsk = Boolean(attachJobId);
9694
+ const attached = skipAsk && !ownJob;
9562
9695
  // PRIVATE MODE NEEDS THE gu TOKEN (task #246): the local API fetches the agent's instructions
9563
9696
  // from the gu service with it. Asked for HERE, before anything is sent, with the steps — rather
9564
9697
  // than letting the turn fail and leaving the user to decode why.
9565
- if (!attached && !(await ensureTokenForTurn())) return;
9698
+ if (!skipAsk && !(await ensureTokenForTurn())) return;
9566
9699
  inputVisible = false; // the frozen zone shows an empty prompt for the duration (#141)
9567
9700
  // Something on screen from the FIRST moment (task #140): Enter has just wiped the bar, and
9568
9701
  // the next thing to draw itself is the turn's ticker — which stays silent until the daemon
9569
9702
  // claims the job, seconds away. This names what is actually happening meanwhile.
9570
- startPendingStatus(attached ? "attaching" : "sending");
9571
- const res = attached ? null : await fetch(`${apiBase}/api/worker/agent-ask`, {
9703
+ startPendingStatus(skipAsk ? "attaching" : "sending");
9704
+ const res = skipAsk ? null : await fetch(`${apiBase}/api/worker/agent-ask`, {
9572
9705
  method: "POST",
9573
9706
  headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
9574
9707
  // cwd = this terminal's folder → agent puts download/unzip output here (when it's
@@ -9628,7 +9761,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
9628
9761
  }
9629
9762
  throw new Error(data?.error || `agent-ask failed (HTTP ${res.status})`);
9630
9763
  }
9631
- const jobId = attached ? attachJobId : data?.jobId;
9764
+ const jobId = skipAsk ? attachJobId : data?.jobId;
9632
9765
  if (!jobId) throw new Error("agent-ask returned no jobId.");
9633
9766
 
9634
9767
  // THE CODER WAS DROPPED, AND NOTHING SAID SO (task #222 phase 0). When the picked coding
@@ -9640,7 +9773,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
9640
9773
  // Read off codingKeyMissingKnown, never off `data`: the flag lives under `payload` and this
9641
9774
  // response is a different shape, so reaching for it here would be undefined — falsy — and
9642
9775
  // warn nobody, which is the bug agent-payload-shape.test.mjs exists to catch.
9643
- if (!attached && codingKeyMissingKnown === true) {
9776
+ if (!skipAsk && codingKeyMissingKnown === true) {
9644
9777
  console.log(
9645
9778
  GUTTER + orange("⚠ no API key for the coding model") +
9646
9779
  dim(" — coding is running on your chat model. ctrl+k to set it.\n")
@@ -9652,7 +9785,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
9652
9785
  // hand-over to the turn's own ticker happens the moment that worker claims it. An ATTACHED
9653
9786
  // job is usually already running, so it says what is true of it instead — the poll below
9654
9787
  // corrects either of them within a second.
9655
- setPendingStatus(attached ? "attaching — reading its progress" : "queued — waiting for the worker");
9788
+ setPendingStatus(skipAsk ? "attaching — reading its progress" : "queued — waiting for the worker");
9656
9789
  following = true;
9657
9790
  followingAttached = attached;
9658
9791
  typedDuringTurn = false;
@@ -9694,6 +9827,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
9694
9827
  // key lookup failed." — a cold Mongo connection, NOT a bad key) must NOT kill a
9695
9828
  // long-running turn. Tolerate a run of them; only give up after too many in a row.
9696
9829
  let pollFailures = 0;
9830
+ let pollOfflineSince = 0; // #302 part 3: when a remote API stopped answering (0 = reachable)
9697
9831
  const MAX_POLL_FAILURES = 6; // ~6 tries with backoff before conceding the follow
9698
9832
  let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
9699
9833
  // Sticky: the agent RAN, so the coding model was called ("thinking…" was on screen). A
@@ -10534,6 +10668,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
10534
10668
  if (jr.ok) {
10535
10669
  job = parsed;
10536
10670
  pollFailures = 0; // a clean poll resets the tolerance window
10671
+ pollOfflineSince = 0;
10537
10672
  } else if (jr.status >= 400 && jr.status < 500) {
10538
10673
  // Client error (bad/expired key, job genuinely gone) — NOT transient, fail fast.
10539
10674
  throw new Error(parsed?.error || `job poll failed (HTTP ${jr.status})`);
@@ -10549,11 +10684,33 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
10549
10684
  const transient = err?.transient === true || isNetworkError(err);
10550
10685
  if (!transient) throw err;
10551
10686
  pollFailures += 1;
10552
- if (pollFailures >= MAX_POLL_FAILURES) {
10687
+ // THE NETWORK, NOT THE API (task #302, part 3). A REMOTE API that cannot be reached is
10688
+ // usually this machine being offline — a Wi-Fi drop, a VPN reconnecting — while the turn
10689
+ // itself carries on on its worker. So a network error is waited out for the same budget
10690
+ // the agent gives its own calls, instead of giving up after ~13s. A LOCAL API unreachable
10691
+ // is the API process itself gone (see "Agent killed its own API"), which waiting will not
10692
+ // fix — that keeps the quick give-up.
10693
+ const offlineWait = isNetworkError(err) && !apiIsLocal() ? offlineWaitMs() : 0;
10694
+ if (offlineWait) {
10695
+ if (!pollOfflineSince) pollOfflineSince = Date.now();
10696
+ const waited = Date.now() - pollOfflineSince;
10697
+ if (waited < offlineWait) {
10698
+ setPendingStatus(`reconnecting to the API… (${Math.round(waited / 1000)}s)`);
10699
+ await sleep(Math.min(1000 * pollFailures, 10000)); // backoff, capped at 10s
10700
+ continue;
10701
+ }
10702
+ }
10703
+ if (offlineWait || pollFailures >= MAX_POLL_FAILURES) {
10553
10704
  clearStatus();
10554
- throw new Error(
10555
- `lost contact with the API after ${pollFailures} tries (${String(err.message).slice(0, 80)}). ` +
10556
- "The turn may still be running on the worker — try 'continue'."
10705
+ // The job id travels with the error: the turn may well still be running on its worker,
10706
+ // and "continue" should FOLLOW it again rather than ask the same question twice.
10707
+ throw Object.assign(
10708
+ new Error(
10709
+ `lost contact with the API after ${offlineWait ? `${Math.round(offlineWait / 60000)} min` : `${pollFailures} tries`} ` +
10710
+ `(${String(err.message).slice(0, 80)}). The turn may still be running on the worker — ` +
10711
+ "type continue to follow it again."
10712
+ ),
10713
+ { lostJobId: jobId }
10557
10714
  );
10558
10715
  }
10559
10716
  await sleep(Math.min(700 * pollFailures, 3000)); // linear backoff, capped at 3s
@@ -10784,7 +10941,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
10784
10941
  // turn (main() drops it from history).
10785
10942
  clearStatus();
10786
10943
  process.stdout.write("\n" + GUTTER + dim("(cancelled — the agent was stopped)") + "\n");
10787
- return { text: "", shown: false, cancelled: true, started: reachedModel };
10944
+ return { text: "", shown: false, cancelled: true, started: reachedModel, stoppedCommand: runningCmd || "" };
10788
10945
  }
10789
10946
  if (job.jobStatus === "failed") {
10790
10947
  clearStatus();
@@ -11637,6 +11794,7 @@ async function main() {
11637
11794
  rl.line = "";
11638
11795
  rl.cursor = 0;
11639
11796
  rl.historyIndex = -1;
11797
+ prefillFor = null; // "never mind" also lets go of a restored question (#302)
11640
11798
  if (_lineQueue.length > 0) _lineQueue.length = 0;
11641
11799
  prunePasteStore("");
11642
11800
  imageStore.clear();
@@ -11768,6 +11926,12 @@ async function main() {
11768
11926
  footerActive = false; // a line arrived from the queue (no Enter keypress) → disarm
11769
11927
  if (lineRaw === null) break; // stdin closed
11770
11928
  const line = lineRaw.trim();
11929
+ // #302: what was typed for THIS line (before expansion), and whether it was sent from a
11930
+ // stopped turn's restored question.
11931
+ const submittedInput = lastSubmittedInput;
11932
+ lastSubmittedInput = null;
11933
+ const editedFrom = prefillFor?.task ?? "";
11934
+ prefillFor = null;
11771
11935
  // readline's echo is suppressed while the input line is frozen, so the submitted question
11772
11936
  // is printed here instead — it belongs in the scrollback like every other line. Its marker
11773
11937
  // is the GREY one: the cyan ">>" belongs to the live input line alone.
@@ -11902,6 +12066,10 @@ async function main() {
11902
12066
  await runS3Command(line);
11903
12067
  continue;
11904
12068
  }
12069
+ if (line === "/undo" || line.startsWith("/undo ")) {
12070
+ await runUndoCommand(line);
12071
+ continue;
12072
+ }
11905
12073
  if (line === "/save-preset" || line.startsWith("/save-preset ")) {
11906
12074
  await runSavePresetCommand(line);
11907
12075
  continue;
@@ -12154,6 +12322,10 @@ async function main() {
12154
12322
  // mid-retry still has something to resume next time.
12155
12323
  sessionUnfinishedTask = "";
12156
12324
  const { task: taskLine, resumed, nothingToResume } = resumeTask(line, pendingResume);
12325
+ // #302 part 3: "continue" after a lost connection FOLLOWS the job that may still be running,
12326
+ // rather than sending the question again. Anything else typed lets it go.
12327
+ const reattach = lostTurn && isResumeWord(line) && lostTurn.taskLine === taskLine ? lostTurn : null;
12328
+ lostTurn = null;
12157
12329
  // "continue" with nothing behind it runs NOTHING (task #201). It used to be sent to the
12158
12330
  // model as the literal word, which then got remembered as the unfinished task — so every
12159
12331
  // later "continue" retried "continue". Worse than the loop: with no task the model invents
@@ -12172,6 +12344,9 @@ async function main() {
12172
12344
  // "where were we?" reached the model with no history and it said nothing had been asked.
12173
12345
  // Only the failure NOTICE stays out of history (task #144). A resume, or the same question
12174
12346
  // typed again after a failure, is already the last entry and is not pushed a second time.
12347
+ // #302: the restored question, EDITED and sent — a change of mind, so it takes the stopped
12348
+ // one's place instead of standing beside it (then the block below sees it as pending).
12349
+ if (editedFrom) replaceStoppedQuestion(history, editedFrom, taskLine);
12175
12350
  if (isPendingQuestion(history, taskLine)) {
12176
12351
  delete history[history.length - 1].status; // it is the current task again, not a stopped one
12177
12352
  } else {
@@ -12179,7 +12354,9 @@ async function main() {
12179
12354
  }
12180
12355
  await saveSession(cwd, history, { unfinishedTask: isResumeWord(taskLine) ? "" : taskLine });
12181
12356
  try {
12182
- const turn = await runAgentTurn(history);
12357
+ const turnStartMs = Date.now();
12358
+ if (reattach) console.log(GUTTER + dim("following the turn that was running when the connection went…\n"));
12359
+ const turn = await runAgentTurn(history, reattach ? { attachJobId: reattach.jobId, ownJob: true } : {});
12183
12360
  // NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
12184
12361
  // Nothing began, and "continue" can send it once the cause is fixed (task #247).
12185
12362
  if (!turn) {
@@ -12188,7 +12365,7 @@ async function main() {
12188
12365
  continue;
12189
12366
  }
12190
12367
  const { text: answer, shown, cancelled, started, detached, inputTokens, outputTokens,
12191
- turnOutputTokens, elapsedMs } = turn;
12368
+ turnOutputTokens, elapsedMs, stoppedCommand } = turn;
12192
12369
  // Detached: the agent keeps running on the worker, so the question is neither answered
12193
12370
  // nor stopped — leave it untagged, and say nothing contradicting "the job keeps running".
12194
12371
  if (detached) continue;
@@ -12285,6 +12462,7 @@ async function main() {
12285
12462
  // Stopped AFTER the model was called = too slow or stuck, never a change of mind: the
12286
12463
  // request still stands (task #247, the user's rule). Before that, nothing had begun.
12287
12464
  await markInterrupted(cwd, history, taskLine, started ? "stopped" : "cancelled");
12465
+ await afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand, typed: line });
12288
12466
  } else {
12289
12467
  await markInterrupted(cwd, history, taskLine, "failed");
12290
12468
  // The turn ended cleanly but empty — no exception to summarize, so say exactly that.
@@ -12297,6 +12475,8 @@ async function main() {
12297
12475
  // turn that just threw auto-fire the next one.
12298
12476
  if (_lineQueue.length > 0) _lineQueue.length = 0;
12299
12477
  await rememberUnfinishedTask(cwd, taskLine); // …and "continue" can retry it (task #150)
12478
+ // #302 part 3: the connection went, not the turn — keep its job so "continue" follows it.
12479
+ lostTurn = err?.lostJobId ? { jobId: err.lostJobId, taskLine } : null;
12300
12480
  printTurnError(err?.message, {
12301
12481
  resumable: true,
12302
12482
  failures: noteTaskFailure(taskLine, classifyTurnFailure(err?.message)),
package/image-paste.mjs CHANGED
@@ -248,6 +248,16 @@ export function createImageStore() {
248
248
  const present = new Set(String(line ?? "").match(TOKEN_RE) ?? []);
249
249
  for (const t of [...store.keys()]) if (!present.has(t)) store.delete(t);
250
250
  },
251
+ /** The live entries — snapshotted when a line is sent, so a stopped turn can give it back (#302). */
252
+ entries() {
253
+ return [...store];
254
+ },
255
+ /** Put one back exactly as it was (#302): same token, and later images number after it. */
256
+ restore(token, path) {
257
+ store.set(token, path);
258
+ const n = Number(/#(\d+)/.exec(String(token))?.[1]);
259
+ if (Number.isFinite(n) && n > seq) seq = n;
260
+ },
251
261
  /** After a message is sent: the next one numbers its images from #1 again. */
252
262
  clear() {
253
263
  store.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.769",
3
+ "version": "1.0.770",
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",
@@ -52,6 +52,8 @@
52
52
  "step-tokens.mjs",
53
53
  "model-setup-ui.mjs",
54
54
  "setup-prompts.mjs",
55
+ "turn-stop.mjs",
56
+ "workspace-revert.mjs",
55
57
  "terminal-hover.mjs",
56
58
  "terminal-layout.mjs",
57
59
  "terminal-viewport.mjs",
package/turn-stop.mjs ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * AFTER Ctrl+C STOPS A TURN — the question goes back in the input, and what the turn changed is
3
+ * named (task #302). Pure helpers; gu-repl.mjs does the drawing.
4
+ *
5
+ * Asked for: "for a couple of seconds the thinking is still in process and the user thinks again
6
+ * and wants to change the question" — as Claude Code does. The screen keeps what the turn printed
7
+ * (the record of what it did); files are NOT reverted by themselves (part of the work may be
8
+ * wanted, and a command that ran cannot be undone) — /undo offers that as a choice.
9
+ */
10
+
11
+ const PASTE_TOKEN_RE = /\[Pasted text #\d+(?: \+\d+ lines)?\]/g;
12
+ const IMAGE_TOKEN_RE = /\[Image #\d+\]/g;
13
+
14
+ /**
15
+ * What was TYPED, captured at the moment the line is submitted — before the pasted blocks and the
16
+ * images are expanded for the model and their stores are cleared. Only the entries whose
17
+ * placeholder is actually in the line are kept.
18
+ */
19
+ export function snapshotInput(raw, { pastes = new Map(), images = new Map() } = {}) {
20
+ const text = String(raw ?? "");
21
+ const keep = (re, store) => {
22
+ const present = new Set(text.match(re) ?? []);
23
+ return [...store].filter(([token]) => present.has(token));
24
+ };
25
+ return { raw: text, pastes: keep(PASTE_TOKEN_RE, pastes), images: keep(IMAGE_TOKEN_RE, images) };
26
+ }
27
+
28
+ /**
29
+ * Put a snapshot back: its placeholders' payloads return to the stores, and the typed text is
30
+ * returned for the input line. Nothing is renumbered — the text is shown exactly as it was.
31
+ */
32
+ export function restoreInput(snap, { pasteStore, imageStore } = {}) {
33
+ if (!snap) return "";
34
+ for (const [token, text] of snap.pastes ?? []) pasteStore?.set(token, text);
35
+ for (const [token, path] of snap.images ?? []) imageStore?.restore?.(token, path);
36
+ return String(snap.raw ?? "");
37
+ }
38
+
39
+ /**
40
+ * The restored question was EDITED and sent: that is a change of mind, so the abandoned version
41
+ * must not stay in history beside the new one (the model would take both as asks). Only when the
42
+ * last entry IS that stopped question; returns whether it replaced it.
43
+ *
44
+ * Sending it UNCHANGED is not handled here — the existing pending-question path re-runs it. And a
45
+ * question cleared and replaced by something else keeps its "still stands" tag (task #247).
46
+ */
47
+ export function replaceStoppedQuestion(history, oldTask, newTask) {
48
+ const last = Array.isArray(history) ? history[history.length - 1] : null;
49
+ const was = String(oldTask ?? "").trim();
50
+ const now = String(newTask ?? "").trim();
51
+ if (!last || last.role !== "user" || !last.status || !was || !now || was === now) return false;
52
+ if (String(last.content ?? "").trim() !== was) return false;
53
+ last.content = now;
54
+ delete last.status;
55
+ return true;
56
+ }
57
+
58
+ /** Short relative names for the stop note: the last two path parts. */
59
+ function shortPath(p) {
60
+ const parts = String(p ?? "").split(/[\\/]/).filter(Boolean);
61
+ return parts.slice(-2).join("/") || String(p ?? "");
62
+ }
63
+
64
+ /**
65
+ * The lines under a stopped turn. `files` = the paths the turn changed (from its run's manifest),
66
+ * `command` = the command it was in the middle of, if any.
67
+ */
68
+ export function stopNoteLines({ files = [], command = "", restored = true } = {}) {
69
+ const out = [];
70
+ if (command) out.push(`stopped while running \`${command}\` — it may be incomplete; run it again if you need it`);
71
+ const uniq = [...new Set(files)];
72
+ if (uniq.length) {
73
+ const shown = uniq.slice(0, 3).map(shortPath).join(", ") + (uniq.length > 3 ? ` +${uniq.length - 3} more` : "");
74
+ out.push(`this turn changed ${uniq.length} file${uniq.length === 1 ? "" : "s"}: ${shown} — /undo puts ${uniq.length === 1 ? "it" : "them"} back`);
75
+ }
76
+ if (restored) out.push("your question is back in the input — edit it and press Enter, or Ctrl+C to clear it");
77
+ return out;
78
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * UNDO AN AGENT RUN'S FILE CHANGES — shared by `gu-cli workspace revert` and the terminal's /undo
3
+ * (task #302).
4
+ *
5
+ * Every agent run keeps ~/.gonext/workspace-backups/<runId>/: a copy of each file taken before its
6
+ * FIRST edit in that run, and manifest.json listing every change (gonext_agent_chat.py
7
+ * _ws_snapshot / _ws_record_change). Undo puts the ORIGINAL state back.
8
+ *
9
+ * WHY A PLAN, NOT A REPLAY. The manifest is a log, and a file can appear in it more than once. The
10
+ * old revert replayed it in order — so a file the run CREATED and then edited was deleted (the
11
+ * "create" entry) and then brought back by the "edit" entry's backup, which is a copy of the file
12
+ * the run had just created. What matters is each path's EARLIEST entry: it says what the file was
13
+ * before the run touched it. Created → delete it; edited → restore the copy taken before the first
14
+ * edit; a created folder → remove it only if empty.
15
+ */
16
+ import { existsSync, readdirSync, statSync } from "node:fs";
17
+ import { copyFile, readFile, rmdir, unlink } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ export const BACKUPS_ROOT = join(homedir(), ".gonext", "workspace-backups");
22
+
23
+ /** The manifest's changes → one step per path, from its earliest entry. Pure. */
24
+ export function planRevert(changes) {
25
+ const first = new Map();
26
+ for (const c of Array.isArray(changes) ? changes : []) {
27
+ if (!c || typeof c.path !== "string" || !c.path) continue;
28
+ if (!first.has(c.path)) first.set(c.path, c);
29
+ }
30
+ const steps = [];
31
+ for (const c of first.values()) {
32
+ if (c.action === "edit" && c.backup) steps.push({ op: "restore", path: c.path, backup: c.backup });
33
+ else if (c.action === "create") steps.push({ op: "delete", path: c.path });
34
+ else if (c.action === "create_dir") steps.push({ op: "rmdir", path: c.path });
35
+ }
36
+ // Files before folders, deepest folders first — a created folder can only go once it is empty.
37
+ const rank = { restore: 0, delete: 0, rmdir: 1 };
38
+ return steps.sort((a, b) => rank[a.op] - rank[b.op] || (a.op === "rmdir" ? b.path.length - a.path.length : 0));
39
+ }
40
+
41
+ /** The run directories, oldest first (names are timestamps, so they sort). */
42
+ export function listRuns(root = BACKUPS_ROOT) {
43
+ try {
44
+ return readdirSync(root).filter((d) => !d.startsWith(".")).sort();
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ /**
51
+ * The run a turn produced: the newest run whose manifest was written at or after `sinceMs`.
52
+ * Every run is one agent process, and every turn is one process — so a run touched after the turn
53
+ * began belongs to it. null when the turn changed nothing (or ran on another machine).
54
+ */
55
+ export function runSince(sinceMs, root = BACKUPS_ROOT) {
56
+ const runs = listRuns(root).reverse();
57
+ for (const id of runs) {
58
+ try {
59
+ if (statSync(join(root, id, "manifest.json")).mtimeMs >= sinceMs - 1000) return id;
60
+ } catch {
61
+ /* a run with no manifest changed nothing */
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /** A run's changes, or null when it has no readable manifest. */
68
+ export async function readRunChanges(runId, root = BACKUPS_ROOT) {
69
+ try {
70
+ const m = JSON.parse(await readFile(join(root, runId, "manifest.json"), "utf8"));
71
+ return Array.isArray(m?.changes) ? m.changes : [];
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Carry out a plan. → { restored, deleted, failed, lines } — `lines` says what happened to each
79
+ * path, for the caller to print. Never throws; a step that fails is reported and the rest go on.
80
+ */
81
+ export async function applyRevert(steps, { exists = existsSync } = {}) {
82
+ let restored = 0, deleted = 0, failed = 0;
83
+ const lines = [];
84
+ for (const s of steps) {
85
+ try {
86
+ if (s.op === "restore" && exists(s.backup)) {
87
+ await copyFile(s.backup, s.path);
88
+ restored += 1;
89
+ lines.push(`restored ${s.path}`);
90
+ } else if (s.op === "delete" && exists(s.path)) {
91
+ await unlink(s.path);
92
+ deleted += 1;
93
+ lines.push(`deleted ${s.path}`);
94
+ } else if (s.op === "rmdir" && exists(s.path)) {
95
+ // rmdir removes only an EMPTY folder — undo never destroys what someone put inside later.
96
+ await rmdir(s.path);
97
+ deleted += 1;
98
+ lines.push(`removed ${s.path}/`);
99
+ }
100
+ } catch (err) {
101
+ failed += 1;
102
+ lines.push(`FAILED ${s.path}: ${err.message}`);
103
+ }
104
+ }
105
+ return { restored, deleted, failed, lines };
106
+ }