@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.
- package/gonext-local-worker.mjs +13 -40
- package/gonext_agent_chat.py +184 -14
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -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.
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
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}`, {
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
4296
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|