@tiens.nguyen/gonext-local-worker 1.0.205 → 1.0.207

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.
@@ -2020,6 +2020,71 @@ function normalizeBaseUrl(raw) {
2020
2020
  return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
2021
2021
  }
2022
2022
 
2023
+ /**
2024
+ * "Load Models" (task #44): probe the agent coding-model server's model list. Tries the
2025
+ * OpenAI-compatible /v1/models (MLX + Ollama both serve it) and falls back to Ollama's
2026
+ * /api/tags. The server is on the user's network, so only this worker can reach it. The
2027
+ * job's resultText is JSON {models:[...]} the web parses into checkboxes.
2028
+ */
2029
+ async function runListModelsJob(job) {
2030
+ const { jobId, payload } = job;
2031
+ const start = Date.now();
2032
+ const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
2033
+ method: "PATCH",
2034
+ body: JSON.stringify({ jobStatus: "running" }),
2035
+ });
2036
+ await ensureWorkerOk(runRes, `mark running list_models jobId=${jobId}`);
2037
+ try {
2038
+ const base = normalizeBaseUrl(payload?.url).replace(/\/v1$/i, "");
2039
+ if (!base) throw new Error("No coding-model URL provided.");
2040
+ const models = new Set();
2041
+ // 1) OpenAI-compatible /v1/models → { data: [{ id }] }
2042
+ try {
2043
+ const r = await fetch(`${base}/v1/models`, { method: "GET" });
2044
+ if (r.ok) {
2045
+ const j = await r.json().catch(() => ({}));
2046
+ for (const m of Array.isArray(j?.data) ? j.data : []) {
2047
+ if (typeof m?.id === "string" && m.id.trim()) models.add(m.id.trim());
2048
+ }
2049
+ }
2050
+ } catch {
2051
+ /* try the Ollama shape next */
2052
+ }
2053
+ // 2) Ollama /api/tags → { models: [{ name }] }
2054
+ if (models.size === 0) {
2055
+ const r = await fetch(`${base}/api/tags`, { method: "GET" });
2056
+ if (r.ok) {
2057
+ const j = await r.json().catch(() => ({}));
2058
+ for (const m of Array.isArray(j?.models) ? j.models : []) {
2059
+ if (typeof m?.name === "string" && m.name.trim()) models.add(m.name.trim());
2060
+ }
2061
+ }
2062
+ }
2063
+ const list = Array.from(models).sort();
2064
+ const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
2065
+ method: "PATCH",
2066
+ body: JSON.stringify({
2067
+ jobStatus: "completed",
2068
+ resultText: JSON.stringify({ models: list }),
2069
+ totalTimeSeconds: (Date.now() - start) / 1000,
2070
+ }),
2071
+ });
2072
+ await ensureWorkerOk(doneRes, `complete list_models jobId=${jobId}`);
2073
+ console.log(`[gonext-worker] list_models ${jobId}: ${list.length} model(s) from ${base}`);
2074
+ } catch (e) {
2075
+ const message = e instanceof Error ? e.message : String(e);
2076
+ await workerFetch(`/api/worker/jobs/${jobId}`, {
2077
+ method: "PATCH",
2078
+ body: JSON.stringify({
2079
+ jobStatus: "failed",
2080
+ errorMessage: `Could not list models: ${message}`,
2081
+ totalTimeSeconds: (Date.now() - start) / 1000,
2082
+ }),
2083
+ }).catch(() => {});
2084
+ console.error(`[gonext-worker] failed list_models ${jobId}:`, message);
2085
+ }
2086
+ }
2087
+
2023
2088
  function normalizeOpenAiV1Root(raw) {
2024
2089
  const base = normalizeBaseUrl(raw);
2025
2090
  if (!base) return "";
@@ -2601,6 +2666,10 @@ async function pollOnce() {
2601
2666
  await runHttpProbeJob(job);
2602
2667
  return;
2603
2668
  }
2669
+ if (job.jobType === "list_models") {
2670
+ await runListModelsJob(job);
2671
+ return;
2672
+ }
2604
2673
  if (job.jobType === "agent_chat") {
2605
2674
  await runAgentChatJob(job);
2606
2675
  return;
package/gonext-repl.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  * interrupted investigation actually resumes it). /reset clears it.
22
22
  *
23
23
  * Requires the worker daemon to be running (it claims the job).
24
- * Commands: /exit /reset /revert [runId] /help Ctrl-C stops following the current run.
24
+ * Commands: /exit /model /reset /revert [runId] /help Ctrl-C stops following the current run.
25
25
  */
26
26
  import { readFile, mkdir, writeFile, unlink } from "node:fs/promises";
27
27
  import { existsSync, statSync, readFileSync } from "node:fs";
@@ -119,7 +119,7 @@ if (argv.includes("--help") || argv.includes("-h")) {
119
119
  " -v, --verbose show the agent's diagnostic logs ([gonext-agent] …) and python stderr\n" +
120
120
  " -h, --help this help\n\n" +
121
121
  "Auth comes from ~/.gonext/worker.env; models/budgets/RAG come from your web Settings.\n" +
122
- "Inside the REPL: /exit · /reset · /revert [runId] · /help — Ctrl-C stops following a turn.\n" +
122
+ "Inside the REPL: /exit · /model · /reset · /revert [runId] · /help — Ctrl-C stops following a turn.\n" +
123
123
  "Questions are enqueued like web questions and executed by the RUNNING\n" +
124
124
  "gonext-local-worker daemon — its terminal shows the full [gonext-agent] logs.\n" +
125
125
  "Conversation history is saved per-folder in ~/.gonext/sessions and resumed next time\n" +
@@ -172,6 +172,14 @@ rl.setPrompt(PROMPT);
172
172
  // final-byte) from every submitted line so stray arrow-key presses can't corrupt task
173
173
  // text sent to the model.
174
174
  const stripAnsiEscapes = (s) => String(s ?? "").replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "");
175
+ // A fetch() failure (DNS, connection reset, timeout) throws rather than returning a
176
+ // response — recognize it so the poll loop can treat it as a transient blip, not a hard
177
+ // stop. Node's fetch throws a TypeError ("fetch failed") wrapping the socket error.
178
+ const isNetworkError = (e) =>
179
+ e instanceof TypeError ||
180
+ /fetch failed|network|ECONN|ETIMEDOUT|EAI_AGAIN|socket hang up|und_err|terminated/i.test(
181
+ String(e?.message ?? e)
182
+ );
175
183
  const _lineQueue = [];
176
184
  let _lineWaiter = null;
177
185
  let _stdinClosed = false;
@@ -193,6 +201,19 @@ rl.on("line", (l) => {
193
201
  _lineQueue.push(clean);
194
202
  }
195
203
  });
