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