@tiens.nguyen/gonext-local-worker 1.0.214 → 1.0.216

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.
@@ -1837,21 +1837,6 @@ async function runAgentChatJob(job) {
1837
1837
  // lands here (must be a registered workspace; the python side re-validates).
1838
1838
  activeWorkspace: payload?.activeWorkspace ?? "",
1839
1839
  });
1840
- // Remember the terminal's current folder so the web homepage can highlight which
1841
- // registered workspace is ACTIVE (the local_health job reads this back). Without it
1842
- // the web can only dump the full workspaces.json list with no "you are here" signal.
1843
- // Fire-and-forget; a stale value just points at the last-used folder, which is fine.
1844
- if (typeof payload?.activeWorkspace === "string" && payload.activeWorkspace) {
1845
- const activePath = payload.activeWorkspace;
1846
- void mkdir(join(homedir(), ".gonext"), { recursive: true })
1847
- .then(() =>
1848
- writeFile(
1849
- join(homedir(), ".gonext", "active-workspace.json"),
1850
- JSON.stringify({ path: activePath, at: new Date().toISOString() }) + "\n"
1851
- )
1852
- )
1853
- .catch(() => {});
1854
- }
1855
1840
  // 30 min max for an agent run: multi-step ReAct on a 14B/31B with a cold prompt
1856
1841
  // cache — or a remote Ollama box on slow GPU cold-loading a big model — can run
1857
1842
  // for many minutes per step. Must stay BELOW the web client's hard WS deadline
@@ -2573,31 +2558,19 @@ async function runLocalHealthJob(job) {
2573
2558
  }
2574
2559
  : undefined,
2575
2560
  // Registered coding workspaces (names/paths only — no secrets) so the web can
2576
- // show which folders the agent may read/edit on this Mac. The one the `gonext`
2577
- // terminal last ran in is flagged `active` (persisted per agent job) and sorted
2578
- // first so the web can highlight it instead of dumping the whole list flat.
2579
- workspaces: await (async () => {
2580
- const activePath = await readFile(
2581
- join(homedir(), ".gonext", "active-workspace.json"),
2582
- "utf8"
2583
- )
2584
- .then((raw) => String(JSON.parse(raw)?.path ?? ""))
2585
- .catch(() => "");
2586
- return readFile(join(homedir(), ".gonext", "workspaces.json"), "utf8")
2587
- .then((raw) => {
2588
- const parsed = JSON.parse(raw);
2589
- if (!Array.isArray(parsed?.workspaces)) return undefined;
2590
- const list = parsed.workspaces.map((w) => ({
2591
- name: String(w.name ?? ""),
2592
- path: String(w.path ?? ""),
2593
- allowRun: Boolean(w.allowRun),
2594
- active: Boolean(activePath) && String(w.path ?? "") === activePath,
2595
- }));
2596
- // Active folder first; the rest keep their registration order.
2597
- return list.sort((a, b) => Number(b.active) - Number(a.active));
2598
- })
2599
- .catch(() => undefined);
2600
- })(),
2561
+ // show which folders the agent may read/edit on this Mac.
2562
+ workspaces: await readFile(join(homedir(), ".gonext", "workspaces.json"), "utf8")
2563
+ .then((raw) => {
2564
+ const parsed = JSON.parse(raw);
2565
+ return Array.isArray(parsed?.workspaces)
2566
+ ? parsed.workspaces.map((w) => ({
2567
+ name: String(w.name ?? ""),
2568
+ path: String(w.path ?? ""),
2569
+ allowRun: Boolean(w.allowRun),
2570
+ }))
2571
+ : undefined;
2572
+ })
2573
+ .catch(() => undefined),
2601
2574
  };
2602
2575
  const totalTimeSeconds = (Date.now() - start) / 1000;
2603
2576
  const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
@@ -1143,6 +1143,80 @@ def _normalize_code_tags(text: str) -> str:
1143
1143
  return fixed
1144
1144
 