204
+ // Neutralize navigation keys (↑/↓ history recall, ←/→ cursor, Home/End, etc.) during a
205
+ // turn: echo is already muted, but readline still recalls history into the invisible
206
+ // buffer on ↑/↓ — reset the line state after every keypress so nothing accumulates or
207
+ // moves. Ctrl+C is unaffected (readline emits its SIGINT before this handler runs).
208
+ if (process.stdin.isTTY) {
209
+ process.stdin.on("keypress", () => {
210
+ if (following) {
211
+ rl.line = "";
212
+ rl.cursor = 0;
213
+ rl.historyIndex = -1;
214
+ }
215
+ });
216
+ }
196
217
  rl.on("close", () => {
197
218
  _stdinClosed = true;
198
219
  if (_lineWaiter) {
@@ -246,8 +267,20 @@ async function ensureWorkspace() {
246
267
  const list = await readWorkspaces();
247
268
  const hit = list.find((w) => cwd === w.path || cwd.startsWith(w.path + "/"));
248
269
  if (hit) {
270
+ // Registered before cloud sync existed → ask once and persist. Otherwise honor the
271
+ // stored choice. (undefined = old record that never opted in → ask.)
272
+ if (hit.allowSync === undefined) {
273
+ hit.allowSync = await askYesNo(
274
+ "Allow syncing this workspace's chat history to the cloud?", true
275
+ );
276
+ await writeFile(WS_FILE, JSON.stringify({ workspaces: list }, null, 2) + "\n");
277
+ }
278
+ wsSync = { enabled: hit.allowSync !== false, name: hit.name, path: hit.path };
249
279
  console.log(
250
- dim(`workspace: ${hit.name} → ${hit.path} (run ${hit.allowRun ? "enabled" : "disabled"})`)
280
+ dim(
281
+ `workspace: ${hit.name} → ${hit.path} (run ${hit.allowRun ? "enabled" : "disabled"}` +
282
+ `${hit.allowSync !== false ? ", cloud sync on" : ""})`
283
+ )
251
284
  );
252
285
  return;
253
286
  }
@@ -268,18 +301,26 @@ async function ensureWorkspace() {
268
301
  still allowlisted (npm/mvn/pytest/…) and run with a scrubbed env. Type n to
269
302
  keep a workspace read/edit-only. */
270
303
  );
304
+ const allowSync = await askYesNo(
305
+ "Allow syncing this workspace's chat history to the cloud?\n" +
306
+ "(your conversation is saved to your account so you can see it on the web app)",
307
+ true /* default Yes — RAG is NOT synced (it stays on your S3); only chat history. */
308
+ );
271
309
  const next = list.filter((w) => w.path !== cwd);
272
310
  next.push({
273
311
  name: basename(cwd),
274
312
  path: cwd,
275
313
  allowRun,
314
+ allowSync,
276
315
  addedAt: new Date().toISOString(),
277
316
  });
278
317
  await mkdir(dirname(WS_FILE), { recursive: true });
279
318
  await writeFile(WS_FILE, JSON.stringify({ workspaces: next }, null, 2) + "\n");
319
+ wsSync = { enabled: allowSync, name: basename(cwd), path: cwd };
280
320
  console.log(
281
321
  green(`✓ workspace registered: ${basename(cwd)}`) +
282
- (allowRun ? green(" (run-commands enabled)") : dim(" (run-commands disabled)"))
322
+ (allowRun ? green(" (run-commands enabled)") : dim(" (run-commands disabled)")) +
323
+ (allowSync ? green(" · cloud sync on") : dim(" · cloud sync off"))
283
324
  );
284
325
  }
285
326
 
@@ -302,6 +343,34 @@ function sessionFilePath(cwd) {
302
343
  // for a long-lived folder with no benefit.
303
344
  const MAX_PERSISTED_MESSAGES = 40;
304
345
 
346
+ // Cloud sync (task #47), opt-in per workspace. Set by ensureWorkspace() for the current
347
+ // folder; when enabled, saveSession/clearSession also mirror the chat history to Mongo
348
+ // via the worker-authed /api/worker/workspace-sync. RAG is NOT synced (stays on S3).
349
+ let wsSync = { enabled: false, name: "", path: "" };
350
+ const workspaceKeyFor = (cwd) =>
351
+ createHash("sha256").update(cwd).digest("hex").slice(0, 32);
352
+
353
+ async function pushWorkspaceSync(cwd, history, { del = false } = {}) {
354
+ if (!wsSync.enabled) return;
355
+ const body = del
356
+ ? { workspaceKey: workspaceKeyFor(cwd), delete: true }
357
+ : {
358
+ workspaceKey: workspaceKeyFor(cwd),
359
+ name: wsSync.name || basename(cwd),
360
+ path: cwd,
361
+ history: history.slice(-MAX_PERSISTED_MESSAGES),
362
+ };
363
+ try {
364
+ await fetch(`${apiBase}/api/worker/workspace-sync`, {
365
+ method: "POST",
366
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
367
+ body: JSON.stringify(body),
368
+ });
369
+ } catch {
370
+ // Best-effort — a cloud-sync blip must never block the REPL (local save already done).
371
+ }
372
+ }
373
+
305
374
  async function loadSession(cwd) {
306
375
  try {
307
376
  const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
@@ -327,6 +396,8 @@ async function saveSession(cwd, history) {
327
396
  // Non-fatal — losing session persistence should never crash or block the REPL.
328
397
  console.error(dim(`(session save failed: ${err.message})`));
329
398
  }
399
+ // Mirror to the cloud AFTER the local write (fire-and-forget; opt-in via wsSync).
400
+ void pushWorkspaceSync(cwd, history);
330
401
  }
331
402
 
332
403
  async function clearSession(cwd) {
@@ -335,9 +406,57 @@ async function clearSession(cwd) {
335
406
  } catch {
336
407
  // Nothing on disk to clear — fine.
337
408
  }
409
+ void pushWorkspaceSync(cwd, [], { del: true }); // /reset removes the cloud copy too
338
410
  }
339
411
 
340
412
  // ---------- fetch the ready-to-run agent payload from user settings ----------
413
+ // /model command: fetch the allowed coding models (default + web-curated whitelist),
414
+ // show a numbered picker, and set sessionCodingModel to the choice for this session.
415
+ async function chooseModel() {
416
+ let probe;
417
+ try {
418
+ probe = await fetchAgentPayload([{ role: "user", content: "ping" }]);
419
+ } catch (err) {
420
+ console.log(red(` couldn't load models: ${err.message}\n`));
421
+ return;
422
+ }
423
+ const allowed = Array.isArray(probe?.codingAllowed) ? probe.codingAllowed : [];
424
+ const def = (probe?.codingDefault ?? "").trim();
425
+ if (allowed.length === 0) {
426
+ console.log(dim(" No coding model configured. Set one in the web app → Settings → Agent.\n"));
427
+ return;
428
+ }
429
+ if (allowed.length === 1) {
430
+ console.log(
431
+ dim(` Only one coding model is available: ${allowed[0]}.\n`) +
432
+ dim(" Add more in the web app → Settings → Agent → Load Models (checkboxes).\n")
433
+ );
434
+ return;
435
+ }
436
+ const active = sessionCodingModel || def;
437
+ console.log(dim(" Choose the coding model for this session:"));
438
+ allowed.forEach((m, i) => {
439
+ const mark = m === active ? green(" ●") : " ";
440
+ const tag = m === def ? dim(" (default)") : "";
441
+ console.log(` ${mark} ${i + 1}. ${m}${tag}`);
442
+ });
443
+ const pick = (await ask(dim(" number (or Enter to keep current): "))).trim();
444
+ if (!pick) {
445
+ console.log("");
446
+ return;
447
+ }
448
+ const idx = Number.parseInt(pick, 10) - 1;
449
+ if (!Number.isInteger(idx) || idx < 0 || idx >= allowed.length) {
450
+ console.log(red(" invalid choice.\n"));
451
+ return;
452
+ }
453
+ const chosen = allowed[idx];
454
+ // Empty override means "use the account default" — so don't send an override when the
455
+ // user picks the default (keeps the payload clean and lets a later default change win).
456
+ sessionCodingModel = chosen === def ? "" : chosen;
457
+ console.log(green(` ✓ coding model → ${chosen}${chosen === def ? " (default)" : ""}\n`));
458
+ }
459
+
341
460
  async function fetchAgentPayload(messages) {
342
461
  const res = await fetch(`${apiBase}/api/worker/agent-payload`, {
343
462
  method: "POST",
@@ -370,6 +489,9 @@ let following = false;
370
489
  let followAborted = false;
371
490
  let currentJobId = null; // the running turn's jobId — so Ctrl+C can cancel it server-side
372
491
  let cancelRequested = false; // a cancel POST has been sent for the current turn
492
+ // Per-session coding-model override chosen via /model (empty = use the account default).
493
+ // Sent to /agent-ask as codingModelOverride; the API validates it against the whitelist.
494
+ let sessionCodingModel = "";
373
495
 
374
496
  // While a turn runs, mute readline's own echo/redraws so keystrokes can't corrupt the
375
497
  // live status display and there's no way to type a new question — only Ctrl+C works.
@@ -381,6 +503,15 @@ rl._writeToOutput = (s) => {
381
503
  if (_origWriteToOutput) _origWriteToOutput(s);
382
504
  else process.stdout.write(s);
383
505
  };
506
+ // _writeToOutput only mutes echoed TEXT; readline's line refresh writes cursor-position
507
+ // + erase escapes (e.g. ESC[1G, ESC[0J) straight to output on every keypress, which
508
+ // would corrupt our live status display. No-op it during a turn so a keypress produces
509
+ // ZERO terminal output.
510
+ const _origRefreshLine = rl._refreshLine ? rl._refreshLine.bind(rl) : null;
511
+ rl._refreshLine = () => {
512
+ if (following) return;
513
+ if (_origRefreshLine) _origRefreshLine();
514
+ };
384
515
 
385
516
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
386
517
 
@@ -397,8 +528,12 @@ async function runAgentTurn(history) {
397
528
  method: "POST",
398
529
  headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
399
530
  // cwd = this terminal's folder → agent puts download/unzip output here (when it's
400
- // a registered workspace).
401
- body: JSON.stringify({ messages: history, cwd: resolve(process.cwd()) }),
531
+ // a registered workspace). codingModelOverride = the /model choice for this session.
532
+ body: JSON.stringify({
533
+ messages: history,
534
+ cwd: resolve(process.cwd()),
535
+ ...(sessionCodingModel ? { codingModelOverride: sessionCodingModel } : {}),
536
+ }),
402
537
  });
403
538
  const data = await res.json().catch(() => ({}));
404
539
  if (!res.ok) {
@@ -421,6 +556,11 @@ async function runAgentTurn(history) {
421
556
  let carry = ""; // trailing partial content line held until its newline arrives
422
557
  let statusShown = false; // a transient status line is currently on screen
423
558
  let warnedPending = false;
559
+ // Transient poll failures (a network blip or a 5xx like the API's momentary "Worker
560
+ // key lookup failed." — a cold Mongo connection, NOT a bad key) must NOT kill a
561
+ // long-running turn. Tolerate a run of them; only give up after too many in a row.
562
+ let pollFailures = 0;
563
+ const MAX_POLL_FAILURES = 6; // ~6 tries with backoff before conceding the follow
424
564
  let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
425
565
  let answerShownLive = false; // plain-reply content already printed — don't re-print it
426
566
  // True once we've SPECIFICALLY seen "→ Chat reply" (the plain-reply/trivial-chat
@@ -673,11 +813,40 @@ async function runAgentTurn(history) {
673
813
  );
674
814
  return { text: "", shown: false };
675
815
  }
676
- const jr = await fetch(`${apiBase}/api/worker/jobs/${jobId}`, {
677
- headers: { "X-Worker-Key": workerKey },
678
- });
679
- const job = await jr.json().catch(() => ({}));
680
- if (!jr.ok) throw new Error(job?.error || `job poll failed (HTTP ${jr.status})`);
816
+ let job;
817
+ try {
818
+ const jr = await fetch(`${apiBase}/api/worker/jobs/${jobId}`, {
819
+ headers: { "X-Worker-Key": workerKey },
820
+ });
821
+ const parsed = await jr.json().catch(() => ({}));
822
+ if (jr.ok) {
823
+ job = parsed;
824
+ pollFailures = 0; // a clean poll resets the tolerance window
825
+ } else if (jr.status >= 400 && jr.status < 500) {
826
+ // Client error (bad/expired key, job genuinely gone) — NOT transient, fail fast.
827
+ throw new Error(parsed?.error || `job poll failed (HTTP ${jr.status})`);
828
+ } else {
829
+ // 5xx — a momentary server/DB blip (e.g. "Worker key lookup failed."). Transient.
830
+ throw Object.assign(
831
+ new Error(parsed?.error || `job poll failed (HTTP ${jr.status})`),
832
+ { transient: true }
833
+ );
834
+ }
835
+ } catch (err) {
836
+ // A fetch/network exception has no HTTP status — treat it as transient too.
837
+ const transient = err?.transient === true || isNetworkError(err);
838
+ if (!transient) throw err;
839
+ pollFailures += 1;
840
+ if (pollFailures >= MAX_POLL_FAILURES) {
841
+ clearStatus();
842
+ throw new Error(
843
+ `lost contact with the API after ${pollFailures} tries (${String(err.message).slice(0, 80)}). ` +
844
+ "The turn may still be running on the worker — try 'continue'."
845
+ );
846
+ }
847
+ await sleep(Math.min(700 * pollFailures, 3000)); // linear backoff, capped at 3s
848
+ continue;
849
+ }
681
850
  jobRunning = job.jobStatus === "running";
682
851
  const text = typeof job.resultText === "string" ? job.resultText : "";
683
852
  if (job.jobStatus === "completed") {
@@ -813,12 +982,17 @@ async function main() {
813
982
  if (line === "/help") {
814
983
  console.log(
815
984
  dim(
816
- " /exit — quit · /revert [runId] undo the agent's file edits (latest run) · " +
985
+ " /exit — quit · /modelswitch the coding model for this session · " +
986
+ "/revert [runId] — undo the agent's file edits (latest run) · " +
817
987
  "/reset — forget this folder's saved conversation and start fresh"
818
988
  )
819
989
  );
820
990
  continue;
821
991
  }
992
+ if (line === "/model") {
993
+ await chooseModel();
994
+ continue;
995
+ }
822
996
  if (line === "/reset" || line === "/new") {
823
997
  history.length = 0;
824
998
  await clearSession(cwd);
@@ -1891,6 +1891,19 @@ def _ws_for_path(path: str):
1891
1891
  return None
1892
1892
 
1893
1893
 
1894
+ def _default_ws_root() -> str:
1895
+ """The folder a tool defaults to when the model passes NO path/workdir: the terminal's
1896
+ ACTIVE folder (where `gonext` was launched), NOT the first-registered workspace.
1897
+ Registered workspaces accumulate across sessions, so falling back to _WS_ROOTS[0]
1898
+ made a no-workdir command land in an OLD workspace (bug #46: launched in t9, created
1899
+ the project in t1). Falls back to the first workspace only when there is no active
1900
+ folder at all."""
1901
+ import os
1902
+ if _WS_ACTIVE and os.path.isdir(_WS_ACTIVE):
1903
+ return _WS_ACTIVE
1904
+ return _WS_ROOTS[0]["path"] if _WS_ROOTS else "."
1905
+
1906
+
1894
1907
  def _workspace_overview(roots, max_entries: int = 40) -> str:
1895
1908
  """Cheap, LOCAL top-level listing of each registered workspace root — a plain
1896
1909
  os.listdir(), no recursion, no network call, no model call. Used two ways: (1)
@@ -2744,14 +2757,33 @@ def run_agent_chat(cfg):
2744
2757
  "If the user adds information, rag_add(source_url=url, text=...).\n"
2745
2758
  ) if _RAG_AVAILABLE else ""
2746
2759
  _ws_names = ", ".join(f"{w['name']} = {w['path']}" for w in _WS_ROOTS)
2760
+ # The folder the terminal is OPEN in (where `gonext` was launched). This is where the
2761
+ # user means "here"/"this workspace" and where a no-path/no-workdir tool call defaults
2762
+ # (see _default_ws_root). Registered workspaces accumulate over time, so WITHOUT this
2763
+ # the model would pick an arbitrary/old one (bug #46: opened in t9, built in t1).
2764
+ _active_ws = _ws_for_path(_WS_ACTIVE) if _WS_ACTIVE else None
2765
+ _active_label = (
2766
+ f"{_active_ws['name']} = {_WS_ACTIVE}" if _active_ws else (_WS_ACTIVE or "")
2767
+ )
2768
+ _ws_current_line = (
2769
+ f" CURRENT folder — the terminal is open HERE; default ALL create/edit/run/"
2770
+ f"download operations to THIS folder unless the user EXPLICITLY names another "
2771
+ f"workspace: {_active_label}\n"
2772
+ if _active_label else ""
2773
+ )
2747
2774
  # Upfront, ZERO-COST (no tool call, no model round-trip) top-level listing — so the
2748
2775
  # agent already knows the workspace is non-empty and roughly what's in it before its
2749
2776
  # first step, instead of having to spend (and risk losing to a timeout) a whole
2750
- # round-trip just calling list_dir to discover that.
2751
- _ws_overview = _workspace_overview(_WS_ROOTS) if _WS_AVAILABLE else ""
2777
+ # round-trip just calling list_dir to discover that. Only the CURRENT folder (not every
2778
+ # accumulated workspace) to keep the model anchored on where it should act.
2779
+ _overview_roots = (
2780
+ [_active_ws] if _active_ws else _WS_ROOTS
2781
+ ) if _WS_AVAILABLE else []
2782
+ _ws_overview = _workspace_overview(_overview_roots) if _overview_roots else ""
2752
2783
  _ws_tool_block = (
2753
- f" WORKSPACES (local code folders you may READ and EDIT): {_ws_names}\n"
2754
- f" Current contents:\n"
2784
+ _ws_current_line
2785
+ + f" Other registered workspaces (use only if the user names them): {_ws_names}\n"
2786
+ f" Current folder contents:\n"
2755
2787
  + "\n".join(f" {line}" for line in _ws_overview.splitlines()) + "\n"
2756
2788
  " - list_dir(path) / read_text_file(path) — browse and read files.\n"
2757
2789
  " - grep_repo(pattern, path='', glob='') — search code, returns file:line hits. "
@@ -4085,7 +4117,7 @@ def run_agent_chat(cfg):
4085
4117
  try:
4086
4118
  import fnmatch
4087
4119
  import os
4088
- root = _rag_read_allowed((path or "").strip() or (_WS_ROOTS[0]["path"] if _WS_ROOTS else "."))
4120
+ root = _rag_read_allowed((path or "").strip() or _default_ws_root())
4089
4121
  try:
4090
4122
  rx = re.compile(pattern)
4091
4123
  except re.error:
@@ -4330,7 +4362,7 @@ def run_agent_chat(cfg):
4330
4362
  import signal
4331
4363
  import subprocess
4332
4364
  import time as _t
4333
- wd = _rag_read_allowed((workdir or "").strip() or (_WS_ROOTS[0]["path"] if _WS_ROOTS else "."))
4365
+ wd = _rag_read_allowed((workdir or "").strip() or _default_ws_root())
4334
4366
  w = _ws_for_path(wd)
4335
4367
  if w is None or not w.get("allowRun"):
4336
4368
  msg = ("Error: running commands is not enabled for this workspace. "
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.205",
3
+ "version": "1.0.207",
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",