@tiens.nguyen/gonext-local-worker 1.0.197 → 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.
Files changed (2) hide show
  1. package/gonext_agent_chat.py +226 -22
  2. package/package.json +1 -1
@@ -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.\n"
837
- "Answer NO only if it is pure conversation, opinion, or simple text the "
838
- "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."
839
861
  )},
840
862
  {"role": "user", "content": (
841
- 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"
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
- 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
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 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.
4158
4255
 
4159
4256
  Args:
4160
- command: the command line (build/test runners only; no shells or sudo).
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: 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).
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
- # Scrubbed env: repo test scripts must never see worker secrets.
4188
- proc = subprocess.run(
4189
- argv, cwd=wd, env=_ws_scrubbed_env(), timeout=t,
4190
- 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,
4191
4299
  )
4192
- out = _ws_distill_output(proc.stdout or "")
4193
- status = "PASSED (exit 0)" if proc.returncode == 0 else f"FAILED (exit {proc.returncode})"
4194
- _emit({"type": "step", "text": f"Command {status.split()[0].lower()} {command[:50]}"})
4195
- result = f"{status}\n{out}" if out.strip() else status
4196
- _last_obs["text"] = result
4197
- return result
4198
- except subprocess.TimeoutExpired:
4199
- 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}"
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: run_command failed: {type(ex).__name__}: {ex}"
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.197",
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",