1145
1145
 
1146
+ def _escape_raw_newlines_in_strings(code: str) -> str:
1147
+ """Turn RAW newlines/tabs that sit INSIDE a single/double-quoted string literal into
1148
+ \\n/\\t escapes. Triple-quoted strings, comments, and everything outside a string are
1149
+ copied verbatim. A tiny hand-rolled scanner (the code doesn't parse, so ast/tokenize
1150
+ can't help). Used only by _repair_unterminated_string below."""
1151
+ out = []
1152
+ i, n = 0, len(code)
1153
+ quote = None # the delimiter of the string we're inside, or None
1154
+ escaped = False
1155
+ while i < n:
1156
+ ch = code[i]
1157
+ if quote is None:
1158
+ three = code[i:i + 3]
1159
+ if three in ("'''", '"""'): # legal multi-line string — copy whole
1160
+ end = code.find(three, i + 3)
1161
+ if end == -1:
1162
+ out.append(code[i:]); break
1163
+ out.append(code[i:end + 3]); i = end + 3; continue
1164
+ if ch == "#": # comment — copy to end of line
1165
+ j = code.find("\n", i)
1166
+ if j == -1:
1167
+ out.append(code[i:]); break
1168
+ out.append(code[i:j]); i = j; continue
1169
+ if ch in ("'", '"'):
1170
+ quote = ch
1171
+ out.append(ch); i += 1
1172
+ else:
1173
+ if escaped:
1174
+ out.append(ch); escaped = False; i += 1; continue
1175
+ if ch == "\\":
1176
+ out.append(ch); escaped = True; i += 1; continue
1177
+ if ch == quote:
1178
+ out.append(ch); quote = None; i += 1; continue
1179
+ out.append({"\n": "\\n", "\r": "\\r", "\t": "\\t"}.get(ch, ch)); i += 1
1180
+ return "".join(out)
1181
+
1182
+
1183
+ def _repair_unterminated_string(code: str) -> str:
1184
+ """Rescue the 'unterminated string literal' failure weak models hit when they pass
1185
+ multi-line file content with RAW line breaks inside a normal "..." (create_file /
1186
+ edit_lines / edit_file). Escapes those newlines and returns the repaired code ONLY if
1187
+ it now compiles; otherwise returns the ORIGINAL unchanged (never yields non-parsing
1188
+ code, and never touches code that already compiles)."""
1189
+ try:
1190
+ compile(code, "<gonext-code>", "exec")
1191
+ return code # already valid — do nothing
1192
+ except SyntaxError as e:
1193
+ msg = (getattr(e, "msg", "") or "").lower()
1194
+ if "unterminated string literal" not in msg and "eol while scanning" not in msg:
1195
+ return code
1196
+ except Exception: # noqa: BLE001
1197
+ return code
1198
+ try:
1199
+ fixed = _escape_raw_newlines_in_strings(code)
1200
+ compile(fixed, "<gonext-code>", "exec")
1201
+ return fixed
1202
+ except Exception: # noqa: BLE001
1203
+ return code
1204
+
1205
+
1206
+ def _repair_code_block_strings(text: str) -> str:
1207
+ """Apply _repair_unterminated_string to the python inside a <code>…</code> block
1208
+ (leaving the surrounding Thought prose untouched). No-op when there's no block or the
1209
+ block already parses."""
1210
+ m = re.search(r"<code>([\s\S]*?)</code>", text)
1211
+ if not m:
1212
+ return text
1213
+ inner = m.group(1)
1214
+ fixed = _repair_unterminated_string(inner)
1215
+ if fixed == inner:
1216
+ return text
1217
+ return text[:m.start(1)] + fixed + text[m.end(1):]
1218
+
1219
+
1146
1220
  def _strip_think(text: str) -> str:
