@tiens.nguyen/gonext-local-worker 1.0.227 → 1.0.229
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 +9 -0
- package/gonext-repl.mjs +289 -16
- package/gonext_agent_chat.py +158 -14
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1848,6 +1848,15 @@ async function runAgentChatJob(job) {
|
|
|
1848
1848
|
// the API sends (list or comma/space string); python re-parses either shape.
|
|
1849
1849
|
runAllowlist: payload?.runAllowlist ?? [],
|
|
1850
1850
|
runDenylist: payload?.runDenylist ?? [],
|
|
1851
|
+
// Interactive command-approval gate: the jobId lets python register a pending
|
|
1852
|
+
// approval + poll for the user's Yes/No via the API; interactiveApproval is the
|
|
1853
|
+
// REPL's "I can show a picker" signal (else python keeps hard-blocking, as on web).
|
|
1854
|
+
jobId,
|
|
1855
|
+
interactiveApproval: payload?.interactiveApproval === true,
|
|
1856
|
+
// Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
|
|
1857
|
+
autoTest: payload?.autoTest === true,
|
|
1858
|
+
// Deploy target chosen via the terminal /server picker (task #69). Host/user only.
|
|
1859
|
+
deployServer: payload?.deployServer ?? null,
|
|
1851
1860
|
// Max web_search + fetch_url calls the agent may make per task (user-configurable
|
|
1852
1861
|
// in web Settings → Agent). Default 10; past it the retrieval tools refuse.
|
|
1853
1862
|
researchBudget: payload?.researchBudget ?? 10,
|
package/gonext-repl.mjs
CHANGED
|
@@ -163,6 +163,8 @@ if (!workerKey || !apiBase) {
|
|
|
163
163
|
// unknown-command guard, and /help, so they can never drift.
|
|
164
164
|
const COMMANDS = [
|
|
165
165
|
{ name: "/model", desc: "switch the coding model for this session" },
|
|
166
|
+
{ name: "/test-auto", desc: "toggle auto-test (verify & fix until it passes) for this folder" },
|
|
167
|
+
{ name: "/server", desc: "pick a deployment server (host/user) for this session" },
|
|
166
168
|
{ name: "/revert", usage: "/revert [runId]", desc: "undo the agent's file edits (latest run)" },
|
|
167
169
|
{ name: "/reset", aliases: ["/new"], desc: "forget this folder's saved conversation and start fresh" },
|
|
168
170
|
{ name: "/help", desc: "list these commands" },
|
|
@@ -298,6 +300,14 @@ function drawSlashHint(prefix, sel) {
|
|
|
298
300
|
}
|
|
299
301
|
if (process.stdin.isTTY) {
|
|
300
302
|
process.stdin.on("keypress", (_ch, key) => {
|
|
303
|
+
// A command-approval picker is up → arrow keys move the highlight, Enter chooses.
|
|
304
|
+
if (approvalActive) {
|
|
305
|
+
onApprovalKey(key);
|
|
306
|
+
rl.line = "";
|
|
307
|
+
rl.cursor = 0;
|
|
308
|
+
rl.historyIndex = -1;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
301
311
|
if (following) {
|
|
302
312
|
rl.line = "";
|
|
303
313
|
rl.cursor = 0;
|
|
@@ -495,6 +505,17 @@ async function loadSession(cwd) {
|
|
|
495
505
|
}
|
|
496
506
|
}
|
|
497
507
|
|
|
508
|
+
// The /test-auto choice is remembered per folder (stored alongside history in the same
|
|
509
|
+
// session file). Read it once at startup so the banner and agent-ask reflect it.
|
|
510
|
+
async function loadSessionTestAuto(cwd) {
|
|
511
|
+
try {
|
|
512
|
+
const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
|
|
513
|
+
return raw?.testAuto === true;
|
|
514
|
+
} catch {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
498
519
|
async function saveSession(cwd, history) {
|
|
499
520
|
try {
|
|
500
521
|
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
@@ -502,7 +523,7 @@ async function saveSession(cwd, history) {
|
|
|
502
523
|
await writeFile(
|
|
503
524
|
sessionFilePath(cwd),
|
|
504
525
|
JSON.stringify(
|
|
505
|
-
{ cwd, updatedAt: new Date().toISOString(), history: trimmed },
|
|
526
|
+
{ cwd, updatedAt: new Date().toISOString(), testAuto: sessionTestAuto, history: trimmed },
|
|
506
527
|
null,
|
|
507
528
|
2
|
|
508
529
|
) + "\n"
|
|
@@ -572,6 +593,89 @@ async function chooseModel() {
|
|
|
572
593
|
console.log(green(` ✓ coding model → ${chosen}${chosen === def ? " (default)" : ""}\n`));
|
|
573
594
|
}
|
|
574
595
|
|
|
596
|
+
// Probe whether SSH KEY auth already works for a server (so we can tell the user to run
|
|
597
|
+
// ssh-copy-id if not). BatchMode=yes = never prompt for a password → exit!=0 if no key.
|
|
598
|
+
async function checkServerKeyAuth(s) {
|
|
599
|
+
try {
|
|
600
|
+
const ok = await new Promise((resolve) => {
|
|
601
|
+
const p = spawn(
|
|
602
|
+
"ssh",
|
|
603
|
+
[
|
|
604
|
+
"-o", "BatchMode=yes",
|
|
605
|
+
"-o", "ConnectTimeout=6",
|
|
606
|
+
"-o", "StrictHostKeyChecking=accept-new",
|
|
607
|
+
`${s.user}@${s.host}`,
|
|
608
|
+
"true",
|
|
609
|
+
],
|
|
610
|
+
{ stdio: "ignore" }
|
|
611
|
+
);
|
|
612
|
+
p.on("exit", (code) => resolve(code === 0));
|
|
613
|
+
p.on("error", () => resolve(false));
|
|
614
|
+
});
|
|
615
|
+
if (ok) console.log(dim(" ✓ key auth works — deploys will connect without a password."));
|
|
616
|
+
else
|
|
617
|
+
console.log(
|
|
618
|
+
yellow(" key auth not set up yet.") +
|
|
619
|
+
dim(` Run once in your terminal: `) +
|
|
620
|
+
`ssh-copy-id ${s.user}@${s.host}`
|
|
621
|
+
);
|
|
622
|
+
} catch {
|
|
623
|
+
/* best-effort check — never block the picker */
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// /server — pick a deployment target (from the web app's Servers registry) for this
|
|
628
|
+
// session. Host/user only, never a secret; the agent deploys here with KEY auth (#69).
|
|
629
|
+
async function chooseServer() {
|
|
630
|
+
let servers;
|
|
631
|
+
try {
|
|
632
|
+
const res = await fetch(`${apiBase}/api/worker/deploy-servers`, {
|
|
633
|
+
headers: { "X-Worker-Key": workerKey },
|
|
634
|
+
});
|
|
635
|
+
const data = await res.json().catch(() => ({}));
|
|
636
|
+
if (!res.ok) {
|
|
637
|
+
console.log(red(` couldn't load servers: ${data?.error || `HTTP ${res.status}`}\n`));
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
servers = Array.isArray(data?.servers) ? data.servers : [];
|
|
641
|
+
} catch (err) {
|
|
642
|
+
console.log(red(` couldn't load servers: ${err.message}\n`));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (servers.length === 0) {
|
|
646
|
+
console.log(dim(" No deployment servers yet — add them in the web app → Servers.\n"));
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const activeHost = selectedServer?.host || "";
|
|
650
|
+
console.log(dim(" Choose a deployment server for this session:"));
|
|
651
|
+
servers.forEach((s, i) => {
|
|
652
|
+
const mark = s.host === activeHost ? green(" ●") : " ";
|
|
653
|
+
console.log(` ${mark} ${i + 1}. ${s.name} ${dim(`(${s.user}@${s.host})`)}`);
|
|
654
|
+
});
|
|
655
|
+
console.log(` 0. ${dim("none (clear selection)")}`);
|
|
656
|
+
const pick = (await ask(dim(" number (or Enter to keep current): "))).trim();
|
|
657
|
+
if (!pick) {
|
|
658
|
+
console.log("");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const n = Number.parseInt(pick, 10);
|
|
662
|
+
if (n === 0) {
|
|
663
|
+
selectedServer = null;
|
|
664
|
+
console.log(dim(" ✓ deployment server cleared.\n"));
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const idx = n - 1;
|
|
668
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= servers.length) {
|
|
669
|
+
console.log(red(" invalid choice.\n"));
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const s = servers[idx];
|
|
673
|
+
selectedServer = { name: s.name, host: s.host, user: s.user };
|
|
674
|
+
console.log(green(` ✓ deploy target → ${s.name} (${s.user}@${s.host})`));
|
|
675
|
+
await checkServerKeyAuth(selectedServer);
|
|
676
|
+
console.log("");
|
|
677
|
+
}
|
|
678
|
+
|
|
575
679
|
async function fetchAgentPayload(messages) {
|
|
576
680
|
const res = await fetch(`${apiBase}/api/worker/agent-payload`, {
|
|
577
681
|
method: "POST",
|
|
@@ -604,9 +708,73 @@ let following = false;
|
|
|
604
708
|
let followAborted = false;
|
|
605
709
|
let currentJobId = null; // the running turn's jobId — so Ctrl+C can cancel it server-side
|
|
606
710
|
let cancelRequested = false; // a cancel POST has been sent for the current turn
|
|
711
|
+
// Interactive command-approval gate: while the agent is paused on a risky command, the
|
|
712
|
+
// terminal shows a Yes/No list picker (↑/↓ + Enter, Yes preselected). approvalActive
|
|
713
|
+
// routes keystrokes to the picker; approvalResolve settles the awaiting promise once.
|
|
714
|
+
let approvalActive = false;
|
|
715
|
+
let approvalSel = 0; // 0 = Yes (default highlighted), 1 = No
|
|
716
|
+
let approvalResolve = null;
|
|
717
|
+
function drawApprovalOptions(redraw) {
|
|
718
|
+
// Two option lines, redrawn in place. On redraw the cursor sits one row below "No";
|
|
719
|
+
// step up 2 rows, clear+rewrite both, landing back where we started.
|
|
720
|
+
const yes = approvalSel === 0 ? green("▸ Yes") : dim(" Yes");
|
|
721
|
+
const no = approvalSel === 1 ? green("▸ No") : dim(" No");
|
|
722
|
+
process.stdout.write((redraw ? "\x1b[2A" : "") + "\x1b[K" + yes + "\n\x1b[K" + no + "\n");
|
|
723
|
+
}
|
|
724
|
+
function finishApproval(allow) {
|
|
725
|
+
if (!approvalActive) return;
|
|
726
|
+
approvalActive = false;
|
|
727
|
+
// Settle the picker: overwrite the two option lines with a single result line.
|
|
728
|
+
process.stdout.write("\x1b[2A\x1b[J" + (allow ? green(" ▸ Yes — running it") : red(" ▸ No — skipped")) + "\n");
|
|
729
|
+
const r = approvalResolve;
|
|
730
|
+
approvalResolve = null;
|
|
731
|
+
if (r) r(!!allow);
|
|
732
|
+
}
|
|
733
|
+
function onApprovalKey(key) {
|
|
734
|
+
if (!key) return;
|
|
735
|
+
if (key.name === "up" || key.name === "down") {
|
|
736
|
+
approvalSel = key.name === "up" ? 0 : 1; // 2 options: up=Yes, down=No
|
|
737
|
+
drawApprovalOptions(true);
|
|
738
|
+
} else if (key.name === "return" || key.name === "enter") {
|
|
739
|
+
finishApproval(approvalSel === 0);
|
|
740
|
+
}
|
|
741
|
+
// Ctrl+C is delivered via readline "SIGINT" (onInterrupt), which also denies — see there.
|
|
742
|
+
}
|
|
743
|
+
// Show the picker and resolve to the user's choice. Non-TTY (piped) can't pick → deny
|
|
744
|
+
// (safe). Auto-denies after ~170s so an absent user never hangs the turn (and stays
|
|
745
|
+
// under the python side's 180s wait); Enter is instant since Yes is preselected.
|
|
746
|
+
function approvalPrompt(command) {
|
|
747
|
+
return new Promise((resolve) => {
|
|
748
|
+
if (!process.stdin.isTTY) {
|
|
749
|
+
process.stdout.write(dim(`\n(approval needed for: ${command} — no interactive terminal, skipping)\n`));
|
|
750
|
+
resolve(false);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
process.stdout.write(
|
|
754
|
+
"\n" + yellow("⚠ Allow running this command?") + "\n" + dim(" " + command) + "\n"
|
|
755
|
+
);
|
|
756
|
+
approvalSel = 0;
|
|
757
|
+
approvalResolve = resolve;
|
|
758
|
+
approvalActive = true;
|
|
759
|
+
drawApprovalOptions(false);
|
|
760
|
+
const t = setTimeout(() => finishApproval(false), 170000);
|
|
761
|
+
const orig = resolve;
|
|
762
|
+
approvalResolve = (v) => {
|
|
763
|
+
clearTimeout(t);
|
|
764
|
+
orig(v);
|
|
765
|
+
};
|
|
766
|
+
});
|
|
767
|
+
}
|
|
607
768
|
// Per-session coding-model override chosen via /model (empty = use the account default).
|
|
608
769
|
// Sent to /agent-ask as codingModelOverride; the API validates it against the whitelist.
|
|
609
770
|
let sessionCodingModel = "";
|
|
771
|
+
// Auto-test mode (/test-auto): when on, the agent verifies its work (build/run/curl/…)
|
|
772
|
+
// and fixes → re-tests until it passes before finishing. Default off; remembered per
|
|
773
|
+
// folder in the session file; sent to /agent-ask as autoTest so python drives the loop.
|
|
774
|
+
let sessionTestAuto = false;
|
|
775
|
+
// Deploy target chosen via /server (task #69): {name, host, user} or null. Session-scoped;
|
|
776
|
+
// sent to /agent-ask as deployServer so the agent deploys to this host with KEY auth.
|
|
777
|
+
let selectedServer = null;
|
|
610
778
|
|
|
611
779
|
// While a turn runs, mute readline's own echo/redraws so keystrokes can't corrupt the
|
|
612
780
|
// live status display and there's no way to type a new question — only Ctrl+C works.
|
|
@@ -638,6 +806,65 @@ const answerFrom = (s) =>
|
|
|
638
806
|
.replace(/<think>[\s\S]*$/i, "")
|
|
639
807
|
.trim();
|
|
640
808
|
|
|
809
|
+
// --- Live-thought normalization (task "agent thought normalizing issue") ---------------
|
|
810
|
+
// The blinking status line names the model's current Thought. But the model often emits
|
|
811
|
+
// <code> with NO "Thought:" prose (or degenerates into a runaway), so the raw stream is
|
|
812
|
+
// a create_file(...) blob / escaped CSS — which used to render verbatim on the line
|
|
813
|
+
// ("◐ , #94a3b8);\n -webkit-background-clip…"). Fix: show a genuine thought ONLY when the
|
|
814
|
+
// model actually wrote prose; otherwise name the TOOL it's calling ("Writing App.js");
|
|
815
|
+
// otherwise show nothing (the line falls back to "Thinking…"). Never raw code/CSS.
|
|
816
|
+
const _baseName = (p) => (String(p || "").split(/[\\/]/).pop() || "").trim();
|
|
817
|
+
const THOUGHT_TOOL_LABELS = {
|
|
818
|
+
create_file: (a) => `Writing ${_baseName(a) || "a file"}`,
|
|
819
|
+
create_folder: (a) => `Creating ${_baseName(a) || "a folder"}`,
|
|
820
|
+
edit_lines: (a) => `Editing ${_baseName(a) || "a file"}`,
|
|
821
|
+
edit_file: (a) => `Editing ${_baseName(a) || "a file"}`,
|
|
822
|
+
read_file_lines: (a) => `Reading ${_baseName(a) || "a file"}`,
|
|
823
|
+
read_text_file: (a) => `Reading ${_baseName(a) || "a file"}`,
|
|
824
|
+
list_dir: (a) => `Listing ${_baseName(a) || "files"}`,
|
|
825
|
+
grep_repo: () => "Searching the code",
|
|
826
|
+
run_command: (a) => `Running ${a || "a command"}`,
|
|
827
|
+
stop_server: () => "Stopping the server",
|
|
828
|
+
deploy_web: () => "Deploying",
|
|
829
|
+
fetch_url: (a) => `Fetching ${_baseName(a) || "a page"}`,
|
|
830
|
+
web_search: () => "Searching the web",
|
|
831
|
+
http_request: () => "Calling an API",
|
|
832
|
+
create_pdf: () => "Building a PDF",
|
|
833
|
+
rag_search: () => "Searching the knowledge base",
|
|
834
|
+
rag_index: () => "Indexing the knowledge base",
|
|
835
|
+
download_file: () => "Downloading a file",
|
|
836
|
+
unzip_file: () => "Unzipping",
|
|
837
|
+
send_email: () => "Preparing an email",
|
|
838
|
+
open_url: (a) => `Opening ${a || "a link"}`,
|
|
839
|
+
final_answer: () => "Composing the answer",
|
|
840
|
+
};
|
|
841
|
+
// Does this candidate look like CODE / a data blob rather than a human thought? (Escaped
|
|
842
|
+
// newlines, braces, hex colors, -webkit-, a leading function call, or too few spaces.)
|
|
843
|
+
const _thoughtLooksCodey = (s) =>
|
|
844
|
+
/\\n|[{}]|=>|="|#[0-9a-fA-F]{3,6}\b|-webkit-|;\s*$|^\s*[<([]/.test(s) ||
|
|
845
|
+
/^\s*[a-z_]\w*\s*\(/i.test(s) ||
|
|
846
|
+
(s.length > 24 && (s.split(" ").length - 1) / s.length < 0.06);
|
|
847
|
+
// Raw in-fence stream → a short, meaningful status: a real Thought clause if the model
|
|
848
|
+
// wrote one, else a friendly label from the tool it's calling, else "" (→ "Thinking…").
|
|
849
|
+
const normalizeThought = (buf) => {
|
|
850
|
+
const t = String(buf || "");
|
|
851
|
+
const ci = t.search(/<code[\s>]/i);
|
|
852
|
+
const head = ci >= 0 ? t.slice(0, ci) : t;
|
|
853
|
+
// 1) a genuine Thought prose line (before the code) — only if it isn't itself code.
|
|
854
|
+
const prose = head
|
|
855
|
+
.replace(/^\s*Thought:\s*/i, "")
|
|
856
|
+
.split(/\n/)
|
|
857
|
+
.map((x) => x.trim())
|
|
858
|
+
.find(Boolean);
|
|
859
|
+
if (prose && !_thoughtLooksCodey(prose)) return prose.slice(0, 100);
|
|
860
|
+
// 2) otherwise name the tool being called (from the code part).
|
|
861
|
+
const codePart = ci >= 0 ? t.slice(ci) : t;
|
|
862
|
+
const m = /(?:<code>\s*)?\b([a-z_]\w*)\s*\(\s*(?:path\s*=\s*)?["']?([^"'\n,)]*)/i.exec(codePart);
|
|
863
|
+
if (m && THOUGHT_TOOL_LABELS[m[1]]) return THOUGHT_TOOL_LABELS[m[1]](m[2].trim()).slice(0, 100);
|
|
864
|
+
// 3) nothing human to show.
|
|
865
|
+
return "";
|
|
866
|
+
};
|
|
867
|
+
|
|
641
868
|
async function runAgentTurn(history) {
|
|
642
869
|
const res = await fetch(`${apiBase}/api/worker/agent-ask`, {
|
|
643
870
|
method: "POST",
|
|
@@ -647,6 +874,13 @@ async function runAgentTurn(history) {
|
|
|
647
874
|
body: JSON.stringify({
|
|
648
875
|
messages: history,
|
|
649
876
|
cwd: resolve(process.cwd()),
|
|
877
|
+
// The terminal can show a Yes/No picker → the agent PAUSES on a risky command and
|
|
878
|
+
// asks instead of hard-blocking it (interactive command-approval gate).
|
|
879
|
+
interactiveApproval: true,
|
|
880
|
+
// Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
|
|
881
|
+
...(sessionTestAuto ? { autoTest: true } : {}),
|
|
882
|
+
// Deploy target chosen via /server — host/user only (no secret), for key-auth deploys.
|
|
883
|
+
...(selectedServer ? { deployServer: selectedServer } : {}),
|
|
650
884
|
...(sessionCodingModel ? { codingModelOverride: sessionCodingModel } : {}),
|
|
651
885
|
}),
|
|
652
886
|
});
|
|
@@ -668,6 +902,7 @@ async function runAgentTurn(history) {
|
|
|
668
902
|
cancelRequested = false;
|
|
669
903
|
const startedAt = Date.now();
|
|
670
904
|
let shownChars = 0;
|
|
905
|
+
let lastApprovalId = null; // the last command-approval request we've already answered
|
|
671
906
|
let carry = ""; // trailing partial content line held until its newline arrives
|
|
672
907
|
let statusShown = false; // a transient status line is currently on screen
|
|
673
908
|
let warnedPending = false;
|
|
@@ -705,15 +940,6 @@ async function runAgentTurn(history) {
|
|
|
705
940
|
// random word. `fenceThought` accumulates the raw in-fence text for the current step.
|
|
706
941
|
let liveThought = "";
|
|
707
942
|
let fenceThought = "";
|
|
708
|
-
// First non-empty Thought clause: drop the "Thought:" prefix, stop at the <code> block
|
|
709
|
-
// (we never show code), take the first line, and cap length so it never wraps.
|
|
710
|
-
const extractThought = (buf) => {
|
|
711
|
-
let t = String(buf || "");
|
|
712
|
-
const ci = t.search(/<code[\s>]/i);
|
|
713
|
-
if (ci >= 0) t = t.slice(0, ci);
|
|
714
|
-
t = t.replace(/^\s*Thought:\s*/i, "").split(/\n/)[0].trim();
|
|
715
|
-
return t;
|
|
716
|
-
};
|
|
717
943
|
const thinkSecs = () => Math.max(1, Math.round((Date.now() - thinkingSince) / 1000));
|
|
718
944
|
|
|
719
945
|
// The status is TWO in-place lines — the in-progress action, then the playful word
|
|
@@ -727,6 +953,7 @@ async function runAgentTurn(history) {
|
|
|
727
953
|
|
|
728
954
|
const tick = () => {
|
|
729
955
|
if (!following || followAborted || !jobRunning) return;
|
|
956
|
+
if (approvalActive) return; // a Yes/No picker owns the screen — don't draw over it
|
|
730
957
|
if (Date.now() - lastContentAt < QUIET_MS) return; // tokens still flowing
|
|
731
958
|
// A plain-reply answer streams onto a line with NO trailing newline until it's fully
|
|
732
959
|
// done — overwriting the ticker there corrupts the visible answer mid-word. Stay
|
|
@@ -817,9 +1044,12 @@ async function runAgentTurn(history) {
|
|
|
817
1044
|
// Thought clause for the blinking line (task #64) — up to the <code> block, which is
|
|
818
1045
|
// where the prose ends and the tool call begins (we never surface code here).
|
|
819
1046
|
if (inStreamFence) {
|
|
820
|
-
|
|
1047
|
+
// Accumulate a bounded head of the stream (enough to see the Thought prose OR the
|
|
1048
|
+
// tool call), capped so a 16k-char runaway can't grow the buffer. normalizeThought
|
|
1049
|
+
// yields a clean label — never raw code/CSS.
|
|
1050
|
+
if (fenceThought.length < 400) {
|
|
821
1051
|
fenceThought += line + "\n";
|
|
822
|
-
const th =
|
|
1052
|
+
const th = normalizeThought(fenceThought);
|
|
823
1053
|
if (th) liveThought = th;
|
|
824
1054
|
}
|
|
825
1055
|
continue;
|
|
@@ -955,8 +1185,8 @@ async function runAgentTurn(history) {
|
|
|
955
1185
|
// no newline yet (a long reasoning sentence streams token-by-token) — recompute the
|
|
956
1186
|
// live clause from the partial too, so the blinking line updates mid-sentence instead
|
|
957
1187
|
// of only once the line finally breaks (task #64).
|
|
958
|
-
if (inStreamFence &&
|
|
959
|
-
const th =
|
|
1188
|
+
if (inStreamFence && fenceThought.length < 400) {
|
|
1189
|
+
const th = normalizeThought(fenceThought + carry);
|
|
960
1190
|
if (th) liveThought = th;
|
|
961
1191
|
}
|
|
962
1192
|
// A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
|
|
@@ -1030,6 +1260,26 @@ async function runAgentTurn(history) {
|
|
|
1030
1260
|
consume(text.slice(shownChars));
|
|
1031
1261
|
shownChars = text.length;
|
|
1032
1262
|
}
|
|
1263
|
+
// Interactive command-approval gate: the agent paused on a risky command. Show the
|
|
1264
|
+
// Yes/No picker once per request id, POST the choice, then let the agent resume.
|
|
1265
|
+
if (
|
|
1266
|
+
job.pendingApproval &&
|
|
1267
|
+
job.pendingApproval.id &&
|
|
1268
|
+
job.pendingApproval.id !== lastApprovalId
|
|
1269
|
+
) {
|
|
1270
|
+
lastApprovalId = job.pendingApproval.id;
|
|
1271
|
+
clearStatus();
|
|
1272
|
+
const allow = await approvalPrompt(job.pendingApproval.command || "");
|
|
1273
|
+
try {
|
|
1274
|
+
await fetch(`${apiBase}/api/worker/jobs/${jobId}/approval`, {
|
|
1275
|
+
method: "POST",
|
|
1276
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
1277
|
+
body: JSON.stringify({ id: lastApprovalId, allow }),
|
|
1278
|
+
});
|
|
1279
|
+
} catch {
|
|
1280
|
+
// Best-effort — if the POST fails, the python side times out and treats as deny.
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1033
1283
|
if (job.jobStatus === "cancelled") {
|
|
1034
1284
|
// User-requested stop (Ctrl+C) — a clean outcome, not an error. The worker has
|
|
1035
1285
|
// killed the Python agent. Print a quiet note and return an empty, non-persisted
|
|
@@ -1071,6 +1321,8 @@ async function runAgentTurn(history) {
|
|
|
1071
1321
|
async function main() {
|
|
1072
1322
|
console.log(cyan("GoNext agent REPL") + dim(` · API ${apiBase} · key ${workerKey.slice(0, 8)}…`));
|
|
1073
1323
|
await ensureWorkspace();
|
|
1324
|
+
// Restore this folder's remembered auto-test choice so the banner + agent-ask reflect it.
|
|
1325
|
+
sessionTestAuto = await loadSessionTestAuto(resolve(process.cwd()));
|
|
1074
1326
|
// Show which models will answer, straight from user settings (also validates auth).
|
|
1075
1327
|
try {
|
|
1076
1328
|
const probe = await fetchAgentPayload([{ role: "user", content: "ping" }]);
|
|
@@ -1079,8 +1331,9 @@ async function main() {
|
|
|
1079
1331
|
dim(
|
|
1080
1332
|
`agent model: ${p.agentModelId || probe?.modelKey || "?"}` +
|
|
1081
1333
|
(p.codingModelId ? ` · coder: ${p.codingModelId}` : "") +
|
|
1082
|
-
(p.ragEnabled ? " · RAG on" : "")
|
|
1083
|
-
|
|
1334
|
+
(p.ragEnabled ? " · RAG on" : "") +
|
|
1335
|
+
` · auto-test: ${sessionTestAuto ? "on" : "off"}`
|
|
1336
|
+
) + (sessionTestAuto ? "" : dim(" (/test-auto to enable)"))
|
|
1084
1337
|
);
|
|
1085
1338
|
} catch (err) {
|
|
1086
1339
|
console.error(red(`gonext: ${err.message}`));
|
|
@@ -1102,6 +1355,9 @@ async function main() {
|
|
|
1102
1355
|
// the OS delivers a real SIGINT to the process. Attach the same handler to both —
|
|
1103
1356
|
// only one can ever fire for a given mode, so there's no double-handling.
|
|
1104
1357
|
const onInterrupt = () => {
|
|
1358
|
+
// Ctrl+C while the approval picker is up = decline it (and fall through to also
|
|
1359
|
+
// cancel the turn) — otherwise the poll loop stays blocked awaiting a choice.
|
|
1360
|
+
if (approvalActive) finishApproval(false);
|
|
1105
1361
|
if (following) {
|
|
1106
1362
|
if (!cancelRequested && currentJobId) {
|
|
1107
1363
|
// First Ctrl+C: actually CANCEL the running turn server-side (stop the model),
|
|
@@ -1157,6 +1413,23 @@ async function main() {
|
|
|
1157
1413
|
await chooseModel();
|
|
1158
1414
|
continue;
|
|
1159
1415
|
}
|
|
1416
|
+
if (line === "/server") {
|
|
1417
|
+
await chooseServer();
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
if (line === "/test-auto") {
|
|
1421
|
+
sessionTestAuto = !sessionTestAuto;
|
|
1422
|
+
await saveSession(cwd, history); // persist the choice for this folder
|
|
1423
|
+
console.log(
|
|
1424
|
+
(sessionTestAuto ? green("auto-test: ON") : dim("auto-test: OFF")) +
|
|
1425
|
+
dim(
|
|
1426
|
+
sessionTestAuto
|
|
1427
|
+
? " — the agent will verify its work and fix until it passes, for this folder.\n"
|
|
1428
|
+
: " for this folder.\n"
|
|
1429
|
+
)
|
|
1430
|
+
);
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1160
1433
|
if (line === "/reset" || line === "/new") {
|
|
1161
1434
|
history.length = 0;
|
|
1162
1435
|
await clearSession(cwd);
|
package/gonext_agent_chat.py
CHANGED
|
@@ -2227,6 +2227,85 @@ _WS_RUN_DENY_ALWAYS = {"sudo", "su", "doas"} # privilege escalation — never r
|
|
|
2227
2227
|
_WS_RUN_ALLOWLIST = set() # opt-in lockdown from cfg; empty = allow all
|
|
2228
2228
|
_WS_RUN_DENYLIST = set() # extra user blocks from cfg
|
|
2229
2229
|
|
|
2230
|
+
# Privilege escalation, scanned on the RAW command (not just argv[0]) so it also catches
|
|
2231
|
+
# `bash -c "sudo …"` / `env sudo …` — the naive shell-wrap bypass a model reaches for.
|
|
2232
|
+
_WS_PRIV_RE = re.compile(r"\b(sudo|doas|su)\b", re.IGNORECASE)
|
|
2233
|
+
# Destructive: mass delete, disk wipe, or power-off. Allowed by default (allow-by-default
|
|
2234
|
+
# policy) but worth a confirmation when we CAN ask the user (interactive terminal).
|
|
2235
|
+
_WS_DESTRUCTIVE_RE = re.compile(
|
|
2236
|
+
r"(^|[\s;&|])rm\b[^|;&\n]*\s-[a-z]*[rf]" # rm with -r / -f (any order)
|
|
2237
|
+
r"|(^|[\s;&|])(dd|shred|wipefs|mkfs(\.\w+)?)\b" # disk destroyers
|
|
2238
|
+
r"|(^|[\s;&|])(shutdown|reboot|halt|poweroff)\b", # power
|
|
2239
|
+
re.IGNORECASE,
|
|
2240
|
+
)
|
|
2241
|
+
|
|
2242
|
+
|
|
2243
|
+
def _ws_command_risk(command, argv, denyset):
|
|
2244
|
+
"""Classify a command for the run policy. Returns (hard_reason, ask_reason), either
|
|
2245
|
+
None when N/A:
|
|
2246
|
+
- hard_reason: block outright when we CAN'T ask (web / non-interactive) — privilege
|
|
2247
|
+
escalation or a user-denylisted runner.
|
|
2248
|
+
- ask_reason: prompt the user when we CAN (interactive terminal) — the above PLUS
|
|
2249
|
+
destructive filesystem/power commands (which are otherwise allowed by default)."""
|
|
2250
|
+
import os as _os
|
|
2251
|
+
cmd = command or ""
|
|
2252
|
+
runner = argv[0] if argv else ""
|
|
2253
|
+
rbase = _os.path.basename(runner)
|
|
2254
|
+
if _WS_PRIV_RE.search(cmd):
|
|
2255
|
+
r = "privilege escalation (sudo/su)"
|
|
2256
|
+
return (r, r)
|
|
2257
|
+
if denyset and (runner in denyset or rbase in denyset):
|
|
2258
|
+
r = f"'{runner}' is on your blocked-commands list"
|
|
2259
|
+
return (r, r)
|
|
2260
|
+
if _WS_DESTRUCTIVE_RE.search(cmd):
|
|
2261
|
+
return (None, "a destructive command (deletes files / wipes disk / powers off)")
|
|
2262
|
+
return (None, None)
|
|
2263
|
+
|
|
2264
|
+
|
|
2265
|
+
def _ws_request_approval(api_base, worker_key, job_id, command, reason):
|
|
2266
|
+
"""Pause and ask the user (via the terminal REPL's Yes/No picker) to allow a risky
|
|
2267
|
+
command. Registers a pending approval on the job through the API, then polls the job
|
|
2268
|
+
for the user's decision. Returns True (allow) or False (deny / timeout / cancelled).
|
|
2269
|
+
Mirrors _pdf_upload_via_api's worker-key auth — no new creds on the worker."""
|
|
2270
|
+
import urllib.request as _u
|
|
2271
|
+
import uuid as _uuid
|
|
2272
|
+
import time as _t
|
|
2273
|
+
base = (api_base or "").rstrip("/")
|
|
2274
|
+
if not base or not worker_key or not job_id:
|
|
2275
|
+
return False
|
|
2276
|
+
rid = _uuid.uuid4().hex[:12]
|
|
2277
|
+
ctx = _ssl_context()
|
|
2278
|
+
try:
|
|
2279
|
+
req = _u.Request(
|
|
2280
|
+
f"{base}/api/worker/jobs/{job_id}/approval-request",
|
|
2281
|
+
data=json.dumps({"id": rid, "command": command}).encode("utf-8"),
|
|
2282
|
+
headers={"Content-Type": "application/json", "X-Worker-Key": worker_key},
|
|
2283
|
+
method="POST",
|
|
2284
|
+
)
|
|
2285
|
+
with _u.urlopen(req, timeout=20, context=ctx) as resp:
|
|
2286
|
+
resp.read()
|
|
2287
|
+
except Exception as e: # noqa: BLE001
|
|
2288
|
+
_log(f"approval-request failed: {e} — treating as deny")
|
|
2289
|
+
return False
|
|
2290
|
+
_emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
|
|
2291
|
+
deadline = _t.time() + 180 # 3 min; absent user → deny (never auto-run something risky)
|
|
2292
|
+
while _t.time() < deadline:
|
|
2293
|
+
_t.sleep(1.2)
|
|
2294
|
+
try:
|
|
2295
|
+
g = _u.Request(f"{base}/api/worker/jobs/{job_id}",
|
|
2296
|
+
headers={"X-Worker-Key": worker_key}, method="GET")
|
|
2297
|
+
with _u.urlopen(g, timeout=15, context=ctx) as resp:
|
|
2298
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
2299
|
+
except Exception as e: # noqa: BLE001
|
|
2300
|
+
_log(f"approval poll error: {e}")
|
|
2301
|
+
continue
|
|
2302
|
+
if data.get("jobStatus") in ("cancelled", "failed", "completed"):
|
|
2303
|
+
return False # Ctrl+C or the job ended → treat as declined
|
|
2304
|
+
dec = data.get("approvalDecision") or {}
|
|
2305
|
+
if dec.get("id") == rid:
|
|
2306
|
+
return bool(dec.get("allow"))
|
|
2307
|
+
return False
|
|
2308
|
+
|
|
2230
2309
|
# ---- background servers (behavior-detected, command-agnostic) ----
|
|
2231
2310
|
# run_command classifies a command by OBSERVED BEHAVIOR, never by its text: if the
|
|
2232
2311
|
# process keeps running AND starts LISTENing on a TCP port, it IS a server — npm start,
|
|
@@ -2414,6 +2493,18 @@ def run_agent_chat(cfg):
|
|
|
2414
2493
|
# For the create_pdf tool: API base + worker key to request a presigned S3 upload.
|
|
2415
2494
|
pdf_api_base = (cfg.get("apiBaseURL") or "").strip()
|
|
2416
2495
|
pdf_worker_key = (cfg.get("workerKey") or "").strip()
|
|
2496
|
+
# Interactive command-approval gate: this job's id + whether the client (terminal
|
|
2497
|
+
# REPL) can show a Yes/No picker. When both are present, run_command PAUSES on a
|
|
2498
|
+
# risky command and asks the user via the API instead of hard-blocking it.
|
|
2499
|
+
_job_id = (cfg.get("jobId") or "").strip()
|
|
2500
|
+
_interactive_approval = bool(cfg.get("interactiveApproval"))
|
|
2501
|
+
# Auto-test mode (/test-auto): after making changes the agent must VERIFY them and
|
|
2502
|
+
# fix → re-test until they pass before finishing. Drives a prompt directive + a
|
|
2503
|
+
# slightly higher step budget (the verify loop needs room). Terminal-only.
|
|
2504
|
+
_auto_test = bool(cfg.get("autoTest"))
|
|
2505
|
+
# Deploy target chosen via the terminal /server picker (task #69): {name, host, user}
|
|
2506
|
+
# or None. Host/user only — never a secret; deploys use SSH KEY auth to this host.
|
|
2507
|
+
_deploy_server = cfg.get("deployServer") if isinstance(cfg.get("deployServer"), dict) else None
|
|
2417
2508
|
# Optional dedicated coding/reasoning model for the CodeAgent's tool-use loop.
|
|
2418
2509
|
# Routing, plain replies and summarization stay on the chat model (better at
|
|
2419
2510
|
# natural language); the code model only drives http_request reasoning.
|
|
@@ -2647,6 +2738,12 @@ def run_agent_chat(cfg):
|
|
|
2647
2738
|
if not cfg.get("maxSteps") and max_steps < 12:
|
|
2648
2739
|
max_steps = 12
|
|
2649
2740
|
_log("workspace registered → step budget raised to 12 (no explicit maxSteps)")
|
|
2741
|
+
# Auto-test adds a verify → fix → re-test cycle on top of the work itself, so give
|
|
2742
|
+
# it more room (unless the user pinned a budget). Kept modest so a stuck test loop
|
|
2743
|
+
# still terminates rather than burning a huge budget.
|
|
2744
|
+
if _auto_test and not cfg.get("maxSteps") and max_steps < 16:
|
|
2745
|
+
max_steps = 16
|
|
2746
|
+
_log("auto-test on → step budget raised to 16 (no explicit maxSteps)")
|
|
2650
2747
|
|
|
2651
2748
|
def _rag_s3_and_loc():
|
|
2652
2749
|
client = _rag_s3_client(rag_region, rag_akid, rag_secret)
|
|
@@ -3014,6 +3111,43 @@ def run_agent_chat(cfg):
|
|
|
3014
3111
|
"- 'email …' / 'send an email to …' -> send_email(to, subject, body) "
|
|
3015
3112
|
"(previews first, then sends on confirm).\n"
|
|
3016
3113
|
) if _EMAIL_AVAILABLE else ""
|
|
3114
|
+
# Auto-test mode (/test-auto): a directive that makes the agent VERIFY its changes
|
|
3115
|
+
# and fix → re-test until they pass. Only meaningful for workspace code work; empty
|
|
3116
|
+
# otherwise so it costs no prompt-eval when off. Climbs the cheapest-sufficient
|
|
3117
|
+
# verification ladder and bakes in the guardrails (freeze the pass criterion, ignore
|
|
3118
|
+
# flakes, prove the test ran, cap retries) so it converges instead of chasing green.
|
|
3119
|
+
_auto_test_block = (
|
|
3120
|
+
"\nAUTO-TEST MODE (ON): after you CHANGE code you MUST verify it before "
|
|
3121
|
+
"final_answer — never finish on an untested change. Use the CHEAPEST check that "
|
|
3122
|
+
"proves the change, climbing only as needed:\n"
|
|
3123
|
+
" 1. grep_repo the edited file to confirm the change landed (and the old code is gone).\n"
|
|
3124
|
+
" 2. build / typecheck (e.g. run_command('npm run build') / 'tsc --noEmit' / 'go build').\n"
|
|
3125
|
+
" 3. run the project's tests (npm test / pytest / …) if they exist.\n"
|
|
3126
|
+
" 4. if it's a running app, start it and curl/fetch_url it, then check the response "
|
|
3127
|
+
"contains what the change should produce.\n"
|
|
3128
|
+
"DECIDE what 'passes' BEFORE you fix — do NOT weaken the check to make it green. If a "
|
|
3129
|
+
"check FAILS: read the error, make ONE fix, then RE-TEST the SAME way. If it passes only "
|
|
3130
|
+
"after re-running with no change, it was flaky — stop, don't keep editing. Give up after "
|
|
3131
|
+
"3 fix attempts on the same failure and final_answer honestly what still fails. Confirm a "
|
|
3132
|
+
"test actually exercised the change (e.g. the asserted text was really present), not an "
|
|
3133
|
+
"empty/no-op pass.\n"
|
|
3134
|
+
if (_auto_test and _WS_AVAILABLE) else ""
|
|
3135
|
+
)
|
|
3136
|
+
# Deploy target (/server): when the user picked one, tell the agent to deploy THERE
|
|
3137
|
+
# with key auth. Host/user only — the block never contains a password.
|
|
3138
|
+
_deploy_block = ""
|
|
3139
|
+
if _deploy_server and _deploy_server.get("host") and _deploy_server.get("user"):
|
|
3140
|
+
_dh = str(_deploy_server.get("host")).strip()
|
|
3141
|
+
_du = str(_deploy_server.get("user")).strip()
|
|
3142
|
+
_dn = str(_deploy_server.get("name") or _dh).strip()
|
|
3143
|
+
_deploy_block = (
|
|
3144
|
+
f"\nDEPLOY TARGET (the user selected server '{_dn}'): host {_dh}, user {_du}. "
|
|
3145
|
+
f"When the task is about DEPLOYING, deploy to THIS server — deploy_web(local_dir, "
|
|
3146
|
+
f"host='{_dh}', user='{_du}', remote_path=...) or ssh/scp/rsync to {_du}@{_dh}. "
|
|
3147
|
+
"KEY auth ONLY (-o BatchMode=yes); NEVER ask for or type a password. If key auth "
|
|
3148
|
+
f"isn't set up (Permission denied publickey,password), STOP and tell the user to "
|
|
3149
|
+
f"run 'ssh-copy-id {_du}@{_dh}' once, then retry.\n"
|
|
3150
|
+
)
|
|
3017
3151
|
tool_hint = (
|
|
3018
3152
|
f"Solve the TASK step by step (up to {max_steps} steps). At EACH step write a "
|
|
3019
3153
|
"brief Thought, then ONE code block that calls a SINGLE tool. You will SEE that "
|
|
@@ -3024,7 +3158,7 @@ def run_agent_chat(cfg):
|
|
|
3024
3158
|
"{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
|
|
3025
3159
|
f"(Current date/time: {now_str} — pass a timezone to get_current_datetime() only "
|
|
3026
3160
|
"if the task needs a DIFFERENT one.)\n"
|
|
3027
|
-
+ _rag_tool_block + _ws_tool_block +
|
|
3161
|
+
+ _rag_tool_block + _ws_tool_block + _auto_test_block + _deploy_block +
|
|
3028
3162
|
"\n"
|
|
3029
3163
|
"http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
|
|
3030
3164
|
"\n"
|
|
@@ -4629,26 +4763,36 @@ def run_agent_chat(cfg):
|
|
|
4629
4763
|
msg = "Error: empty command."
|
|
4630
4764
|
_last_obs["text"] = msg
|
|
4631
4765
|
return msg
|
|
4632
|
-
# Allow-by-default policy
|
|
4633
|
-
#
|
|
4766
|
+
# Allow-by-default policy. Match on the runner's basename too, so
|
|
4767
|
+
# `/usr/bin/<x>` can't slip past a bare-name rule.
|
|
4634
4768
|
_runner = argv[0]
|
|
4635
4769
|
_rbase = os.path.basename(_runner)
|
|
4636
|
-
|
|
4637
|
-
msg = (f"Error: '{_runner}' is blocked (privilege escalation is never "
|
|
4638
|
-
"run by the agent). Run that step yourself, or tell the user the "
|
|
4639
|
-
"exact command to run.")
|
|
4640
|
-
_last_obs["text"] = msg
|
|
4641
|
-
return msg
|
|
4642
|
-
if _WS_RUN_DENYLIST and (_runner in _WS_RUN_DENYLIST or _rbase in _WS_RUN_DENYLIST):
|
|
4643
|
-
msg = (f"Error: '{_runner}' is blocked by your run denylist "
|
|
4644
|
-
"(change it in web Settings → Agent).")
|
|
4645
|
-
_last_obs["text"] = msg
|
|
4646
|
-
return msg
|
|
4770
|
+
# Opt-in allowlist lockdown: outside the set = hard refuse, never asked.
|
|
4647
4771
|
if _WS_RUN_ALLOWLIST and _runner not in _WS_RUN_ALLOWLIST and _rbase not in _WS_RUN_ALLOWLIST:
|
|
4648
4772
|
msg = (f"Error: '{_runner}' is not in your run allowlist. Add it in web "
|
|
4649
4773
|
"Settings → Agent, or clear the allowlist to allow all commands.")
|
|
4650
4774
|
_last_obs["text"] = msg
|
|
4651
4775
|
return msg
|
|
4776
|
+
# Risk gate: privilege escalation / denylisted / destructive. When the
|
|
4777
|
+
# client can approve interactively (terminal), PAUSE and ask the user;
|
|
4778
|
+
# otherwise (web) keep hard-blocking the privilege/denylist cases.
|
|
4779
|
+
_hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST)
|
|
4780
|
+
if _ask_reason:
|
|
4781
|
+
if _interactive_approval and _job_id and pdf_api_base and pdf_worker_key:
|
|
4782
|
+
if not _ws_request_approval(pdf_api_base, pdf_worker_key,
|
|
4783
|
+
_job_id, command, _ask_reason):
|
|
4784
|
+
msg = (f"Error: the user declined to run '{command[:80]}' "
|
|
4785
|
+
f"({_ask_reason}). Do NOT retry it — choose another "
|
|
4786
|
+
"approach or ask the user what to do.")
|
|
4787
|
+
_last_obs["text"] = msg
|
|
4788
|
+
return msg
|
|
4789
|
+
# approved → fall through and run it
|
|
4790
|
+
elif _hard_reason:
|
|
4791
|
+
msg = (f"Error: '{command[:80]}' is blocked ({_hard_reason}). Run "
|
|
4792
|
+
"that step yourself, or tell the user the exact command.")
|
|
4793
|
+
_last_obs["text"] = msg
|
|
4794
|
+
return msg
|
|
4795
|
+
# else: destructive but non-interactive → allowed by default (unchanged)
|
|
4652
4796
|
_emit({"type": "step", "text": f"Running → {command[:70]}"})
|
|
4653
4797
|
t = max(5, min(int(timeout_seconds or 180), 600))
|
|
4654
4798
|
# Own process group (start_new_session) + output to a log file. This is
|
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.229",
|
|
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",
|