@tiens.nguyen/gonext-local-worker 1.0.197 → 1.0.199
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-repl.mjs +84 -28
- package/gonext_agent_chat.py +226 -22
- package/package.json +1 -1
package/gonext-repl.mjs
CHANGED
|
@@ -54,11 +54,16 @@ const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
|
|
|
54
54
|
// also drop the worker's ~~~ stream fences (they only matter to the web renderer).
|
|
55
55
|
const isHeartbeatLine = (line) => /…\s?\(\d+s\)\s*$/.test(line);
|
|
56
56
|
const isFenceLine = (line) => line.trim() === "~~~";
|
|
57
|
-
// The model's raw Python code blob is delimited by <code>/</code>
|
|
58
|
-
// the
|
|
57
|
+
// The model's raw Python code blob is delimited by <code>/</code> tags. We don't print
|
|
58
|
+
// the tags themselves, but the code IN BETWEEN prints in a distinct grey (codeColor)
|
|
59
59
|
// instead of dim prose, so it visually separates from the "Thought:" reasoning.
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// Tolerant matching, NOT exact-line: the raw token stream often mangles the tags —
|
|
61
|
+
// glued to content ("<codecreate_folder(...)"), missing the ">", duplicated, or the
|
|
62
|
+
// close tag stuck on the code's last line — and exact matching printed those
|
|
63
|
+
// fragments literally in mixed colors (user-reported).
|
|
64
|
+
const CODE_OPEN_RE = /^<code(?:\s*>)?/; // applied to line.trim()
|
|
65
|
+
const CODE_CLOSE_RE = /<\/code>?\s*$/;
|
|
66
|
+
const isBareCloseTag = (t) => /^<\/code>?$/.test(t);
|
|
62
67
|
// The step summary the worker emits right after a tool call finishes (e.g.
|
|
63
68
|
// "unzip_file(...) | → Unzipped 256 files…") duplicates what's already visible in the
|
|
64
69
|
// gonext-local-worker daemon's own terminal — swallow it here to keep the REPL terse.
|
|
@@ -142,16 +147,19 @@ if (!workerKey || !apiBase) {
|
|
|
142
147
|
// for piped/scripted input, harmless for interactive TTYs. Both ask() and the main
|
|
143
148
|
// loop consume from this queue (never rl.question, to avoid double-capture).
|
|
144
149
|
//
|
|
145
|
-
// terminal:
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
const
|
|
150
|
+
// terminal: true (on real TTYs) with readline OWNING the prompt via setPrompt/prompt().
|
|
151
|
+
// History of this setting: plain terminal:true with a MANUALLY-written ">> " once caused
|
|
152
|
+
// a stray "[" artifact — readline redrew the input line using its own (empty, never-set)
|
|
153
|
+
// prompt and cursor tracking that our un-announced writes had made stale. The interim
|
|
154
|
+
// fix (terminal:false) traded that for something worse: the OS's canonical line
|
|
155
|
+
// discipline echoes raw CSI bytes, so arrows/Home/End showed literal "^[[D" and mid-line
|
|
156
|
+
// editing didn't work at all (user-reported). Giving readline the prompt makes its
|
|
157
|
+
// redraw math correct — rl.prompt() resets the cursor state after every burst of our
|
|
158
|
+
// output — and raw mode means backspace/arrows/↑-history all behave like a normal shell.
|
|
159
|
+
const IS_TTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
160
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: IS_TTY });
|
|
161
|
+
const PROMPT = cyan(">> ");
|
|
162
|
+
rl.setPrompt(PROMPT);
|
|
155
163
|
// Side effect of terminal:false above: readline no longer intercepts arrow keys for
|
|
156
164
|
// command-history recall, so pressing ↑/↓/→/← (or Home/End/Delete) sends the RAW escape
|
|
157
165
|
// bytes straight through as literal input — the OS's own canonical line discipline
|
|
@@ -189,8 +197,13 @@ const nextLine = () =>
|
|
|
189
197
|
? Promise.resolve(null)
|
|
190
198
|
: new Promise((res) => (_lineWaiter = res));
|
|
191
199
|
const ask = async (q) => {
|
|
192
|
-
|
|
200
|
+
// Route the question through readline's own prompt so its redraw logic stays
|
|
201
|
+
// consistent (a raw stdout write here would re-create the stale-cursor artifact
|
|
202
|
+
// that terminal:true mode is designed to avoid). Restored to ">> " afterwards.
|
|
203
|
+
rl.setPrompt(q);
|
|
204
|
+
rl.prompt();
|
|
193
205
|
const a = await nextLine();
|
|
206
|
+
rl.setPrompt(PROMPT);
|
|
194
207
|
return (a ?? "").trim();
|
|
195
208
|
};
|
|
196
209
|
|
|
@@ -510,14 +523,48 @@ async function runAgentTurn(history) {
|
|
|
510
523
|
}
|
|
511
524
|
inEditCard = false; // card ended — fall through to normal handling
|
|
512
525
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
526
|
+
{
|
|
527
|
+
const t = line.trim();
|
|
528
|
+
const openM = !inCode || t.startsWith("<code") ? CODE_OPEN_RE.exec(t) : null;
|
|
529
|
+
if (openM) {
|
|
530
|
+
// Enter code mode (or stay in it on a duplicate tag). Anything glued after
|
|
531
|
+
// the tag on the same line is code — print it, minus a glued close tag.
|
|
532
|
+
stampIfThinking(); // hold/refresh the stamp; the code body commits it below
|
|
533
|
+
inCode = true;
|
|
534
|
+
let rest = t.slice(openM[0].length).trim();
|
|
535
|
+
if (CODE_CLOSE_RE.test(rest)) {
|
|
536
|
+
inCode = false;
|
|
537
|
+
rest = rest.replace(CODE_CLOSE_RE, "").trim();
|
|
538
|
+
}
|
|
539
|
+
if (rest) {
|
|
540
|
+
onContent();
|
|
541
|
+
process.stdout.write(codeColor(rest) + "\n");
|
|
542
|
+
lastWasBlank = false;
|
|
543
|
+
}
|
|
544
|
+
lastContentAt = Date.now();
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (!inCode && isBareCloseTag(t)) {
|
|
548
|
+
// Stray close tag with no open in sight — swallow, never show it.
|
|
549
|
+
lastContentAt = Date.now();
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
517
552
|
}
|
|
518
553
|
if (inCode) {
|
|
519
|
-
|
|
520
|
-
if (
|
|
554
|
+
const t = line.trim();
|
|
555
|
+
if (CODE_CLOSE_RE.test(t)) {
|
|
556
|
+
// Close tag, possibly with the code's last line glued in front of it.
|
|
557
|
+
const before = t.replace(CODE_CLOSE_RE, "").trim();
|
|
558
|
+
if (before) {
|
|
559
|
+
onContent();
|
|
560
|
+
process.stdout.write(codeColor(before) + "\n");
|
|
561
|
+
lastWasBlank = false;
|
|
562
|
+
}
|
|
563
|
+
inCode = false;
|
|
564
|
+
lastContentAt = Date.now();
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (t === "") { lastContentAt = Date.now(); continue; } // skip blank lines in code
|
|
521
568
|
onContent();
|
|
522
569
|
process.stdout.write(codeColor(line) + "\n");
|
|
523
570
|
lastWasBlank = false;
|
|
@@ -637,20 +684,29 @@ async function main() {
|
|
|
637
684
|
dim(`Resumed session: ${turns} prior turn(s) from last time in this folder — /reset to start fresh.\n`)
|
|
638
685
|
);
|
|
639
686
|
}
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
process.
|
|
687
|
+
// Ctrl-C arrives on ONE of two paths depending on the readline mode: with
|
|
688
|
+
// terminal:true (real TTY) readline's raw-mode byte scanning intercepts \x03 and
|
|
689
|
+
// emits rl "SIGINT" (no OS signal is delivered); with terminal:false (piped input)
|
|
690
|
+
// the OS delivers a real SIGINT to the process. Attach the same handler to both —
|
|
691
|
+
// only one can ever fire for a given mode, so there's no double-handling.
|
|
692
|
+
const onInterrupt = () => {
|
|
644
693
|
if (following) {
|
|
645
694
|
followAborted = true;
|
|
646
695
|
process.stdout.write(red("\n^C "));
|
|
647
696
|
} else {
|
|
648
|
-
process.stdout.write(dim("\n(/exit to quit)\n")
|
|
697
|
+
process.stdout.write(dim("\n(/exit to quit)\n"));
|
|
698
|
+
// Discard anything half-typed (Ctrl-C at a prompt means "never mind") and
|
|
699
|
+
// redraw through readline so its cursor tracking stays right.
|
|
700
|
+
rl.line = "";
|
|
701
|
+
rl.cursor = 0;
|
|
702
|
+
rl.prompt();
|
|
649
703
|
}
|
|
650
|
-
}
|
|
704
|
+
};
|
|
705
|
+
process.on("SIGINT", onInterrupt);
|
|
706
|
+
rl.on("SIGINT", onInterrupt);
|
|
651
707
|
|
|
652
708
|
for (;;) {
|
|
653
|
-
|
|
709
|
+
rl.prompt();
|
|
654
710
|
// Absorb blank Enter-presses SILENTLY under the same prompt — without this, lines
|
|
655
711
|
// typed while the agent was busy on a long turn (queued, not yet read) all drain at
|
|
656
712
|
// once the moment the turn ends, each re-printing ">> " with no newline between them
|
package/gonext_agent_chat.py
CHANGED
|
@@ -610,6 +610,13 @@ _WS_ACTION_RE = re.compile(
|
|
|
610
610
|
r"\b(?:create|make|add|new|delete|remove|rename|move|copy|list|show)\b"
|
|
611
611
|
r"[^.?!]{0,60}?"
|
|
612
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"
|
|
613
620
|
r"|\bmkdir\b",
|
|
614
621
|
re.IGNORECASE,
|
|
615
622
|
)
|
|
@@ -825,6 +832,20 @@ def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
|
|
|
825
832
|
from openai import OpenAI
|
|
826
833
|
client = OpenAI(base_url=base_url, api_key=api_key or "local",
|
|
827
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 ""
|
|
828
849
|
resp = _chat_create(
|
|
829
850
|
client,
|
|
830
851
|
model=model_id,
|
|
@@ -833,12 +854,13 @@ def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
|
|
|
833
854
|
"You are a task classifier. Reply YES or NO only, no punctuation.\n"
|
|
834
855
|
"Answer YES if the task requires fetching data from an external network source "
|
|
835
856
|
"(URL, API, website, remote server), a web search / factual lookup, or the "
|
|
836
|
-
"current date or time
|
|
837
|
-
|
|
838
|
-
"
|
|
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."
|
|
839
861
|
)},
|
|
840
862
|
{"role": "user", "content": (
|
|
841
|
-
f"Does this task require
|
|
863
|
+
f"Does this task require using tools (network{ws_q})?\n\n"
|
|
842
864
|
f"Task: {task_text}\n\nYES or NO:"
|
|
843
865
|
)},
|
|
844
866
|
],
|
|
@@ -2064,6 +2086,77 @@ _WS_RUN_ALLOWED = {
|
|
|
2064
2086
|
"rspec", "bundle", "php", "composer", "tsc", "jest", "vitest",
|
|
2065
2087
|
}
|
|
2066
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
|
+
|
|
2067
2160
|
|
|
2068
2161
|
def _ws_scrubbed_env() -> dict:
|
|
2069
2162
|
"""Minimal child env for run_command. The worker process env holds secrets
|
|
@@ -2072,7 +2165,11 @@ def _ws_scrubbed_env() -> dict:
|
|
|
2072
2165
|
import os
|
|
2073
2166
|
keep = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SHELL", "USER", "TERM",
|
|
2074
2167
|
"JAVA_HOME", "GOPATH", "CARGO_HOME", "NVM_DIR")
|
|
2075
|
-
|
|
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
|
|
2076
2173
|
|
|
2077
2174
|
|
|
2078
2175
|
def _ws_distill_output(out: str, cap_head: int = 1200, cap_tail: int = 4000) -> str:
|
|
@@ -4154,17 +4251,19 @@ def run_agent_chat(cfg):
|
|
|
4154
4251
|
|
|
4155
4252
|
@tool
|
|
4156
4253
|
def run_command(command: str, workdir: str = "", timeout_seconds: int = 180) -> str:
|
|
4157
|
-
"""Run a
|
|
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.
|
|
4158
4255
|
|
|
4159
4256
|
Args:
|
|
4160
|
-
command: the command line (
|
|
4257
|
+
command: the command line (allowlisted runners only; no shells or sudo).
|
|
4161
4258
|
workdir: directory to run in. Empty = first registered workspace.
|
|
4162
|
-
timeout_seconds:
|
|
4259
|
+
timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
|
|
4163
4260
|
"""
|
|
4164
4261
|
try:
|
|
4165
4262
|
import os
|
|
4166
4263
|
import shlex
|
|
4264
|
+
import signal
|
|
4167
4265
|
import subprocess
|
|
4266
|
+
import time as _t
|
|
4168
4267
|
wd = _rag_read_allowed((workdir or "").strip() or (_WS_ROOTS[0]["path"] if _WS_ROOTS else "."))
|
|
4169
4268
|
w = _ws_for_path(wd)
|
|
4170
4269
|
if w is None or not w.get("allowRun"):
|
|
@@ -4184,23 +4283,128 @@ def run_agent_chat(cfg):
|
|
|
4184
4283
|
return msg
|
|
4185
4284
|
_emit({"type": "step", "text": f"Running → {command[:70]}"})
|
|
4186
4285
|
t = max(5, min(int(timeout_seconds or 180), 600))
|
|
4187
|
-
#
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
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,
|
|
4191
4299
|
)
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
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}"
|
|
4200
4354
|
_last_obs["text"] = msg
|
|
4201
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
|
|
4202
4406
|
except Exception as ex: # noqa: BLE001
|
|
4203
|
-
msg = f"Error:
|
|
4407
|
+
msg = f"Error: stop_server failed: {type(ex).__name__}: {ex}"
|
|
4204
4408
|
_last_obs["text"] = msg
|
|
4205
4409
|
return msg
|
|
4206
4410
|
|
|
@@ -4218,7 +4422,7 @@ def run_agent_chat(cfg):
|
|
|
4218
4422
|
rag_index, rag_add, rag_search]
|
|
4219
4423
|
if _WS_AVAILABLE:
|
|
4220
4424
|
agent_tools += [grep_repo, read_file_lines, edit_lines, edit_file,
|
|
4221
|
-
create_file, create_folder, run_command]
|
|
4425
|
+
create_file, create_folder, run_command, stop_server]
|
|
4222
4426
|
# list_dir/read_text_file are needed for workspace browsing even when RAG
|
|
4223
4427
|
# (S3 indexing) isn't configured.
|
|
4224
4428
|
if not _RAG_AVAILABLE:
|
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.199",
|
|
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",
|