1147
1221
  """Remove Qwen3-style <think>…</think> reasoning traces from a model reply.
1148
1222
 
@@ -2178,6 +2252,57 @@ def _ws_listening_ports(pgid: int) -> list:
2178
2252
  return []
2179
2253
 
2180
2254
 
2255
+ def _ws_gui_pids(pgid: int) -> list:
2256
+ """pids in the group that OWN AN ON-SCREEN WINDOW — the behavioral definition of
2257
+ 'this command is a desktop/GUI app' (tkinter, pygame, PyQt…), the third outcome
2258
+ alongside 'exited' and 'listening on a port'. macOS only; empty on other platforms
2259
+ or when the signal is unavailable (safe no-op → falls back to the old wait/kill).
2260
+
2261
+ Detection is RUNTIME/behavior-based (what the process actually did), never a command
2262
+ name — consistent with _ws_listening_ports. Primary: Quartz's window list filtered by
2263
+ owner pid (definitive). Fallback: lsof for a mapped macOS windowing framework a process
2264
+ only loads once it has a real GUI (AppKit/HIToolbox/Tk) or a live WindowServer socket."""
2265
+ import sys
2266
+ if sys.platform != "darwin":
2267
+ return []
2268
+ pids = set(_ws_pgroup_pids(pgid))
2269
+ if not pids:
2270
+ return []
2271
+ # Primary: on-screen window ownership via Quartz (import-guarded — pyobjc may be absent).
2272
+ try:
2273
+ from Quartz import ( # type: ignore
2274
+ CGWindowListCopyWindowInfo,
2275
+ kCGWindowListOptionOnScreenOnly,
2276
+ kCGNullWindowID,
2277
+ kCGWindowOwnerPID,
2278
+ )
2279
+ info = CGWindowListCopyWindowInfo(
2280
+ kCGWindowListOptionOnScreenOnly, kCGNullWindowID) or []
2281
+ owners = {int(w.get(kCGWindowOwnerPID, 0)) for w in info}
2282
+ hit = sorted(pids & owners)
2283
+ if hit:
2284
+ return hit
2285
+ except Exception: # noqa: BLE001 — Quartz missing/failed → fall through to lsof
2286
+ pass
2287
+ # Fallback: a process that mapped the GUI stack (or holds a WindowServer connection).
2288
+ import subprocess
2289
+ try:
2290
+ out = subprocess.run(
2291
+ ["lsof", "-p", ",".join(map(str, sorted(pids)))],
2292
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=5)
2293
+ gui = set()
2294
+ for line in out.stdout.splitlines()[1:]:
2295
+ if ("AppKit.framework" in line or "HIToolbox.framework" in line
2296
+ or "/Tk.framework" in line or "WindowServer" in line):
2297
+ try:
2298
+ gui.add(int(line.split()[1]))
2299
+ except (ValueError, IndexError):
2300
+ pass
2301
+ return sorted(gui & pids)
2302
+ except Exception: # noqa: BLE001
2303
+ return []
2304
+
2305
+
2181
2306
  def _ws_servers_path() -> str:
2182
2307
  import os
2183
2308
  base = os.path.join(os.path.expanduser("~"), ".gonext")
@@ -2794,10 +2919,18 @@ def run_agent_chat(cfg):
2794
2919
  "replace a line range (backup saved automatically).\n"
2795
2920
  " - edit_file(path, old_string, new_string) — replace one exact unique string "
2796
2921
  "(must match byte-for-byte; prefer edit_lines).\n"
2797
- " - create_file(path, content) — create a NEW file.\n"
2922
+ " - create_file(path, content, overwrite=False) — create a file; pass "
2923
+ "overwrite=True to REPLACE an existing file in ONE call (don't read+edit_lines "
2924
+ "just to rewrite everything).\n"
2798
2925
  " - run_command(command, workdir='') — run a build/test command (npm test, mvn "
2799
- "test, pytest…) to VERIFY changes (only in workspaces with run permission).\n"
2926
+ "test, pytest…) to VERIFY changes, or start an app. A server/desktop-GUI app is "
2927
+ "left RUNNING (its window opens on the user's Mac); stop_server closes it. An "
2928
+ "interactive input() CLI can't be driven here (EOFError) — make it non-interactive "
2929
+ "or give the user the command.\n"
2800
2930
  " NOTE: plain `import os`/`open()` is blocked — use ONLY these tools for files.\n"
2931
+ " WRITING FILE CONTENT: multi-line content MUST use \\n for newlines (or a "
2932
+ "triple-quoted string). NEVER put a raw line break inside a normal \"...\" — it "
2933
+ "causes 'unterminated string literal'.\n"
2801
2934
  ) if _WS_AVAILABLE else ""
2802
2935
  _ws_choose_line = (
2803
2936
  "- user asks to FIX A BUG / MODIFY / ADD CODE in a workspace -> "
@@ -3659,7 +3792,14 @@ def run_agent_chat(cfg):
3659
3792
  normalized = _normalize_code_tags(content)
3660
3793
  if normalized != content:
3661
3794
  _log("normalized code-block tags (<code >/fence → <code>)")
3662
- msg.content = normalized
3795
+ # Facet 1: rescue multi-line file content passed with RAW newlines
3796
+ # inside a normal "..." ("unterminated string literal") — repair the
3797
+ # <code> block ONLY when it makes the snippet compile.
3798
+ repaired = _repair_code_block_strings(normalized)
3799
+ if repaired != normalized:
3800
+ _log("repaired unterminated string literal in tool-call code")
3801
+ if repaired != content:
3802
+ msg.content = repaired
3663
3803
  else:
3664
3804
  stripped = content.strip()
3665
3805
  looks_final = (
@@ -4282,31 +4422,38 @@ def run_agent_chat(cfg):
4282
4422
  return msg
4283
4423
 
4284
4424
  @tool
4285
- def create_file(path: str, content: str) -> str:
4286
- """Create a NEW file inside a registered workspace (errors if it already exists — use edit tools for existing files).
4425
+ def create_file(path: str, content: str, overwrite: bool = False) -> str:
4426
+ """Create a file inside a registered workspace, or REPLACE the whole file when overwrite=True. Pass overwrite=True to rewrite an existing file in one call (a backup is saved for revert) no need to read_file_lines + edit_lines just to replace everything. Multi-line content MUST use \\n for line breaks (or a triple-quoted string); a raw line break inside a normal "..." causes an 'unterminated string literal' error.
4287
4427
 
4288
4428
  Args:
4289
- path: new file path inside a workspace.
4429
+ path: file path inside a workspace.
4290
4430
  content: full file content.
4431
+ overwrite: replace the file if it already exists (default False = error on existing).
4291
4432
  """
