@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.
- package/gonext-local-worker.mjs +71 -3
- package/gonext-repl.mjs +108 -33
- package/package.json +1 -1
- package/thinking_words.txt +1000 -103
package/gonext-local-worker.mjs
CHANGED
|
@@ -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 &&
|
|
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)
|
|
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
|
|
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
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
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
|
-
//
|
|
448
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
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
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
|
|
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 === "
|
|
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 ||
|
|
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
|
-
|
|
715
|
-
|
|
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
|
|
784
|
-
|
|
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.
|
|
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",
|