@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.
- package/gonext-local-worker.mjs +140 -3
- package/gonext-repl.mjs +151 -12
- 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);
|
|
@@ -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
|
|
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({
|
|
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 === "
|
|
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 ||
|
|
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
|
-
|
|
732
|
-
|
|
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 · /
|
|
892
|
+
" /exit — quit · /model — switch 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
|
|
801
|
-
|
|
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.
|
|
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",
|