4292
4433
  try:
4293
4434
  rp = _ws_write_allowed((path or "").strip())
4294
4435
  import os
4295
- if os.path.exists(rp):
4296
- msg = f"Error: {path} already exists — use edit_lines/edit_file to change it."
4436
+ existed = os.path.exists(rp)
4437
+ if existed and not overwrite:
4438
+ msg = (f"Error: {path} already exists — pass overwrite=True to replace the "
4439
+ "whole file, or use edit_lines/edit_file for a partial change.")
4297
4440
  _last_obs["text"] = msg
4298
4441
  return msg
4442
+ # Snapshot BEFORE overwriting so revert restores the prior version. Record as
4443
+ # an "edit" (not "create") when the file already existed, so revert restores
4444
+ # the backup instead of deleting a file the user may have had before.
4445
+ backup = _ws_snapshot(rp) if existed else ""
4299
4446
  os.makedirs(os.path.dirname(rp) or ".", exist_ok=True)
4300
4447
  with open(rp, "w", encoding="utf-8") as fh:
4301
4448
  fh.write(content)
4302
- _ws_record_change("create", rp, None)
4449
+ _ws_record_change("edit" if existed else "create", rp, backup or None)
4303
4450
  content_ls = content.splitlines() or [""]
4304
4451
  _emit({"type": "step", "text": _render_edit_card(
4305
- "Create", rp,
4306
- f"Added {len(content_ls)} line(s)",
4452
+ "Update" if existed else "Create", rp,
4453
+ ("Rewrote file — " if existed else "Added ") + f"{len(content_ls)} line(s)",
4307
4454
  added=[(i + 1, t) for i, t in enumerate(content_ls)],
4308
4455
  )})
