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

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.
@@ -867,7 +867,7 @@ async function performHttpMeasurement(params) {
867
867
  * line as they arrive. Resolves when the process exits cleanly; rejects on
868
868
  * non-zero exit or timeout. stderr is collected and appended to error messages.
869
869
  */
870
- function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine, extraEnv) {
870
+ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine, extraEnv, abortSignal) {
871
871
  return new Promise((resolve, reject) => {
872
872
  const spawnOpts = { stdio: ["pipe", "pipe", "pipe"] };
873
873
  if (extraEnv && Object.keys(extraEnv).length > 0) {
@@ -877,10 +877,23 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
877
877
  let stderr = "";
878
878
  let lineBuffer = "";
879
879
  let timedOut = false;
880
+ let aborted = false;
880
881
  const timer = setTimeout(() => {
881
882
  timedOut = true;
882
883
  child.kill("SIGKILL");
883
884
  }, timeoutMs);
885
+ // User-requested cancel (gonext Ctrl+C): kill the Python agent so it stops burning
886
+ // steps/tokens. On close we resolve cleanly (not reject) — the caller checks the
887
+ // cancel flag and leaves the job in its already-"cancelled" state.
888
+ const onAbort = () => {
889
+ if (aborted) return;
890
+ aborted = true;
891
+ child.kill("SIGKILL");
892
+ };
893
+ if (abortSignal) {
894
+ if (abortSignal.aborted) onAbort();
895
+ else abortSignal.addEventListener("abort", onAbort, { once: true });
896
+ }
884
897
  child.stdout.on("data", (d) => {
885
898
  lineBuffer += d.toString("utf8");
886
899
  const parts = lineBuffer.split("\n");
@@ -905,6 +918,7 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
905
918
  });
906
919
  child.on("close", (code) => {
907
920
  clearTimeout(timer);
921
+ if (abortSignal) abortSignal.removeEventListener?.("abort", onAbort);
908
922
  // Drain any remaining buffered line
909
923
  const remaining = lineBuffer.trim();
910
924
  if (remaining) {
@@ -914,6 +928,10 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
914
928
  /* ignore */
915
929
  }
916
930
  }
931
+ if (aborted) {
932
+ resolve(); // cancelled by the user — a clean stop, not an error
933
+ return;
934
+ }
917
935
  if (timedOut) {
918
936
  reject(new Error(`agent chat timed out after ${timeoutMs} ms`));
919
937
  return;
@@ -1686,6 +1704,12 @@ function trackCodingModelForWarmup(codingBaseURL, codingModelId) {
1686
1704
  async function runAgentChatJob(job) {
1687
1705
  const { jobId, payload } = job;
1688
1706
  const start = Date.now();
1707
+ // User-requested cancel (gonext Ctrl+C): the REPL marks the job "cancelled" via the
1708
+ // API; a poll below (and the job-chunk 409 path) trips this controller, which kills
1709
+ // the Python agent. `cancelled` is checked at the finish so we DON'T overwrite the
1710
+ // "cancelled" status with completed/failed.
1711
+ const cancelAc = new AbortController();
1712
+ let cancelled = false;
1689
1713
  // Remember this job's Ollama coding model and keep it resident via a background
1690
1714
  // interval. Only warms now if the model is new/changed — repeat questions rely on
1691
1715
  // the interval, so we don't re-warm (or re-log) on every question.
@@ -1713,8 +1737,12 @@ async function runAgentChatJob(job) {
1713
1737
  });
1714
1738
  if (!res.ok && res.status !== 204) {
1715
1739
  const snippet = (await res.text().catch(() => "")).trim().slice(0, 400);
1740
+ const cancelledNow =
1741
+ res.status === 409 && snippet.includes('"jobStatus":"cancelled"');
1742
+ if (cancelledNow) cancelAc.abort(); // REPL cancelled mid-run → stop the Python agent
1716
1743
  const benign409 =
1717
- res.status === 409 && snippet.includes('"jobStatus":"completed"');
1744
+ res.status === 409 &&
1745
+ (snippet.includes('"jobStatus":"completed"') || cancelledNow);
1718
1746
  if (!benign409) {
1719
1747
  console.error(
1720
1748
  `[gonext-worker] agent_chat job-chunk POST failed status=${res.status} jobId=${jobId}` +
@@ -1848,6 +1876,28 @@ async function runAgentChatJob(job) {
1848
1876
  pdfEnv = { DYLD_FALLBACK_LIBRARY_PATH: merged };
1849
1877
  }
1850
1878
 
1879
+ // Poll our own job status during the run so a REPL Ctrl+C (which marks the job
1880
+ // "cancelled" via the API) actually stops the Python agent within ~1.5s, instead of
1881
+ // only being noticed on the next chunk flush (which can be 45s apart during a long
1882
+ // model call). The job-chunk 409 path (flushChunks) is the backup signal.
1883
+ const cancelPoll = setInterval(async () => {
1884
+ if (cancelAc.signal.aborted) return;
1885
+ try {
1886
+ const r = await workerFetch(`/api/worker/jobs/${jobId}`);
1887
+ if (r.ok) {
1888
+ const j = await r.json().catch(() => ({}));
1889
+ if (j?.jobStatus === "cancelled") {
1890
+ cancelled = true;
1891
+ cancelAc.abort();
1892
+ }
1893
+ }
1894
+ } catch {
1895
+ /* transient poll error — try again next tick */
1896
+ }
1897
+ }, 1500);
1898
+ cancelAc.signal.addEventListener("abort", () => { cancelled = true; }, { once: true });
1899
+
1900
+ try {
1851
1901
  await runProcessWithStreamingStdout(python, [scriptPath], input, timeoutMs, (event) => {
1852
1902
  if (event.type === "log" && typeof event.text === "string") {
1853
1903
  console.log(`[gonext-agent] ${event.text}`);
@@ -1901,7 +1951,19 @@ async function runAgentChatJob(job) {
1901
1951
  enqueueText(event.text);
1902
1952
  }
1903
1953
  }
1904
- }, pdfEnv);
1954
+ }, pdfEnv, cancelAc.signal);
1955
+ } finally {
1956
+ clearInterval(cancelPoll);
1957
+ }
1958
+
1959
+ // User cancelled mid-run: the Python child was SIGKILLed and the job is already
1960
+ // "cancelled" in the store. Leave it there — do NOT PATCH completed/failed (which
1961
+ // would resurrect a finished status) and skip the final chunk flush (it'd 409).
1962
+ if (cancelled || cancelAc.signal.aborted) {
1963
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
1964
+ console.log(`[gonext-worker] agent_chat ${jobId} cancelled by user`);
1965
+ return;
1966
+ }
1905
1967
 
1906
1968
  closeStreamFence();
1907
1969
  if (inThink) {
@@ -1926,6 +1988,12 @@ async function runAgentChatJob(job) {
1926
1988
  console.log(`[gonext-worker] completed agent_chat ${jobId} (${totalTimeSeconds.toFixed(1)}s)`);
1927
1989
  } catch (e) {
1928
1990
  if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
1991
+ // A cancel that raced into the catch (e.g. the kill surfaced as an error before the
1992
+ // aborted-close path): the job is already "cancelled" — don't overwrite with failed.
1993
+ if (cancelled || cancelAc.signal.aborted) {
1994
+ console.log(`[gonext-worker] agent_chat ${jobId} cancelled by user`);
1995
+ return;
1996
+ }
1929
1997
  await flushTail;
1930
1998
  await flushChunks().catch(() => {});
1931
1999
  const message = e instanceof Error ? e.message : String(e);
package/gonext-repl.mjs CHANGED
@@ -43,11 +43,11 @@ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
43
43
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
44
44
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
45
45
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
46
+ const white = (s) => `\x1b[37m${s}\x1b[0m`;
46
47
  // ANSI 90 ("bright black") — a reliable neutral grey across terminal themes, unlike
47
48
  // code 34 (blue) which many dark-theme palettes render as purple/indigo instead.
48
49
  const codeColor = (s) => `\x1b[90m${s}\x1b[0m`;
49
50
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
50
- const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
51
51
 
52
52
  // The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
53
53
  // SWALLOW them from the terminal (the local ticker below drives progress instead), and
@@ -176,6 +176,14 @@ const _lineQueue = [];
176
176
  let _lineWaiter = null;
177
177
  let _stdinClosed = false;
178
178
  rl.on("line", (l) => {
179
+ // While a turn is running, IGNORE typed input entirely — no type-ahead, no queued
180
+ // next question. Only Ctrl+C (handled via SIGINT) does anything mid-turn. Clear the
181
+ // line buffer so nothing the user typed leaks into the next prompt.
182
+ if (following) {
183
+ rl.line = "";
184
+ rl.cursor = 0;
185
+ return;
186
+ }
179
187
  const clean = stripAnsiEscapes(l);
180
188
  if (_lineWaiter) {
181
189
  const w = _lineWaiter;
@@ -360,6 +368,19 @@ async function fetchAgentPayload(messages) {
360
368
  // [gonext-agent] logs. We poll the job here and stream the persisted chunks live.
361
369
  let following = false;
362
370
  let followAborted = false;
371
+ let currentJobId = null; // the running turn's jobId — so Ctrl+C can cancel it server-side
372
+ let cancelRequested = false; // a cancel POST has been sent for the current turn
373
+
374
+ // While a turn runs, mute readline's own echo/redraws so keystrokes can't corrupt the
375
+ // live status display and there's no way to type a new question — only Ctrl+C works.
376
+ // Same _writeToOutput override password prompts use; our UI (prompt, status, bullets)
377
+ // writes to stdout directly, so it's unaffected — only readline's echo is swallowed.
378
+ const _origWriteToOutput = rl._writeToOutput ? rl._writeToOutput.bind(rl) : null;
379
+ rl._writeToOutput = (s) => {
380
+ if (following) return;
381
+ if (_origWriteToOutput) _origWriteToOutput(s);
382
+ else process.stdout.write(s);
383
+ };
363
384
 
364
385
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
365
386
 
@@ -393,6 +414,8 @@ async function runAgentTurn(history) {
393
414
 
394
415
  following = true;
395
416
  followAborted = false;
417
+ currentJobId = jobId;
418
+ cancelRequested = false;
396
419
  const startedAt = Date.now();
397
420
  let shownChars = 0;
398
421
  let carry = ""; // trailing partial content line held until its newline arrives
@@ -400,7 +423,6 @@ async function runAgentTurn(history) {
400
423
  let warnedPending = false;
401
424
  let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
402
425
  let answerShownLive = false; // plain-reply content already printed — don't re-print it
403
- let routingAnnounced = false; // one friendly line replaces the whole routing play-by-play
404
426
  // True once we've SPECIFICALLY seen "→ Chat reply" (the plain-reply/trivial-chat
405
427
  // branch), as opposed to "→ Agent mode…" (the tool-use branch). Only the plain-reply
406
428
  // path streams its answer via answer_stream with NO heartbeat/tool-summary/code lines
@@ -419,11 +441,18 @@ async function runAgentTurn(history) {
419
441
  let lastContentAt = Date.now();
420
442
  let thinking = false;
421
443
  let thinkingSince = 0;
444
+ let thinkWord = pickWord(); // playful word shown UNDER the in-progress line (flavor)
422
445
  let almostDone = false; // set from the worker's "…almost completed thinking…" heartbeat
446
+ let runningCmd = null; // the command currently executing (from a "Running → …" event)
423
447
  const thinkSecs = () => Math.max(1, Math.round((Date.now() - thinkingSince) / 1000));
424
448
 
449
+ // The status is TWO in-place lines — the in-progress action, then the playful word
450
+ // under it. Cursor sits at the end of line 2, so clearing = wipe line 2, move up, wipe
451
+ // line 1, leaving the cursor back at the status's origin for a redraw or real content.
425
452
  const clearStatus = () => {
426
- if (statusShown) { process.stdout.write(CLEAR_LINE); statusShown = false; }
453
+ if (!statusShown) return;
454
+ process.stdout.write("\r\x1b[K\x1b[1A\r\x1b[K");
455
+ statusShown = false;
427
456
  };
428
457
 
429
458
  const tick = () => {
@@ -433,22 +462,33 @@ async function runAgentTurn(history) {
433
462
  // done — overwriting the ticker there corrupts the visible answer mid-word. Stay
434
463
  // silent once any of THIS line has already been shown (see carryFlushedLen).
435
464
  if (plainReplyFlow && carryFlushedLen > 0) return;
436
- // Count from when the stream went quiet, so the seconds reflect the true wait.
437
- if (!thinking) { thinking = true; thinkingSince = lastContentAt; }
465
+ // Count from when this phase went quiet, so the seconds reflect the current wait/run.
466
+ if (!thinking) { thinking = true; thinkingSince = lastContentAt; thinkWord = pickWord(); }
438
467
  const secs = thinkSecs();
439
- const glyph = SPINNER[secs % SPINNER.length]; // rotates reads as "in progress"
440
- const label = almostDone ? "almost done" : "working";
441
- process.stdout.write(CLEAR_LINE + yellow(`${glyph} ${label}… (${secs}s)`));
468
+ if (secs % 12 === 0) thinkWord = pickWord(); // rotate the word on long waits
469
+ const glyph = SPINNER[secs % SPINNER.length]; // rotates → reads as "in progress"
470
+ // Line 1 = WHAT is happening now: the running command, else the phase (Thinking /
471
+ // Almost done). Line 2 = the playful word (flavor). Truncate so neither wraps.
472
+ const primary = runningCmd
473
+ ? `Running ${runningCmd}`
474
+ : almostDone ? "Almost done" : "Thinking";
475
+ const max = Math.max(24, (process.stdout.columns || 80) - 14);
476
+ const fit = (s) => (s.length > max ? s.slice(0, max - 1) + "…" : s);
477
+ clearStatus();
478
+ process.stdout.write(
479
+ yellow(`${glyph} ${fit(primary)}… (${secs}s)`) + "\n" + dim(` ${thinkWord}…`)
480
+ );
442
481
  statusShown = true;
443
482
  };
444
483
  const ticker = setInterval(tick, 1000);
445
484
 
446
485
  // Called right before any real (bullet/edit-card/answer) content prints: drop the
447
- // blinking-bullet status line so content lands cleanly on its own line. No "completed
448
- // thought" stamp each finished action prints its own solid ● bullet instead.
486
+ // status lines so content lands cleanly, and end the current phase (so the next phase's
487
+ // seconds count fresh, and any running command is considered finished).
449
488
  const onContent = () => {
450
489
  clearStatus();
451
- if (thinking) { process.stdout.write(CLEAR_LINE); thinking = false; }
490
+ thinking = false;
491
+ runningCmd = null;
452
492
  lastContentAt = Date.now();
453
493
  };
454
494
 
@@ -494,17 +534,10 @@ async function runAgentTurn(history) {
494
534
  if (inStreamFence) continue;
495
535
  if (isToolStepSummary(line)) { lastContentAt = Date.now(); continue; } // swallow
496
536
  if (isRoutingLine(line)) {
497
- // Replace the whole routing play-by-play with ONE friendly line, shown once
498
- // per turn the raw labels are implementation detail (visible in the daemon's
499
- // own terminal if you need them).
537
+ // Swallow the router's play-by-play entirely the blinking-bullet ticker (which
538
+ // already shows the playful word + seconds) is the single in-progress indicator,
539
+ // so a separate static "Booting the big brain…" line here would just duplicate it.
500
540
  if (line.trim() === "→ Chat reply") plainReplyFlow = true;
501
- if (!routingAnnounced) {
502
- routingAnnounced = true;
503
- onContent();
504
- process.stdout.write(dim(`${pickWord()}…`) + "\n");
505
- lastWasBlank = false;
506
- }
507
- lastContentAt = Date.now();
508
541
  continue;
509
542
  }
510
543
  // Edit card (a file change made by the agent's edit tools) — rendered as a
@@ -593,6 +626,14 @@ async function runAgentTurn(history) {
593
626
  }
594
627
  continue;
595
628
  }
629
+ // Agent flow: a "Running → <cmd>" is the START of a command — show it as the live
630
+ // in-progress status (the blinking bullet names it) rather than a solid bullet; its
631
+ // completion ("Command passed/failed → …" / "Server up → …") prints the done bullet.
632
+ // So the pair collapses to: "◑ Running <cmd>…" while it runs, then "● <result>".
633
+ if (!plainReplyFlow) {
634
+ const mRun = /^Running → (.+)$/.exec(line.trim());
635
+ if (mRun) { runningCmd = mRun[1]; thinking = false; lastContentAt = Date.now(); continue; }
636
+ }
596
637
  if (alreadyFlushed === 0) onContent();
597
638
  if (plainReplyFlow) {
598
639
  // Plain-reply content IS the answer — print it in the default color and remember
@@ -600,11 +641,10 @@ async function runAgentTurn(history) {
600
641
  process.stdout.write(line.slice(alreadyFlushed) + "\n");
601
642
  answerShownLive = true;
602
643
  } else {
603
- // Agent flow: with the raw Thought/code stream suppressed above, the only lines
604
- // that reach here are the worker's out-of-fence ACTION events ("Running npx…",
605
- // "Searching code → import (11 hits)", "Server up …", "Composing answer…").
606
- // Render each as ONE solid bullet — the clean, summarized action list (task #41).
607
- process.stdout.write(green(BULLET) + " " + line.trim() + "\n");
644
+ // The remaining out-of-fence ACTION events ("Command passed …", "Searching code
645
+ // …", "Server up …", "Composing answer…") each print as ONE solid bullet —
646
+ // the clean, summarized action list (task #41).
647
+ process.stdout.write(white(BULLET) + " " + line.trim() + "\n");
608
648
  }
609
649
  lastWasBlank = false;
610
650
  }
@@ -651,9 +691,17 @@ async function runAgentTurn(history) {
651
691
  consume(text.slice(shownChars));
652
692
  shownChars = text.length;
653
693
  }
654
- if (job.jobStatus === "failed" || job.jobStatus === "cancelled") {
694
+ if (job.jobStatus === "cancelled") {
695
+ // User-requested stop (Ctrl+C) — a clean outcome, not an error. The worker has
696
+ // killed the Python agent. Print a quiet note and return an empty, non-persisted
697
+ // turn (main() drops it from history).
698
+ clearStatus();
699
+ process.stdout.write(dim("\n(cancelled — the agent was stopped)\n"));
700
+ return { text: "", shown: false, cancelled: true };
701
+ }
702
+ if (job.jobStatus === "failed") {
655
703
  clearStatus();
656
- throw new Error(job.errorMessage || `job ${job.jobStatus}`);
704
+ throw new Error(job.errorMessage || "job failed");
657
705
  }
658
706
  if (
659
707
  job.jobStatus === "pending" &&
@@ -672,6 +720,11 @@ async function runAgentTurn(history) {
672
720
  clearInterval(ticker);
673
721
  clearStatus();
674
722
  following = false;
723
+ currentJobId = null;
724
+ // Drop anything typed (but not submitted) during the turn — echo was muted, so it's
725
+ // invisible; without this it would surface on the next prompt.
726
+ rl.line = "";
727
+ rl.cursor = 0;
675
728
  }
676
729
  }
677
730
 
@@ -711,8 +764,26 @@ async function main() {
711
764
  // only one can ever fire for a given mode, so there's no double-handling.
712
765
  const onInterrupt = () => {
713
766
  if (following) {
714
- followAborted = true;
715
- process.stdout.write(red("\n^C "));
767
+ if (!cancelRequested && currentJobId) {
768
+ // First Ctrl+C: actually CANCEL the running turn server-side (stop the model),
769
+ // not just detach. The worker sees the "cancelled" status and kills the Python
770
+ // agent; the poll loop then returns cleanly with the "(cancelled)" note.
771
+ cancelRequested = true;
772
+ const jobId = currentJobId;
773
+ process.stdout.write(red("\n^C ") + dim("stopping the agent…\n"));
774
+ fetch(`${apiBase}/api/worker/jobs/cancel`, {
775
+ method: "POST",
776
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
777
+ body: JSON.stringify({ jobId }),
778
+ }).catch(() => {
779
+ /* best-effort — the worker also self-detects via the chunk 409 path */
780
+ });
781
+ } else {
782
+ // Second Ctrl+C (or no jobId yet): give up waiting locally. The worker keeps
783
+ // trying to stop, but we stop following.
784
+ followAborted = true;
785
+ process.stdout.write(red("\n^C "));
786
+ }
716
787
  } else {
717
788
  process.stdout.write(dim("\n(/exit to quit)\n"));
718
789
  // Discard anything half-typed (Ctrl-C at a prompt means "never mind") and
@@ -769,7 +840,7 @@ async function main() {
769
840
  }
770
841
  history.push({ role: "user", content: line });
771
842
  try {
772
- const { text: answer, shown } = await runAgentTurn(history);
843
+ const { text: answer, shown, cancelled } = await runAgentTurn(history);
773
844
  if (answer) {
774
845
  history.push({ role: "assistant", content: answer });
775
846
  // Persist after every successful turn (not just on clean exit) — an ungraceful
@@ -780,8 +851,12 @@ async function main() {
780
851
  // again here would just duplicate it; a blank line is enough for spacing.
781
852
  console.log(shown ? "" : `\n\n${answer}\n`);
782
853
  } else {
783
- history.pop(); // aborted/failed turn — don't poison the next request's history
784
- console.log(red("\n(no answer run aborted or failed)\n"));
854
+ history.pop(); // aborted/failed/cancelled turn — don't poison the next request
855
+ // Ctrl+C cancel already printed its own "(cancelled)" note in runAgentTurn — a
856
+ // second "(no answer)" line here would be redundant/misleading.
857
+ if (!cancelled) {
858
+ console.log(red("\n(no answer — run aborted or failed)\n"));
859
+ }
785
860
  }
786
861
  } catch (err) {
787
862
  history.pop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.203",
3
+ "version": "1.0.205",
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",