@tiens.nguyen/gonext-local-worker 1.0.196 → 1.0.198

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.
@@ -288,7 +288,7 @@ if (args[0] === "workspace") {
288
288
  // gonext-local-worker workspace revert → latest run
289
289
  // gonext-local-worker workspace revert <runId> → a specific run (see backups dir)
290
290
  const { readdirSync, existsSync: ex } = await import("node:fs");
291
- const { copyFile, unlink } = await import("node:fs/promises");
291
+ const { copyFile, unlink, rmdir } = await import("node:fs/promises");
292
292
  const backupsRoot = join(homedir(), ".gonext", "workspace-backups");
293
293
  let runId = args[2] || "";
294
294
  if (!runId) {
@@ -321,6 +321,12 @@ if (args[0] === "workspace") {
321
321
  await unlink(c.path);
322
322
  deleted += 1;
323
323
  console.log(` deleted ${c.path}`);
324
+ } else if (c.action === "create_dir" && ex(c.path)) {
325
+ // rmdir only removes an EMPTY dir — a revert must never destroy content
326
+ // the user (or a later run) put inside the created folder afterwards.
327
+ await rmdir(c.path);
328
+ deleted += 1;
329
+ console.log(` removed ${c.path}/`);
324
330
  }
325
331
  } catch (err) {
326
332
  failed += 1;
package/gonext-repl.mjs CHANGED
@@ -15,21 +15,28 @@
15
15
  * logs, exactly like a web question. The REPL polls the job and streams the persisted
16
16
  * chunks live into the terminal.
17
17
  *
18
+ * Conversation history is saved to ~/.gonext/sessions/<hash-of-folder-path>.json after
19
+ * every turn and reloaded on the next `gonext` in the SAME folder — so closing the
20
+ * terminal and coming back later still has context (e.g. "continue" after an
21
+ * interrupted investigation actually resumes it). /reset clears it.
22
+ *
18
23
  * Requires the worker daemon to be running (it claims the job).
19
- * Commands: /exit /revert [runId] /help Ctrl-C stops following the current run.
24
+ * Commands: /exit /reset /revert [runId] /help Ctrl-C stops following the current run.
20
25
  */
21
- import { readFile, mkdir, writeFile } from "node:fs/promises";
26
+ import { readFile, mkdir, writeFile, unlink } from "node:fs/promises";
22
27
  import { existsSync, statSync, readFileSync } from "node:fs";
23
28
  import { spawn } from "node:child_process";
24
29
  import { homedir } from "node:os";
25
30
  import { dirname, join, resolve, basename } from "node:path";
26
31
  import { fileURLToPath } from "node:url";
27
32
  import { createInterface } from "node:readline";
33
+ import { createHash } from "node:crypto";
28
34
  import dotenv from "dotenv";
29
35
 
30
36
  const DIR = dirname(fileURLToPath(import.meta.url));
31
37
  const ENV_FILE = join(homedir(), ".gonext", "worker.env");
32
38
  const WS_FILE = join(homedir(), ".gonext", "workspaces.json");
39
+ const SESSIONS_DIR = join(homedir(), ".gonext", "sessions");
33
40
 
34
41
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
35
42
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
@@ -39,6 +46,7 @@ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
39
46
  // ANSI 90 ("bright black") — a reliable neutral grey across terminal themes, unlike
40
47
  // code 34 (blue) which many dark-theme palettes render as purple/indigo instead.
41
48
  const codeColor = (s) => `\x1b[90m${s}\x1b[0m`;
49
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
42
50
  const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
43
51
 
44
52
  // The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
@@ -65,6 +73,19 @@ const isRoutingLine = (line) =>
65
73
  /^(Routing your request…|Choosing a tool…|Composing a reply…|→ Agent mode.*|→ Chat reply)$/.test(
66
74
  line.trim()
67
75
  );
76
+ // The edit tools (edit_lines/edit_file/create_file) emit a multi-line "edit card"
77
+ // as one step event — a small stable line protocol rendered here as a colored diff
78
+ // (bold header, red '-' / green '+' lines, dim line numbers). Shapes must stay in
79
+ // sync with _render_edit_card in gonext_agent_chat.py:
80
+ // ⏺ Update(XeroForwardService.java)
81
+ // └ Replaced lines 19-19 with 1 line
82
+ // 19 - ...old...
83
+ // 19 + ...new...
84
+ // … (+3 more)
85
+ const isEditCardHeader = (line) => /^⏺ (Update|Create)\(.+\)/.test(line);
86
+ const EDIT_DIFF_RE = /^(\s{4})(\d+) ([-+]) (.*)$/;
87
+ const isEditCardBody = (line) =>
88
+ /^\s*└ /.test(line) || EDIT_DIFF_RE.test(line) || /^\s{4}… /.test(line);
68
89
 
69
90
  // Playful words for the local "thinking…" ticker, reused verbatim from the worker's
70
91
  // thinking_words.txt sibling (falls back to a tiny built-in list if it's missing).
@@ -90,9 +111,11 @@ if (argv.includes("--help") || argv.includes("-h")) {
90
111
  " -v, --verbose show the agent's diagnostic logs ([gonext-agent] …) and python stderr\n" +
91
112
  " -h, --help this help\n\n" +
92
113
  "Auth comes from ~/.gonext/worker.env; models/budgets/RAG come from your web Settings.\n" +
93
- "Inside the REPL: /exit · /revert [runId] · /help — Ctrl-C stops following a turn.\n" +
114
+ "Inside the REPL: /exit · /reset · /revert [runId] · /help — Ctrl-C stops following a turn.\n" +
94
115
  "Questions are enqueued like web questions and executed by the RUNNING\n" +
95
- "gonext-local-worker daemon — its terminal shows the full [gonext-agent] logs."
116
+ "gonext-local-worker daemon — its terminal shows the full [gonext-agent] logs.\n" +
117
+ "Conversation history is saved per-folder in ~/.gonext/sessions and resumed next time\n" +
118
+ "you run `gonext` in the same folder; /reset forgets it and starts fresh."
96
119
  );
97
120
  process.exit(0);
98
121
  }
@@ -218,6 +241,60 @@ async function ensureWorkspace() {
218
241
  );
219
242
  }
220
243
 