4309
- out = f"Created {rp} ({len(content)} chars)."
4456
+ out = f"{'Overwrote' if existed else 'Created'} {rp} ({len(content)} chars)."
4310
4457
  _last_obs["text"] = out
4311
4458
  return out
4312
4459
  except Exception as ex: # noqa: BLE001
@@ -4349,7 +4496,7 @@ def run_agent_chat(cfg):
4349
4496
 
4350
4497
  @tool
4351
4498
  def run_command(command: str, workdir: str = "", timeout_seconds: int = 180) -> str:
4352
- """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.
4499
+ """Run a command inside a workspace that has run permission (e.g. 'npm test', 'npm run build', 'npm start', 'mvn test', 'python3 app.py'). 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 and its URL is reported. If it is a DESKTOP/GUI app (opens a window tkinter, pygame, PyQt…), it is LAUNCHED and left running: its window appears on the user's Mac; tell them it's open. Both are stoppable via stop_server. Note: an interactive CLI that reads stdin (input()) cannot be driven here — it will fail with EOFError; make it non-interactive or give the user the command to run themselves.
4353
4500
 
4354
4501
  Args:
4355
4502
  command: the command line (allowlisted runners only; no shells or sudo).
@@ -4436,6 +4583,29 @@ def run_agent_chat(cfg):
4436
4583
  f"Startup output:\n{tail}")
4437
4584
  _last_obs["text"] = result
4438
4585
  return result
4586
+ # Not a server, but still alive AND it OPENED A WINDOW → a desktop
4587
+ # app. Launch-and-leave like a server (a GUI never exits and opens
4588
+ # no port, so it would otherwise hit the timeout kill below and its
4589
+ # window would be destroyed). Returning fast here also dodges the
4590
+ # smolagents 60s executor cap that was killing the tool call.
4591
+ if _ws_gui_pids(proc.pid):
4592
+ servers = _ws_load_servers()
4593
+ servers.append({
4594
+ "pgid": proc.pid, "pid": proc.pid, "command": command,
4595
+ "workdir": wd, "ports": [], "kind": "gui", "log": log_path,
4596
+ "started_at": _t.strftime("%Y-%m-%d %H:%M:%S"),
4597
+ })
4598
+ _ws_save_servers(servers)
4599
+ _emit({"type": "step",
4600
+ "text": f"App launched → window open (left running)"})
4601
+ tail = _ws_distill_output(_read_log())
4602
+ result = (f"LAUNCHED: '{command}' — its window should be open on "
4603
+ f"the user's Mac now (pid {proc.pid}). It keeps running "
4604
+ "in the background; use stop_server (or just close the "
4605
+ "window) to quit."
4606
+ + (f"\nStartup output:\n{tail}" if tail.strip() else ""))
4607
+ _last_obs["text"] = result
4608
+ return result
4439
4609
  if _t.time() - start >= t:
4440
4610
  try:
4441
4611
  os.killpg(proc.pid, signal.SIGKILL)
@@ -4459,7 +4629,7 @@ def run_agent_chat(cfg):
4459
4629
 
4460
4630
  @tool
4461
4631
  def stop_server(port_or_pid: str = "") -> str:
4462
- """Stop a background server previously started by run_command. Use when the user asks to stop/kill the server.
4632
+ """Stop a background server OR desktop app previously started by run_command (closes its window). Use when the user asks to stop/kill/close it.
4463
4633
 
4464
4634
  Args:
4465
4635
  port_or_pid: the server's port or pid (empty = the most recently started one).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.214",
3
+ "version": "1.0.216",
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",