@tiens.nguyen/gonext-local-worker 1.0.204 → 1.0.206

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);
@@ -1952,6 +2020,71 @@ function normalizeBaseUrl(raw) {
1952
2020
  return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
1953
2021
  }
1954
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
+
1955
2088
  function normalizeOpenAiV1Root(raw) {
1956
2089
  const base = normalizeBaseUrl(raw);
1957
2090
  if (!base) return "";
@@ -2533,6 +2666,10 @@ async function pollOnce() {
2533
2666
  await runHttpProbeJob(job);
2534
2667
  return;
2535
2668
  }
2669
+ if (job.jobType === "list_models") {
2670
+ await runListModelsJob(job);
2671
+ return;
2672
+ }
2536
2673
  if (job.jobType === "agent_chat") {
2537
2674
  await runAgentChatJob(job);
2538
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" +
@@ -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;
@@ -185,6 +193,19 @@ rl.on("line", (l) => {
185
193
  _lineQueue.push(clean);
186
194
  }
187
195
  });
196
+ // Neutralize navigation keys (↑/↓ history recall, ←/→ cursor, Home/End, etc.) during a
197
+ // turn: echo is already muted, but readline still recalls history into the invisible
198
+ // buffer on ↑/↓ — reset the line state after every keypress so nothing accumulates or
199
+ // moves. Ctrl+C is unaffected (readline emits its SIGINT before this handler runs).
200
+ if (process.stdin.isTTY) {
201
+ process.stdin.on("keypress", () => {
202
+ if (following) {
203
+ rl.line = "";
204
+ rl.cursor = 0;
205
+ rl.historyIndex = -1;
206
+ }
207
+ });
208
+ }
188
209
  rl.on("close", () => {
189
210
  _stdinClosed = true;
190
211
  if (_lineWaiter) {
@@ -330,6 +351,53 @@ async function clearSession(cwd) {
330
351
  }
331
352
 
332
353
  // ---------- fetch the ready-to-run agent payload from user settings ----------
354
+ // /model command: fetch the allowed coding models (default + web-curated whitelist),
355
+ // show a numbered picker, and set sessionCodingModel to the choice for this session.
356
+ async function chooseModel() {
357
+ let probe;
358
+ try {
359
+ probe = await fetchAgentPayload([{ role: "user", content: "ping" }]);
360
+ } catch (err) {
361
+ console.log(red(` couldn't load models: ${err.message}\n`));
362
+ return;
363
+ }
364
+ const allowed = Array.isArray(probe?.codingAllowed) ? probe.codingAllowed : [];
365
+ const def = (probe?.codingDefault ?? "").trim();
366
+ if (allowed.length === 0) {
367
+ console.log(dim(" No coding model configured. Set one in the web app → Settings → Agent.\n"));
368
+ return;
369
+ }
370
+ if (allowed.length === 1) {
371
+ console.log(
372
+ dim(` Only one coding model is available: ${allowed[0]}.\n`) +
373
+ dim(" Add more in the web app → Settings → Agent → Load Models (checkboxes).\n")
374
+ );
375
+ return;
376
+ }
377
+ const active = sessionCodingModel || def;
378
+ console.log(dim(" Choose the coding model for this session:"));
379
+ allowed.forEach((m, i) => {
380
+ const mark = m === active ? green(" ●") : " ";
381
+ const tag = m === def ? dim(" (default)") : "";
382
+ console.log(` ${mark} ${i + 1}. ${m}${tag}`);
383
+ });
384
+ const pick = (await ask(dim(" number (or Enter to keep current): "))).trim();
385
+ if (!pick) {
386
+ console.log("");
387
+ return;
388
+ }
389
+ const idx = Number.parseInt(pick, 10) - 1;
390
+ if (!Number.isInteger(idx) || idx < 0 || idx >= allowed.length) {
391
+ console.log(red(" invalid choice.\n"));
392
+ return;
393
+ }
394
+ const chosen = allowed[idx];
395
+ // Empty override means "use the account default" — so don't send an override when the
396
+ // user picks the default (keeps the payload clean and lets a later default change win).
397
+ sessionCodingModel = chosen === def ? "" : chosen;
398
+ console.log(green(` ✓ coding model → ${chosen}${chosen === def ? " (default)" : ""}\n`));
399
+ }
400
+
333
401
  async function fetchAgentPayload(messages) {
334
402
  const res = await fetch(`${apiBase}/api/worker/agent-payload`, {
335
403
  method: "POST",
@@ -360,6 +428,31 @@ async function fetchAgentPayload(messages) {
360
428
  // [gonext-agent] logs. We poll the job here and stream the persisted chunks live.
361
429
  let following = false;
362
430
  let followAborted = false;
431
+ let currentJobId = null; // the running turn's jobId — so Ctrl+C can cancel it server-side
432
+ let cancelRequested = false; // a cancel POST has been sent for the current turn
433
+ // Per-session coding-model override chosen via /model (empty = use the account default).
434
+ // Sent to /agent-ask as codingModelOverride; the API validates it against the whitelist.
435
+ let sessionCodingModel = "";
436
+
437
+ // While a turn runs, mute readline's own echo/redraws so keystrokes can't corrupt the
438
+ // live status display and there's no way to type a new question — only Ctrl+C works.
439
+ // Same _writeToOutput override password prompts use; our UI (prompt, status, bullets)
440
+ // writes to stdout directly, so it's unaffected — only readline's echo is swallowed.
441
+ const _origWriteToOutput = rl._writeToOutput ? rl._writeToOutput.bind(rl) : null;
442
+ rl._writeToOutput = (s) => {
443
+ if (following) return;
444
+ if (_origWriteToOutput) _origWriteToOutput(s);
445
+ else process.stdout.write(s);
446
+ };
447
+ // _writeToOutput only mutes echoed TEXT; readline's line refresh writes cursor-position
448
+ // + erase escapes (e.g. ESC[1G, ESC[0J) straight to output on every keypress, which
449
+ // would corrupt our live status display. No-op it during a turn so a keypress produces
450
+ // ZERO terminal output.
451
+ const _origRefreshLine = rl._refreshLine ? rl._refreshLine.bind(rl) : null;
452
+ rl._refreshLine = () => {
453
+ if (following) return;
454
+ if (_origRefreshLine) _origRefreshLine();
455
+ };
363
456
 
364
457
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
365
458
 
@@ -376,8 +469,12 @@ async function runAgentTurn(history) {
376
469
  method: "POST",
377
470
  headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
378
471
  // cwd = this terminal's folder → agent puts download/unzip output here (when it's
379
- // a registered workspace).
380
- body: JSON.stringify({ messages: history, cwd: resolve(process.cwd()) }),
472
+ // a registered workspace). codingModelOverride = the /model choice for this session.
473
+ body: JSON.stringify({
474
+ messages: history,
475
+ cwd: resolve(process.cwd()),
476
+ ...(sessionCodingModel ? { codingModelOverride: sessionCodingModel } : {}),
477
+ }),
381
478
  });
382
479
  const data = await res.json().catch(() => ({}));
383
480
  if (!res.ok) {
@@ -393,6 +490,8 @@ async function runAgentTurn(history) {
393
490
 
394
491
  following = true;
395
492
  followAborted = false;
493
+ currentJobId = jobId;
494
+ cancelRequested = false;
396
495
  const startedAt = Date.now();
397
496
  let shownChars = 0;
398
497
  let carry = ""; // trailing partial content line held until its newline arrives
@@ -668,9 +767,17 @@ async function runAgentTurn(history) {
668
767
  consume(text.slice(shownChars));
669
768
  shownChars = text.length;
670
769
  }
671
- if (job.jobStatus === "failed" || job.jobStatus === "cancelled") {
770
+ if (job.jobStatus === "cancelled") {
771
+ // User-requested stop (Ctrl+C) — a clean outcome, not an error. The worker has
772
+ // killed the Python agent. Print a quiet note and return an empty, non-persisted
773
+ // turn (main() drops it from history).
774
+ clearStatus();
775
+ process.stdout.write(dim("\n(cancelled — the agent was stopped)\n"));
776
+ return { text: "", shown: false, cancelled: true };
777
+ }
778
+ if (job.jobStatus === "failed") {
672
779
  clearStatus();
673
- throw new Error(job.errorMessage || `job ${job.jobStatus}`);
780
+ throw new Error(job.errorMessage || "job failed");
674
781
  }
675
782
  if (
676
783
  job.jobStatus === "pending" &&
@@ -689,6 +796,11 @@ async function runAgentTurn(history) {
689
796
  clearInterval(ticker);
690
797
  clearStatus();
691
798
  following = false;
799
+ currentJobId = null;
800
+ // Drop anything typed (but not submitted) during the turn — echo was muted, so it's
801
+ // invisible; without this it would surface on the next prompt.
802
+ rl.line = "";
803
+ rl.cursor = 0;
692
804
  }
693
805
  }
694
806
 
@@ -728,8 +840,26 @@ async function main() {
728
840
  // only one can ever fire for a given mode, so there's no double-handling.
729
841
  const onInterrupt = () => {
730
842
  if (following) {
731
- followAborted = true;
732
- process.stdout.write(red("\n^C "));
843
+ if (!cancelRequested && currentJobId) {
844
+ // First Ctrl+C: actually CANCEL the running turn server-side (stop the model),
845
+ // not just detach. The worker sees the "cancelled" status and kills the Python
846
+ // agent; the poll loop then returns cleanly with the "(cancelled)" note.
847
+ cancelRequested = true;
848
+ const jobId = currentJobId;
849
+ process.stdout.write(red("\n^C ") + dim("stopping the agent…\n"));
850
+ fetch(`${apiBase}/api/worker/jobs/cancel`, {
851
+ method: "POST",
852
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
853
+ body: JSON.stringify({ jobId }),
854
+ }).catch(() => {
855
+ /* best-effort — the worker also self-detects via the chunk 409 path */
856
+ });
857
+ } else {
858
+ // Second Ctrl+C (or no jobId yet): give up waiting locally. The worker keeps
859
+ // trying to stop, but we stop following.
860
+ followAborted = true;
861
+ process.stdout.write(red("\n^C "));
862
+ }
733
863
  } else {
734
864
  process.stdout.write(dim("\n(/exit to quit)\n"));
735
865
  // Discard anything half-typed (Ctrl-C at a prompt means "never mind") and
@@ -759,12 +889,17 @@ async function main() {
759
889
  if (line === "/help") {
760
890
  console.log(
761
891
  dim(
762
- " /exit — quit · /revert [runId] undo the agent's file edits (latest run) · " +
892
+ " /exit — quit · /modelswitch the coding model for this session · " +
893
+ "/revert [runId] — undo the agent's file edits (latest run) · " +
763
894
  "/reset — forget this folder's saved conversation and start fresh"
764
895
  )
765
896
  );
766
897
  continue;
767
898
  }
899
+ if (line === "/model") {
900
+ await chooseModel();
901
+ continue;
902
+ }
768
903
  if (line === "/reset" || line === "/new") {
769
904
  history.length = 0;
770
905
  await clearSession(cwd);
@@ -786,7 +921,7 @@ async function main() {
786
921
  }
787
922
  history.push({ role: "user", content: line });
788
923
  try {
789
- const { text: answer, shown } = await runAgentTurn(history);
924
+ const { text: answer, shown, cancelled } = await runAgentTurn(history);
790
925
  if (answer) {
791
926
  history.push({ role: "assistant", content: answer });
792
927
  // Persist after every successful turn (not just on clean exit) — an ungraceful
@@ -797,8 +932,12 @@ async function main() {
797
932
  // again here would just duplicate it; a blank line is enough for spacing.
798
933
  console.log(shown ? "" : `\n\n${answer}\n`);
799
934
  } else {
800
- history.pop(); // aborted/failed turn — don't poison the next request's history
801
- console.log(red("\n(no answer run aborted or failed)\n"));
935
+ history.pop(); // aborted/failed/cancelled turn — don't poison the next request
936
+ // Ctrl+C cancel already printed its own "(cancelled)" note in runAgentTurn — a
937
+ // second "(no answer)" line here would be redundant/misleading.
938
+ if (!cancelled) {
939
+ console.log(red("\n(no answer — run aborted or failed)\n"));
940
+ }
802
941
  }
803
942
  } catch (err) {
804
943
  history.pop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.204",
3
+ "version": "1.0.206",
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",