244
+ // ---------- conversation persistence, keyed by folder ----------
245
+ // Without this, closing the terminal (or restarting `gonext`) loses ALL context — the
246
+ // next session starts from a blank slate even though the USER remembers the earlier
247
+ // investigation. "continue" (and general follow-ups) only make sense across restarts if
248
+ // there's something real on disk to continue FROM. Same hash-keying scheme the worker's
249
+ // python side already uses for per-URL RAG work dirs (sha256, 32 hex chars), applied
250
+ // here to the workspace path instead so returning to the SAME folder reloads the SAME
251
+ // conversation.
252
+ function sessionFilePath(cwd) {
253
+ const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 32);
254
+ return join(SESSIONS_DIR, `${hash}.json`);
255
+ }
256
+
257
+ // Cap what's kept on disk — the worker already truncates what it actually SENDS to the
258
+ // model by a char budget (see "history: N prior turn(s), M chars (budget 8000)" in its
259
+ // logs), so persisting more than that budget could ever use is just unbounded growth
260
+ // for a long-lived folder with no benefit.
261
+ const MAX_PERSISTED_MESSAGES = 40;
262
+
263
+ async function loadSession(cwd) {
264
+ try {
265
+ const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
266
+ return Array.isArray(raw?.history) ? raw.history : [];
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
271
+
272
+ async function saveSession(cwd, history) {
273
+ try {
274
+ await mkdir(SESSIONS_DIR, { recursive: true });
275
+ const trimmed = history.slice(-MAX_PERSISTED_MESSAGES);
276
+ await writeFile(
277
+ sessionFilePath(cwd),
278
+ JSON.stringify(
279
+ { cwd, updatedAt: new Date().toISOString(), history: trimmed },
280
+ null,
281
+ 2
282
+ ) + "\n"
283
+ );
284
+ } catch (err) {
285
+ // Non-fatal — losing session persistence should never crash or block the REPL.
286
+ console.error(dim(`(session save failed: ${err.message})`));
287
+ }
288
+ }
289
+
290
+ async function clearSession(cwd) {
291
+ try {
292
+ await unlink(sessionFilePath(cwd));
293
+ } catch {
294
+ // Nothing on disk to clear — fine.
295
+ }
296
+ }
297
+
221
298
  // ---------- fetch the ready-to-run agent payload from user settings ----------
222
299
  async function fetchAgentPayload(messages) {
223
300
  const res = await fetch(`${apiBase}/api/worker/agent-payload`, {
@@ -374,6 +451,7 @@ async function runAgentTurn(history) {
374
451
  // them prints in a distinct grey (not dim, so it stands out from "Thought:" prose);
375
452
  // blank lines keep spacing; everything else commits dim to scrollback.
376
453
  let inCode = false;
454
+ let inEditCard = false; // inside a colored edit-card block (see isEditCardHeader)
377
455
  let lastWasBlank = true; // suppresses a leading blank line and collapses repeats
378
456
  // Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
379
457
  // a long plain-reply answer stream character-by-character instead of waiting for a "\n"
@@ -403,6 +481,35 @@ async function runAgentTurn(history) {
403
481
  lastContentAt = Date.now();
404
482
  continue;
405
483
  }
484
+ // Edit card (a file change made by the agent's edit tools) — rendered as a
485
+ // colored diff instead of dim prose. Checked before the <code> handling: the
486
+ // card is a step event, never part of the model's raw code stream, and the ⏺
487
+ // header is distinctive enough to never collide with generated Python.
488
+ if (isEditCardHeader(line)) {
489
+ onContent();
490
+ inEditCard = true;
491
+ process.stdout.write(green("⏺") + bold(line.slice(1)) + "\n");
492
+ lastWasBlank = false;
493
+ lastContentAt = Date.now();
494
+ continue;
495
+ }
496
+ if (inEditCard) {
497
+ if (isEditCardBody(line)) {
498
+ onContent();
499
+ const m = EDIT_DIFF_RE.exec(line);
500
+ if (m) {
501
+ // " 19 - old text" → dim line number, red/green signed content.
502
+ const paintDiff = m[3] === "+" ? green : red;
503
+ process.stdout.write(`${m[1]}${dim(m[2])} ${paintDiff(`${m[3]} ${m[4]}`)}\n`);
504
+ } else {
505
+ process.stdout.write(dim(line) + "\n"); // "└ summary" / "… (+N more)"
506
+ }
507
+ lastWasBlank = false;
508
+ lastContentAt = Date.now();
509
+ continue;
510
+ }
511
+ inEditCard = false; // card ended — fall through to normal handling
512
+ }
406
513
  if (!inCode && isCodeOpenLine(line)) {
407
514
  stampIfThinking(); // hold/refresh the stamp; the code body commits it below
408
515
  inCode = true;
@@ -522,7 +629,14 @@ async function main() {
522
629
  }
523
630
  console.log(dim("Ask about this repo, or /help. Ctrl-C aborts a running turn.\n"));
524
631
 
525
- const history = [];
632
+ const cwd = resolve(process.cwd());
633
+ const history = await loadSession(cwd);
634
+ if (history.length > 0) {
635
+ const turns = history.filter((m) => m.role === "user").length;
636
+ console.log(
637
+ dim(`Resumed session: ${turns} prior turn(s) from last time in this folder — /reset to start fresh.\n`)
638
+ );
639
+ }
526
640
  // With terminal:false, Ctrl-C is no longer intercepted by readline's raw-mode byte
527
641
  // scanning (rl.on("SIGINT", …) would never fire) — it now delivers a real OS SIGINT to
528
642
  // the process instead, which we catch directly here. Same behavior as before.
@@ -550,7 +664,18 @@ async function main() {
550
664
  const line = lineRaw.trim();
551
665
  if (line === "/exit" || line === "/quit") break;
552
666
  if (line === "/help") {
553
- console.log(dim(" /exit — quit · /revert [runId] — undo the agent's file edits (latest run)"));
667
+ console.log(
668
+ dim(
669
+ " /exit — quit · /revert [runId] — undo the agent's file edits (latest run) · " +
670
+ "/reset — forget this folder's saved conversation and start fresh"
671
+ )
672
+ );
673
+ continue;
674
+ }
675
+ if (line === "/reset" || line === "/new") {
676
+ history.length = 0;
677
+ await clearSession(cwd);
678
+ console.log(dim("Session cleared — starting fresh.\n"));
554
679
  continue;
555
680
  }
556
681
  if (line.startsWith("/revert")) {
@@ -571,6 +696,10 @@ async function main() {
571
696
  const { text: answer, shown } = await runAgentTurn(history);
572
697
  if (answer) {
573
698
  history.push({ role: "assistant", content: answer });
699
+ // Persist after every successful turn (not just on clean exit) — an ungraceful
700
+ // termination (killed terminal, force-quit) would otherwise lose the whole
701
+ // session even though most of it completed fine.
702
+ await saveSession(cwd, history);
574
703
  // A plain-reply answer already streamed live in the loop above — printing it
575
704
  // again here would just duplicate it; a blank line is enough for spacing.
576
705
  console.log(shown ? "" : `\n\n${answer}\n`);
@@ -599,6 +599,36 @@ _AGENT_KEYWORDS = re.compile(
599
599
  re.IGNORECASE,
600
600
  )
601
601
 
602
+ # File/folder ACTION requests ("create a folder abc", "delete test.txt", "rename X to
603
+ # Y", "list the files") are agent work whenever a workspace is registered. The generic
604
+ # keyword list above cannot carry bare verbs like "create"/"delete" (they'd misroute
605
+ # ordinary chat for non-workspace users), and the weak model classifier reliably
606
+ # misreads these imperatives as how-to questions — answering with INSTRUCTIONS (a
607
+ # mkdir tutorial) instead of DOING the action (the reported bug, task #40). Verb and
608
+ # object noun must appear within one clause (no sentence punctuation between them).
609
+ _WS_ACTION_RE = re.compile(
610
+ r"\b(?:create|make|add|new|delete|remove|rename|move|copy|list|show)\b"
611
+ r"[^.?!]{0,60}?"
612
+ r"\b(?:folders?|director(?:y|ies)|dirs?|files?|subfolders?)\b"
613
+ # Run/verify actions on the project itself ("start and test the project", "run
614
+ # the app", "build it and run the tests") — second reported miss (task #40):
615
+ # none of these words were keywords anywhere, so "help me start and test the
616
+ # project" got a step-by-step npm tutorial instead of the agent running them.
617
+ r"|\b(?:run|start|launch|build|test|install|compile|verify|stop|kill|restart)\b"
618
+ r"[^.?!]{0,60}?"
619
+ r"\b(?:project|app|application|server|site|website|tests?|build|dependenc(?:y|ies)|packages?)\b"
620
+ r"|\bmkdir\b",
621
+ re.IGNORECASE,
622
+ )
623
+ # Genuine how-to QUESTIONS about files/folders ("how do I create a folder in
624
+ # Windows?") must stay plain chat — the user wants an explanation, not the action.
625
+ # Deliberately FIRST-PERSON only ("can I…"): "can/could YOU create a folder" is a
626
+ # polite request for the agent to DO it, not a how-to question.
627
+ _HOWTO_PREFIX_RE = re.compile(
628
+ r"^(?:how|what|why|when|where|which|explain|can\s+i|could\s+i|should\s+i|is\s+it|does)\b",
629
+ re.IGNORECASE,
630
+ )
631
+
602
632
  # Pure conversational openers/closers that never need a tool — a greeting, a thank-you, a
603
633
  # "who are you". Matching the WHOLE message (anchored) means "hi, fetch https://…" won't
604
634
  # match. These skip BOTH the model router call AND the heavy tool preamble → an instant
@@ -802,6 +832,20 @@ def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
802
832
  from openai import OpenAI
803
833
  client = OpenAI(base_url=base_url, api_key=api_key or "local",
804
834
  max_retries=0, timeout=20)
835
+ # The classifier's question must reflect the capabilities that ACTUALLY exist
836
+ # this run — with a workspace registered, acting on files/code/commands is
837
+ # agent work too, and pronoun phrasings ("test it") that the deterministic
838
+ # keyword gates can't safely match land here. Without this clause the prompt
839
+ # was HTTP-only (it predates the workspace tools), so NO was the classifier's
840
+ # CORRECT answer to the wrong question for every workspace action.
841
+ ws_clause = (
842
+ "\nThe user also has a code workspace registered, so answer YES as well "
843
+ "when the task asks to read, edit, create, delete, or list files or "
844
+ "folders, run tests/builds/commands, or start, stop, or restart the "
845
+ "project or its server — even when phrased with pronouns like 'test it' "
846
+ "or 'run it'."
847
+ ) if _WS_ROOTS else ""
848
+ ws_q = ", files, code, or commands" if _WS_ROOTS else ""
805
849
  resp = _chat_create(
806
850
  client,
807
851
  model=model_id,
@@ -810,12 +854,13 @@ def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
810
854
  "You are a task classifier. Reply YES or NO only, no punctuation.\n"
811
855
  "Answer YES if the task requires fetching data from an external network source "
812
856
  "(URL, API, website, remote server), a web search / factual lookup, or the "
813
- "current date or time.\n"
814
- "Answer NO only if it is pure conversation, opinion, or simple text the "
815
- "assistant can answer directly without looking anything up."
857
+ "current date or time."
858
+ + ws_clause +
859
+ "\nAnswer NO only if it is pure conversation, opinion, or simple text the "
860
+ "assistant can answer directly without looking anything up or touching anything."
816
861
  )},
817
862
  {"role": "user", "content": (
818
- f"Does this task require fetching data from an external network source?\n\n"
863
+ f"Does this task require using tools (network{ws_q})?\n\n"
819
864
  f"Task: {task_text}\n\nYES or NO:"
820
865
  )},
821
866
  ],
@@ -1844,11 +1889,137 @@ def _workspace_overview(roots, max_entries: int = 40) -> str:
1844
1889
  return "\n".join(parts)
1845
1890
 
1846
1891
 
1892
+ def _turn_checkpoint_path(workspace: str) -> str:
1893
+ """Path to the interrupted-turn checkpoint for a given workspace. Keyed by a hash
1894
+ of the workspace path (same sha256-slice convention as _rag_source_key) — but a
1895
+ separate file/purpose from the REPL's own ~/.gonext/sessions: this holds one
1896
+ in-PROGRESS turn's step trace, written incrementally as the agent works, not
1897
+ completed Q&A history."""
1898
+ import hashlib
1899
+ import os
1900
+ h = hashlib.sha256(workspace.encode("utf-8")).hexdigest()[:32]
1901
+ base = os.path.join(os.path.expanduser("~"), ".gonext", "agent-turns")
1902
+ os.makedirs(base, exist_ok=True)
1903
+ return os.path.join(base, f"{h}.json")
1904
+
1905
+
1906
+ def _write_turn_checkpoint(workspace: str, task: str, steps: list) -> None:
1907
+ """Overwrite the checkpoint for this workspace with the CURRENT turn's step trace so
1908
+ far. Called after every agent step (see step_callback). If the process dies mid-turn
1909
+ (network drop, kill, crash) before any final answer is ever emitted, this file
1910
+ survives on disk and the NEXT turn in this workspace recovers it instead of silently
1911
+ re-investigating from scratch — see _load_and_clear_turn_checkpoint."""
1912
+ import json
1913
+ import os
1914
+ try:
1915
+ path = _turn_checkpoint_path(workspace)
1916
+ tmp = path + ".tmp"
1917
+ with open(tmp, "w", encoding="utf-8") as f:
1918
+ json.dump({"task": task, "steps": steps}, f)
1919
+ os.replace(tmp, path)
1920
+ except Exception as e: # noqa: BLE001
1921
+ _log(f"turn checkpoint write failed (non-fatal): {e}")
1922
+
1923
+
1924
+ def _load_and_clear_turn_checkpoint(workspace: str):
1925
+ """Read a leftover checkpoint (if any) from a prior turn in this workspace that
1926
+ never reached a graceful finish, then delete it — its content is about to be folded
1927
+ into the NEW turn's context, and the new turn starts writing its own fresh
1928
+ checkpoint from here. Returns None if there's nothing to recover."""
1929
+ import json
1930
+ import os
1931
+ path = _turn_checkpoint_path(workspace)
1932
+ data = None
1933
+ try:
1934
+ with open(path, encoding="utf-8") as f:
1935
+ data = json.load(f)
1936
+ except FileNotFoundError:
1937
+ return None
1938
+ except Exception as e: # noqa: BLE001
1939
+ _log(f"turn checkpoint read failed (non-fatal): {e}")
1940
+ return None
1941
+ finally:
1942
+ try:
1943
+ os.remove(path)
1944
+ except OSError:
1945
+ pass
1946
+ return data
1947
+
1948
+
1949
+ def _turn_checkpoint_exists(workspace: str) -> bool:
1950
+ """Non-consuming probe: does an interrupted turn survive on disk for this
1951
+ workspace? Routing needs to KNOW there's work to resume (to force agent mode on a
1952
+ bare 'continue') without destroying it — the read-and-delete consume happens later,
1953
+ only on the agent path, where the recovered trace is actually fed to a model."""
1954
+ import os
1955
+ return os.path.exists(_turn_checkpoint_path(workspace))
1956
+
1957
+
1958
+ def _clear_turn_checkpoint(workspace: str) -> None:
1959
+ """Delete the checkpoint once a turn reaches ANY graceful finish (full success, the
1960
+ max-steps fallback, or a successful degrade-to-plain-reply) — that turn's findings
1961
+ are already captured in the final answer pushed to conversation history, so the raw
1962
+ step trace is no longer needed for crash recovery."""
1963
+ import os
1964
+ try:
1965
+ os.remove(_turn_checkpoint_path(workspace))
1966
+ except OSError:
1967
+ pass
1968
+
1969
+
1970
+ def _render_edit_card(action: str, path: str, summary: str,
1971
+ removed=None, added=None,
1972
+ max_removed: int = 8, max_added: int = 12) -> str:
1973
+ """Multi-line terminal-friendly summary of a file edit, emitted as ONE step event.
1974
+
1975
+ The line shapes are a small stable protocol the REPL colorizes (bold header,
1976
+ red '-' lines, green '+' lines, dim line numbers) — keep them in sync with the
1977
+ isEditCardHeader/EDIT_DIFF_RE regexes in gonext-repl.mjs. The web thinking panel
1978
+ shows the same text as-is (the 4-space indent renders as a monospace block in
1979
+ markdown), so no web change is needed.
1980
+
1981
+ ⏺ Update(XeroForwardService.java)
1982
+ └ Replaced lines 19-19 with 1 line
1983
+ 19 - ...old text...
1984
+ 19 + ...new text...
1985
+ """
1986
+ import os
1987
+ lines = [f"⏺ {action}({os.path.basename(path)})", f" └ {summary}"]
1988
+
1989
+ def _fmt(entries, sign, cap):
1990
+ out = []
1991
+ for i, (no, txt) in enumerate(entries):
1992
+ if i >= cap:
1993
+ out.append(f" … (+{len(entries) - cap} more)")
1994
+ break
1995
+ # NOT _clip() — that collapses ALL whitespace, destroying the code's
1996
+ # leading indentation, which a diff needs to stay readable.
1997
+ t = txt.rstrip()
1998
+ if len(t) > 160:
1999
+ t = t[:159] + "…"
2000
+ out.append(f" {no} {sign} {t}")
2001
+ return out
2002
+
2003
+ if removed:
2004
+ lines += _fmt(removed, "-", max_removed)
2005
+ if added:
2006
+ lines += _fmt(added, "+", max_added)
2007
+ return "\n".join(lines)
2008
+
2009
+
1847
2010
  def _ws_write_allowed(path: str) -> str:
1848
2011
  """Resolve `path` for WRITING: must be inside a registered workspace and never
1849
2012
  inside its .git/ internals (agents must not corrupt version control)."""
1850
2013
  import os
1851
- rp = os.path.realpath(path)
2014
+ p = (path or "").strip()
2015
+ # Anchor RELATIVE paths to the active/first workspace root — the worker daemon's
2016
+ # own cwd is ~, so a bare "abc" would otherwise resolve to ~/abc and be rejected
2017
+ # even though the user plainly means <workspace>/abc. Mirrors _rag_read_allowed.
2018
+ if p and not os.path.isabs(os.path.expanduser(p)):
2019
+ base = _WS_ACTIVE or (_WS_ROOTS[0]["path"] if _WS_ROOTS else "")
2020
+ if base:
2021
+ p = os.path.join(base, p)
2022
+ rp = os.path.realpath(os.path.expanduser(p))
1852
2023
  w = _ws_for_path(rp)
1853
2024
  if w is None:
1854
2025
  raise ValueError(
@@ -1862,7 +2033,7 @@ def _ws_write_allowed(path: str) -> str:
1862
2033
 
1863
2034
 
1864
2035
  _WS_RUN_ID = None # backup folder for this run, created lazily on first change
1865
- _WS_CHANGES: list = [] # [{"action": "edit"|"create", "path": str, "backup": str|None}]
2036
+ _WS_CHANGES: list = [] # [{"action": "edit"|"create"|"create_dir", "path": str, "backup": str|None}]
1866
2037
 
1867
2038
 
1868
2039
  def _ws_backup_root() -> str:
@@ -1915,6 +2086,77 @@ _WS_RUN_ALLOWED = {
1915
2086
  "rspec", "bundle", "php", "composer", "tsc", "jest", "vitest",
1916
2087
  }
1917
2088
 
2089
+ # ---- background servers (behavior-detected, command-agnostic) ----
2090
+ # run_command classifies a command by OBSERVED BEHAVIOR, never by its text: if the
2091
+ # process keeps running AND starts LISTENing on a TCP port, it IS a server — npm start,
2092
+ # bun dev, a custom `make serve`, anything. (An earlier version pattern-matched command
2093
+ # names like "npm start"; rejected as unwinnable hardcode — same lesson as task #37.)
2094
+ # Detected servers are left running in their own process group (start_new_session),
2095
+ # recorded in ~/.gonext/servers.json, and stoppable via the stop_server tool.
2096
+
2097
+
2098
+ def _ws_pgroup_pids(pgid: int) -> list:
2099
+ """Live pids in a process group (the server + everything it spawned)."""
2100
+ import subprocess
2101
+ try:
2102
+ out = subprocess.run(["pgrep", "-g", str(pgid)],
2103
+ stdout=subprocess.PIPE, text=True, timeout=5)
2104
+ return [int(x) for x in out.stdout.split()]
2105
+ except Exception: # noqa: BLE001
2106
+ return []
2107
+
2108
+
2109
+ def _ws_listening_ports(pgid: int) -> list:
2110
+ """TCP ports the process group is LISTENing on — the behavioral definition of
2111
+ 'this command is a server'. Empty when none (or lsof/pgrep unavailable)."""
2112
+ import subprocess
2113
+ pids = _ws_pgroup_pids(pgid)
2114
+ if not pids:
2115
+ return []
2116
+ try:
2117
+ out = subprocess.run(
2118
+ ["lsof", "-a", "-p", ",".join(map(str, pids)),
2119
+ "-iTCP", "-sTCP:LISTEN", "-P", "-n"],
2120
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=5)
2121
+ ports = set()
2122
+ for line in out.stdout.splitlines()[1:]:
2123
+ m = re.search(r":(\d+)\s+\(LISTEN\)", line)
2124
+ if m:
2125
+ ports.add(int(m.group(1)))
2126
+ return sorted(ports)
2127
+ except Exception: # noqa: BLE001
2128
+ return []
2129
+
2130
+
2131
+ def _ws_servers_path() -> str:
2132
+ import os
2133
+ base = os.path.join(os.path.expanduser("~"), ".gonext")
2134
+ os.makedirs(base, exist_ok=True)
2135
+ return os.path.join(base, "servers.json")
2136
+
2137
+
2138
+ def _ws_load_servers(prune: bool = True) -> list:
2139
+ """Registry of background servers we started. `prune` drops entries whose
2140
+ process group is gone (crashed or stopped outside of us)."""
2141
+ import json as _json
2142
+ try:
2143
+ with open(_ws_servers_path(), encoding="utf-8") as fh:
2144
+ servers = _json.load(fh).get("servers", [])
2145
+ except Exception: # noqa: BLE001
2146
+ return []
2147
+ if prune:
2148
+ servers = [s for s in servers if _ws_pgroup_pids(int(s.get("pgid", 0)))]
2149
+ return servers
2150
+
2151
+
2152
+ def _ws_save_servers(servers: list) -> None:
2153
+ import json as _json
2154
+ try:
2155
+ with open(_ws_servers_path(), "w", encoding="utf-8") as fh:
2156
+ _json.dump({"servers": servers}, fh, indent=2)
2157
+ except OSError as e:
2158
+ _log(f"servers.json write failed: {e}")
2159
+
1918
2160
 
1919
2161
  def _ws_scrubbed_env() -> dict:
1920
2162
  """Minimal child env for run_command. The worker process env holds secrets
@@ -1923,7 +2165,11 @@ def _ws_scrubbed_env() -> dict:
1923
2165
  import os
1924
2166
  keep = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SHELL", "USER", "TERM",
1925
2167
  "JAVA_HOME", "GOPATH", "CARGO_HOME", "NVM_DIR")
1926
- return {k: os.environ[k] for k in keep if k in os.environ}
2168
+ env = {k: os.environ[k] for k in keep if k in os.environ}
2169
+ # CI=1 makes watch-mode test runners (CRA's `npm test`, jest --watch) run ONCE and
2170
+ # exit instead of sitting in interactive watch mode until the timeout kills them.
2171
+ env["CI"] = "1"
2172
+ return env
1927
2173
 
1928
2174
 
1929
2175
  def _ws_distill_output(out: str, cap_head: int = 1200, cap_tail: int = 4000) -> str:
@@ -2246,11 +2492,27 @@ def run_agent_chat(cfg):
2246
2492
  the AGENT, not plain chat — plain chat has zero tool access and can never
2247
2493
  actually continue a stalled grep_repo/read_file_lines investigation, which is
2248
2494
  exactly the reported bug: 'continue' silently degraded to a tool-less reply that
2249
- could only apologize, never resume. Gated tightly (short continuation phrase +
2250
- genuinely-interrupted prior turn) so it doesn't misfire on an unrelated 'continue'
2251
- after a normal, already-complete answer."""
2495
+ could only apologize, never resume. Two independent interruption signals:
2496
+ (a) our own interrupted-investigation marker in the prior assistant turn (the
2497
+ max-steps note or the degrade-fallback note); (b) an on-disk turn checkpoint for
2498
+ this workspace — a CRASHED turn leaves NO history marker at all (the REPL
2499
+ discards failed turns entirely, by design — task #35), so the checkpoint file is
2500
+ the only surviving evidence there is work to resume. Gated tightly (short
2501
+ continuation phrase + a genuine interruption signal) so it doesn't misfire on an
2502
+ unrelated 'continue' after a normal, already-complete answer."""
2252
2503
  return bool(_CONTINUE_CUE.match((latest_user_text or "").strip())
2253
- and _prior_turn_was_interrupted())
2504
+ and (_prior_turn_was_interrupted()
2505
+ or (_WS_ACTIVE and _turn_checkpoint_exists(_WS_ACTIVE))))
2506
+
2507
+ def _ws_action_pending() -> bool:
2508
+ """An imperative file/folder action ("create a folder abc") with a registered
2509
+ workspace must reach the AGENT — plain chat can only answer with mkdir
2510
+ instructions (the reported task-#40 bug). How-to QUESTIONS are excluded: the
2511
+ user asking "how do I create a folder in Windows?" wants an explanation."""
2512
+ t = (latest_user_text or "").strip()
2513
+ return bool(_WS_AVAILABLE
2514
+ and not _HOWTO_PREFIX_RE.match(t)
2515
+ and _WS_ACTION_RE.search(t))
2254
2516
 
2255
2517
  # Route: does this task need HTTP tool use? A trivial greeting/thank-you/"who are you"
2256
2518
  # is caught LOCALLY (no model classifier call, no tool preamble) → straight to a fast
@@ -2275,6 +2537,13 @@ def run_agent_chat(cfg):
2275
2537
  _log("router → YES (continuation of an interrupted investigation)")
2276
2538
  _emit({"type": "step", "text": "→ Agent mode (continuing)"})
2277
2539
  needs_agent = True
2540
+ # An imperative file/folder action with a workspace registered must be DONE with
2541
+ # tools, not answered with terminal instructions — the classifier misreads these
2542
+ # as how-to questions (task #40: "create a folder abc" got a mkdir tutorial).
2543
+ if not needs_agent and _ws_action_pending():
2544
+ _log("router → YES (workspace file/folder action)")
2545
+ _emit({"type": "step", "text": "→ Agent mode (workspace action)"})
2546
+ needs_agent = True
2278
2547
 
2279
2548
  if not needs_agent:
2280
2549
  _log("router: plain chat (no HTTP needed)")
@@ -2518,6 +2787,42 @@ def run_agent_chat(cfg):
2518
2787
  "create_pdf / final_answer.\n"
2519
2788
  "- Do NOT put final_answer outside the code block.\n\n"
2520
2789
  )
2790
+ # Recover an interrupted prior turn's step trace, if one survives on disk (crash/
2791
+ # connection drop mid-investigation — see _write_turn_checkpoint). Consumed HERE,
2792
+ # inside the agent path only, and not up front before routing: task_text feeds only
2793
+ # the agent prompt (plain chat sends the raw `messages` list to _plain_reply), so an
2794
+ # earlier consume would let any plain-chat-routed message delete the checkpoint
2795
+ # without any model ever seeing it — silently destroying the recovery data.
2796
+ if _WS_ACTIVE:
2797
+ _ck = _load_and_clear_turn_checkpoint(_WS_ACTIVE)
2798
+ if _ck and _ck.get("steps"):
2799
+ _ck_steps = _ck["steps"]
2800
+ # Newest-first budget walk (same pattern as the history condenser above):
2801
+ # the latest steps matter most for resuming, and an uncapped 12-step trace
2802
+ # (~25K chars at the per-step clip limits) would dominate prompt-eval on a
2803
+ # slow coding server — the user's box already takes 3+ min at half that.
2804
+ _kept, _used = [], 0
2805
+ for _i, _s in enumerate(reversed(_ck_steps)):
2806
+ if not isinstance(_s, dict):
2807
+ _s = {"did": str(_s), "found": ""}
2808
+ _seg = (f"Step {_s.get('step', len(_ck_steps) - _i)}:\n"
2809
+ f" Did: {_s.get('did', '')}\n Found: {_s.get('found', '')}")
2810
+ if _used + len(_seg) > 5000 and _kept:
2811
+ _kept.append(f"(…{len(_ck_steps) - len(_kept)} earlier step(s) omitted)")
2812
+ break
2813
+ _kept.append(_seg)
2814
+ _used += len(_seg)
2815
+ _kept.reverse()
2816
+ _log(f"recovered {len(_ck_steps)} step(s) from an interrupted prior turn "
2817
+ f"in this workspace ({_used} chars folded into the task)")
2818
+ task_text += (
2819
+ "\n\nNOTE: your previous attempt at a task in this workspace was "
2820
+ "interrupted before finishing (crash or connection drop) — here is "
2821
+ "exactly what was already done and found. Do NOT repeat these steps; "
2822
+ "continue the investigation/edit from here:\n"
2823
+ f"Previous task: {_ck.get('task', '')}\n" + "\n".join(_kept)
2824
+ )
2825
+
2521
2826
  # Lead with the TASK so the weak model anchors on what's actually being asked —
2522
2827
  # not on the tool reference below. (Previously the hint led with the date, and the
2523
2828
  # 3B model treated every message as a date question.)
@@ -2535,6 +2840,13 @@ def run_agent_chat(cfg):
2535
2840
  # tool returned (no extra model call) if the loop ends without final_answer.
2536
2841
  _last_obs: dict = {"text": ""}
2537
2842
 
2843
+ # Set by provide_final_answer when it had to synthesize a PARTIAL answer at the
2844
+ # step budget. Read by the graceful-finish checkpoint clear: a max-steps ending
2845
+ # surfaces only the LAST observation into history, so the full step trace must
2846
+ # survive on disk for a follow-up "continue" to genuinely resume — clearing it
2847
+ # there would leave the next turn a ~500-char condensed history line instead.
2848
+ _maxsteps_partial: dict = {"hit": False}
2849
+
2538
2850
  # Deterministic loop-breaker. Weak models often repeat the SAME search/fetch over and
2539
2851
  # over (hoping for a "more complete" result) and never converge, burning the whole step
2540
2852
  # budget. We remember each (tool, arg) signature; on a repeat we DON'T re-run it —
@@ -2908,6 +3220,11 @@ def run_agent_chat(cfg):
2908
3220
  _last_obs["text"] = out
2909
3221
  return out
2910
3222
 
3223
+ # Growing record of this turn's steps, persisted to disk after each one via
3224
+ # _write_turn_checkpoint (inside step_callback below) — see _load_and_clear_turn_
3225
+ # checkpoint above for how a crashed/interrupted turn gets recovered on the next run.
3226
+ _turn_steps: list = []
3227
+
2911
3228
  def step_callback(step_log):
2912
3229
  step_num = getattr(step_log, "step_number", "?")
2913
3230
 
@@ -2929,6 +3246,22 @@ def run_agent_chat(cfg):
2929
3246
  text = _summarise_step(step_log)
2930
3247
  except Exception as e: # noqa: BLE001
2931
3248
  text = f"Step: {e}"
3249
+ # Checkpoint every real step to disk so an interrupted turn (crash/connection
3250
+ # drop) can be recovered on the next run in this workspace. Deliberately uses
3251
+ # the FULL model_output + FULL _last_obs here, NOT the one-line `text` summary
3252
+ # above — `text` is clipped to a single first line for the live terminal display
3253
+ # and would lose most of what the step actually found (e.g. a grep_repo hit list
3254
+ # is up to 100 lines; `_summarise_step` keeps only the "N hits" header line).
3255
+ # `_last_obs["text"]` already holds the CURRENT step's full tool output — every
3256
+ # workspace/RAG tool sets it on every return path (see task #38) specifically so
3257
+ # the max-steps fallback can surface it; reusing it here for the same reason.
3258
+ if _WS_ACTIVE and (model_output or _last_obs.get("text")):
3259
+ _turn_steps.append({
3260
+ "step": step_num,
3261
+ "did": _clip(str(model_output or "").strip(), 600),
3262
+ "found": _clip(str(_last_obs.get("text") or "").strip(), 1500),
3263
+ })
3264
+ _write_turn_checkpoint(_WS_ACTIVE, latest_user_text, _turn_steps)
2932
3265
  # Skip emitting if there's nothing beyond the tool name — the tool already
2933
3266
  # emitted its own step event with the actual response above.
2934
3267
  if not text or text.rstrip().endswith("| →"):
@@ -3366,6 +3699,7 @@ def run_agent_chat(cfg):
3366
3699
  _log(f"deterministic create_pdf did not succeed: {str(out)[:120]}")
3367
3700
  except Exception as e: # noqa: BLE001
3368
3701
  _log(f"deterministic create_pdf error: {type(e).__name__}: {e}")
3702
+ _maxsteps_partial["hit"] = True
3369
3703
  text = (_last_obs.get("text") or "").strip()
3370
3704
  if text:
3371
3705
  _log(f"max-steps fallback (last tool obs) → {text[:80]}")
@@ -3778,11 +4112,17 @@ def run_agent_chat(cfg):
3778
4112
  new_lines = new_content.splitlines(keepends=True)
3779
4113
  if new_lines and not new_lines[-1].endswith("\n"):
3780
4114
  new_lines[-1] += "\n"
4115
+ old_lines = lines[s - 1:e]
3781
4116
  lines[s - 1:e] = new_lines
3782
4117
  with open(rp, "w", encoding="utf-8") as fh:
3783
4118
  fh.writelines(lines)
3784
4119
  _ws_record_change("edit", rp, backup)
3785
- _emit({"type": "step", "text": f"Edited {os.path.basename(rp)} lines {s}-{e}"})
4120
+ _emit({"type": "step", "text": _render_edit_card(
4121
+ "Update", rp,
4122
+ f"Replaced lines {s}-{e} with {len(new_lines)} line(s)",
4123
+ removed=[(s + i, t) for i, t in enumerate(old_lines)],
4124
+ added=[(s + i, t) for i, t in enumerate(new_lines)],
4125
+ )})
3786
4126
  shown = "".join(f"{i}: {lines[i - 1]}" for i in
3787
4127
  range(max(1, s - 2), min(len(lines), s + len(new_lines) + 1) + 1))
3788
4128
  out = (f"Replaced lines {s}-{e} of {rp} with {len(new_lines)} line(s). "
@@ -3826,7 +4166,15 @@ def run_agent_chat(cfg):
3826
4166
  with open(rp, "w", encoding="utf-8") as fh:
3827
4167
  fh.write(text.replace(old_string, new_string, 1))
3828
4168
  _ws_record_change("edit", rp, backup)
3829
- _emit({"type": "step", "text": f"Edited {os.path.basename(rp)}"})
4169
+ start_no = text[:text.index(old_string)].count("\n") + 1
4170
+ old_ls = old_string.splitlines() or [""]
4171
+ new_ls = new_string.splitlines() or [""]
4172
+ _emit({"type": "step", "text": _render_edit_card(
4173
+ "Update", rp,
4174
+ f"Replaced {len(old_ls)} line(s) at line {start_no}",
4175
+ removed=[(start_no + i, t) for i, t in enumerate(old_ls)],
4176
+ added=[(start_no + i, t) for i, t in enumerate(new_ls)],
4177
+ )})
3830
4178
  out = f"Replaced 1 occurrence in {rp}. Backup saved."
3831
4179
  _last_obs["text"] = out
3832
4180
  return out
@@ -3854,7 +4202,12 @@ def run_agent_chat(cfg):
3854
4202
  with open(rp, "w", encoding="utf-8") as fh:
3855
4203
  fh.write(content)
3856
4204
  _ws_record_change("create", rp, None)
3857
- _emit({"type": "step", "text": f"Created {os.path.basename(rp)}"})
4205
+ content_ls = content.splitlines() or [""]
4206
+ _emit({"type": "step", "text": _render_edit_card(
4207
+ "Create", rp,
4208
+ f"Added {len(content_ls)} line(s)",
4209
+ added=[(i + 1, t) for i, t in enumerate(content_ls)],
4210
+ )})
3858
4211
  out = f"Created {rp} ({len(content)} chars)."
3859
4212
  _last_obs["text"] = out
3860
4213
  return out
@@ -3863,19 +4216,54 @@ def run_agent_chat(cfg):
3863
4216
  _last_obs["text"] = msg
3864
4217
  return msg
3865
4218
 
4219
+ @tool
4220
+ def create_folder(path: str) -> str:
4221
+ """Create a folder (and any missing parent folders) inside a registered workspace. Use this to ACTUALLY create a folder/directory the user asked for — never answer with mkdir instructions.
4222
+
4223
+ Args:
4224
+ path: new folder path inside a workspace (a relative path resolves against the workspace root).
4225
+ """
4226
+ try:
4227
+ rp = _ws_write_allowed((path or "").strip())
4228
+ import os
4229
+ if os.path.isdir(rp):
4230
+ msg = f"Folder already exists: {rp}"
4231
+ _last_obs["text"] = msg
4232
+ return msg
4233
+ if os.path.exists(rp):
4234
+ msg = f"Error: {path} already exists and is a file, not a folder."
4235
+ _last_obs["text"] = msg
4236
+ return msg
4237
+ os.makedirs(rp)
4238
+ # "create_dir" (not "create") — revert removes it with rmdir, which only
4239
+ # deletes EMPTY dirs, so a revert can never destroy content the user
4240
+ # added inside it afterwards. unlink (the "create" revert) fails on dirs.
4241
+ _ws_record_change("create_dir", rp, None)
4242
+ _emit({"type": "step", "text": _render_edit_card(
4243
+ "Create", rp, "New folder")})
4244
+ out = f"Created folder {rp}."
4245
+ _last_obs["text"] = out
4246
+ return out
4247
+ except Exception as ex: # noqa: BLE001
4248
+ msg = f"Error: create_folder failed: {type(ex).__name__}: {ex}"
4249
+ _last_obs["text"] = msg
4250
+ return msg
4251
+
3866
4252
  @tool
3867
4253
  def run_command(command: str, workdir: str = "", timeout_seconds: int = 180) -> str:
3868
- """Run a build/test command inside a workspace that has run permission (e.g. 'npm test', 'mvn test', 'pytest'). Use to VERIFY code changes; read the output and fix failures.
4254
+ """Run a command inside a workspace that has run permission (e.g. 'npm test', 'npm run build', 'npm start', 'mvn test'). One-shot commands return their output when they exit. If the command turns out to be a SERVER (keeps running and listens on a port), it is left RUNNING in the background and its URL is reported — tell the user the URL, and use stop_server to stop it when asked.
3869
4255
 
3870
4256
  Args:
3871
- command: the command line (build/test runners only; no shells or sudo).
4257
+ command: the command line (allowlisted runners only; no shells or sudo).
3872
4258
  workdir: directory to run in. Empty = first registered workspace.
3873
- timeout_seconds: kill the command after this many seconds (max 600).
4259
+ timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
3874
4260
  """
3875
4261
  try:
3876
4262
  import os
3877
4263
  import shlex
4264
+ import signal
3878
4265
  import subprocess
4266
+ import time as _t
3879
4267
  wd = _rag_read_allowed((workdir or "").strip() or (_WS_ROOTS[0]["path"] if _WS_ROOTS else "."))
3880
4268
  w = _ws_for_path(wd)
3881
4269
  if w is None or not w.get("allowRun"):
@@ -3895,23 +4283,128 @@ def run_agent_chat(cfg):
3895
4283
  return msg
3896
4284
  _emit({"type": "step", "text": f"Running → {command[:70]}"})
3897
4285
  t = max(5, min(int(timeout_seconds or 180), 600))
3898
- # Scrubbed env: repo test scripts must never see worker secrets.
3899
- proc = subprocess.run(
3900
- argv, cwd=wd, env=_ws_scrubbed_env(), timeout=t,
3901
- stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
4286
+ # Own process group (start_new_session) + output to a log file. This is
4287
+ # what lets a server OUTLIVE this job process, and what lets a timeout
4288
+ # kill take the runner's whole child tree (npm's node child etc. — the
4289
+ # old subprocess.run timeout killed only the direct child, leaking it).
4290
+ logs_dir = os.path.join(os.path.expanduser("~"), ".gonext", "run-logs")
4291
+ os.makedirs(logs_dir, exist_ok=True)
4292
+ log_path = os.path.join(logs_dir, f"{_t.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}.log")
4293
+ log_fh = open(log_path, "wb")
4294
+ # Scrubbed env: repo scripts must never see worker secrets.
4295
+ proc = subprocess.Popen(
4296
+ argv, cwd=wd, env=_ws_scrubbed_env(),
4297
+ stdout=log_fh, stderr=subprocess.STDOUT,
4298
+ start_new_session=True,
3902
4299
  )
3903
- out = _ws_distill_output(proc.stdout or "")
3904
- status = "PASSED (exit 0)" if proc.returncode == 0 else f"FAILED (exit {proc.returncode})"
3905
- _emit({"type": "step", "text": f"Command {status.split()[0].lower()} {command[:50]}"})
3906
- result = f"{status}\n{out}" if out.strip() else status
3907
- _last_obs["text"] = result
3908
- return result
3909
- except subprocess.TimeoutExpired:
3910
- msg = f"Error: command timed out after {timeout_seconds}s."
4300
+ start = _t.time()
4301
+
4302
+ def _read_log() -> str:
4303
+ log_fh.flush()
4304
+ try:
4305
+ with open(log_path, "r", encoding="utf-8", errors="replace") as fh:
4306
+ return fh.read()
4307
+ except OSError:
4308
+ return ""
4309
+
4310
+ # Behavior classification loop — no command-name matching anywhere:
4311
+ # exited → one-shot result; still running AND listening on a TCP port →
4312
+ # it's a server, leave it running; neither by the deadline → kill group.
4313
+ while True:
4314
+ rc = proc.poll()
4315
+ if rc is not None:
4316
+ out = _ws_distill_output(_read_log())
4317
+ status = "PASSED (exit 0)" if rc == 0 else f"FAILED (exit {rc})"
4318
+ _emit({"type": "step", "text": f"Command {status.split()[0].lower()} → {command[:50]}"})
4319
+ result = f"{status}\n{out}" if out.strip() else status
4320
+ _last_obs["text"] = result
4321
+ return result
4322
+ if _t.time() - start >= 3:
4323
+ ports = _ws_listening_ports(proc.pid)
4324
+ if ports:
4325
+ servers = _ws_load_servers()
4326
+ servers.append({
4327
+ "pgid": proc.pid, "pid": proc.pid, "command": command,
4328
+ "workdir": wd, "ports": ports, "log": log_path,
4329
+ "started_at": _t.strftime("%Y-%m-%d %H:%M:%S"),
4330
+ })
4331
+ _ws_save_servers(servers)
4332
+ urls = ", ".join(f"http://localhost:{p}" for p in ports)
4333
+ _emit({"type": "step", "text": f"Server up → {urls} (left running)"})
4334
+ tail = _ws_distill_output(_read_log())
4335
+ result = (f"RUNNING: '{command}' is up and listening — {urls} "
4336
+ f"(pid {proc.pid}, log {log_path}). It stays running in "
4337
+ "the background; use stop_server to stop it. "
4338
+ f"Startup output:\n{tail}")
4339
+ _last_obs["text"] = result
4340
+ return result
4341
+ if _t.time() - start >= t:
4342
+ try:
4343
+ os.killpg(proc.pid, signal.SIGKILL)
4344
+ except OSError:
4345
+ pass
4346
+ tail = _ws_distill_output(_read_log())
4347
+ msg = (f"Error: command neither exited nor started a server within "
4348
+ f"{t}s — killed. Output so far:\n{tail}")
4349
+ _last_obs["text"] = msg
4350
+ return msg
4351
+ _t.sleep(2)
4352
+ except Exception as ex: # noqa: BLE001
4353
+ msg = f"Error: run_command failed: {type(ex).__name__}: {ex}"
3911
4354
  _last_obs["text"] = msg
3912
4355
  return msg
4356
+ finally:
4357
+ try:
4358
+ log_fh.close()
4359
+ except Exception: # noqa: BLE001
4360
+ pass
4361
+
4362
+ @tool
4363
+ def stop_server(port_or_pid: str = "") -> str:
4364
+ """Stop a background server previously started by run_command. Use when the user asks to stop/kill the server.
4365
+
4366
+ Args:
4367
+ port_or_pid: the server's port or pid (empty = the most recently started one).
4368
+ """
4369
+ try:
4370
+ import os
4371
+ import signal
4372
+ import time as _t
4373
+ servers = _ws_load_servers()
4374
+ if not servers:
4375
+ msg = "No background servers are running."
4376
+ _last_obs["text"] = msg
4377
+ return msg
4378
+ key = (port_or_pid or "").strip()
4379
+ target = None
4380
+ if key:
4381
+ for s in servers:
4382
+ if key == str(s.get("pid")) or any(key == str(p) for p in s.get("ports", [])):
4383
+ target = s
4384
+ break
4385
+ if target is None:
4386
+ listing = "; ".join(
4387
+ f"pid {s['pid']} ports {s.get('ports')} ({s['command']})" for s in servers)
4388
+ msg = f"No running server matches '{key}'. Running: {listing}"
4389
+ _last_obs["text"] = msg
4390
+ return msg
4391
+ else:
4392
+ target = servers[-1]
4393
+ pgid = int(target["pgid"])
4394
+ try:
4395
+ os.killpg(pgid, signal.SIGTERM)
4396
+ _t.sleep(1.5)
4397
+ if _ws_pgroup_pids(pgid):
4398
+ os.killpg(pgid, signal.SIGKILL)
4399
+ except OSError:
4400
+ pass
4401
+ _ws_save_servers([s for s in servers if s is not target])
4402
+ _emit({"type": "step", "text": f"Server stopped → {target['command'][:50]}"})
4403
+ out = f"Stopped '{target['command']}' (pid {target['pid']}, ports {target.get('ports')})."
4404
+ _last_obs["text"] = out
4405
+ return out
3913
4406
  except Exception as ex: # noqa: BLE001
3914
- msg = f"Error: run_command failed: {type(ex).__name__}: {ex}"
4407
+ msg = f"Error: stop_server failed: {type(ex).__name__}: {ex}"
3915
4408
  _last_obs["text"] = msg
3916
4409
  return msg
3917
4410
 
@@ -3929,7 +4422,7 @@ def run_agent_chat(cfg):
3929
4422
  rag_index, rag_add, rag_search]
3930
4423
  if _WS_AVAILABLE:
3931
4424
  agent_tools += [grep_repo, read_file_lines, edit_lines, edit_file,
3932
- create_file, run_command]
4425
+ create_file, create_folder, run_command, stop_server]
3933
4426
  # list_dir/read_text_file are needed for workspace browsing even when RAG
3934
4427
  # (S3 indexing) isn't configured.
3935
4428
  if not _RAG_AVAILABLE:
@@ -3978,6 +4471,16 @@ def run_agent_chat(cfg):
3978
4471
  final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
3979
4472
  _log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
3980
4473
  _emit({"type": "final", "text": final_text})
4474
+ # Clear the step checkpoint ONLY on a genuinely complete finish (the model's own
4475
+ # final_answer, or a deterministic PDF delivery) — that content is now in the
4476
+ # answer pushed to conversation history, so the raw trace is no longer needed.
4477
+ # Kept when the turn ended PARTIALLY, in either way this can happen:
4478
+ # - max-steps fallback (_maxsteps_partial): its answer surfaces only the LAST
4479
+ # observation, so a follow-up "continue" needs the full trace on disk;
4480
+ # - degrade-to-plain-reply (except block below): its note carries only a
4481
+ # top-level file listing, not the step trace — same reasoning, never cleared.
4482
+ if _WS_ACTIVE and not _maxsteps_partial["hit"]:
4483
+ _clear_turn_checkpoint(_WS_ACTIVE)
3981
4484
  except Exception as e: # noqa: BLE001
3982
4485
  # Config errors (wrong coding-model name/URL) must reach the user VERBATIM —
3983
4486
  # they contain the fix (the server's real model list). smolagents wraps model
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.196",
3
+ "version": "1.0.198",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",