@tiens.nguyen/gonext-local-worker 1.0.335 → 1.0.343

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.
@@ -0,0 +1,228 @@
1
+ // Worker-daemon lifecycle for the gonext CLI (task #127, Phase 2). Lets a plain terminal
2
+ // keep the polling worker running WITHOUT the GoTerminal desktop app: `gonext-local-worker
3
+ // start|stop|status|logs`, and `gonext` (the REPL) auto-ensures it on startup.
4
+ //
5
+ // BC4 (task #127): NEVER double-spawn. A second polling daemon would race the first for
6
+ // jobs. We detect an already-running daemon two ways — our own pidfile AND a `ps` scan that
7
+ // also catches a daemon started by GoTerminal or by hand (which never wrote our pidfile) —
8
+ // and refuse to start when either says one is alive.
9
+ import { spawn, execFile } from "node:child_process";
10
+ import { readFile, writeFile, unlink, mkdir, open, stat } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const DIR = join(homedir(), ".gonext");
16
+ export const PID_FILE = join(DIR, "worker.pid");
17
+ export const LOG_FILE = join(DIR, "worker.log");
18
+ const LOCK_FILE = join(DIR, "worker.start.lock");
19
+ const LOCK_STALE_MS = 30_000; // a lock older than this is from a crashed start → steal it
20
+ const DAEMON_SCRIPT = fileURLToPath(new URL("./gonext-local-worker.mjs", import.meta.url));
21
+
22
+ // Any cmdline containing one of these is a SUBCOMMAND invocation (login, start, …), not the
23
+ // polling daemon — so it must not be mistaken for a running worker in the ps scan.
24
+ const SUBCOMMANDS = [
25
+ "login", "set", "start", "stop", "status", "logs", "restart",
26
+ "ws-ping-test", "simulate-chat", "embed", "workspace",
27
+ ];
28
+
29
+ function pidAlive(pid) {
30
+ if (!pid || !Number.isFinite(pid)) return false;
31
+ try {
32
+ process.kill(pid, 0);
33
+ return true;
34
+ } catch (e) {
35
+ // EPERM = the process exists but we can't signal it (still "alive" for our purposes).
36
+ return e && e.code === "EPERM";
37
+ }
38
+ }
39
+
40
+ function psList() {
41
+ return new Promise((resolve) => {
42
+ execFile(
43
+ "ps",
44
+ ["-A", "-o", "pid=,command="],
45
+ { maxBuffer: 8 * 1024 * 1024 },
46
+ (err, stdout) => {
47
+ if (err) return resolve([]);
48
+ const rows = [];
49
+ for (const line of String(stdout).split("\n")) {
50
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
51
+ if (m) rows.push({ pid: Number(m[1]), cmd: m[2] });
52
+ }
53
+ resolve(rows);
54
+ }
55
+ );
56
+ });
57
+ }
58
+
59
+ /**
60
+ * PIDs of polling worker daemons currently running (excluding THIS process and any
61
+ * subcommand invocation). Catches a daemon started by GoTerminal / by hand, not just ours.
62
+ */
63
+ export async function findRunningDaemonPids() {
64
+ const rows = await psList();
65
+ const self = process.pid;
66
+ return rows
67
+ .filter((r) => r.pid !== self)
68
+ .filter((r) => /gonext-local-worker\.mjs(\s|$)/.test(r.cmd))
69
+ .filter((r) => !SUBCOMMANDS.some((sc) => new RegExp(`(\\s|/)${sc}(\\s|$)`).test(r.cmd)))
70
+ .map((r) => r.pid);
71
+ }
72
+
73
+ /**
74
+ * True when `pid` is actually a gonext polling daemon (not an unrelated process that reused
75
+ * the PID). Reads that one pid's command line — cheap and exact (review M1).
76
+ */
77
+ function pidIsGonextDaemon(pid) {
78
+ if (!pid || !Number.isFinite(pid)) return Promise.resolve(false);
79
+ return new Promise((resolve) => {
80
+ execFile("ps", ["-p", String(pid), "-o", "command="], (err, stdout) => {
81
+ const cmd = String(stdout || "").trim();
82
+ resolve(
83
+ !err &&
84
+ /gonext-local-worker\.mjs(\s|$)/.test(cmd) &&
85
+ !SUBCOMMANDS.some((sc) => new RegExp(`(\\s|/)${sc}(\\s|$)`).test(cmd))
86
+ );
87
+ });
88
+ });
89
+ }
90
+
91
+ async function readPidFile() {
92
+ try {
93
+ const n = Number(String(await readFile(PID_FILE, "utf8")).trim());
94
+ return Number.isFinite(n) ? n : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * { running, pids, pidFilePid }. The `ps` scan is the primary signal. The pidfile is trusted
102
+ * ONLY after VERIFYING that its PID is genuinely a gonext daemon (review M1) — this both
103
+ * rescues the case where the full-table scan missed it and rejects a stale pidfile whose PID
104
+ * was reused by an unrelated process (which would otherwise wrongly suppress auto-start).
105
+ */
106
+ export async function daemonStatus() {
107
+ const [pids, pidFilePid] = await Promise.all([findRunningDaemonPids(), readPidFile()]);
108
+ const pidFileIsDaemon =
109
+ pidFilePid != null && (pids.includes(pidFilePid) || (await pidIsGonextDaemon(pidFilePid)));
110
+ const allPids = pidFileIsDaemon && !pids.includes(pidFilePid) ? [...pids, pidFilePid] : pids;
111
+ return {
112
+ running: allPids.length > 0,
113
+ pids: allPids,
114
+ pidFilePid: pidFileIsDaemon ? pidFilePid : null,
115
+ };
116
+ }
117
+
118
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
119
+
120
+ /**
121
+ * Acquire an exclusive single-flight lock over the check→spawn window (review L2). O_EXCL
122
+ * (`wx`) is atomic, so of two concurrent starts exactly one wins. A lock left by a crashed
123
+ * start is stolen once it's older than LOCK_STALE_MS so it can never wedge startup.
124
+ * Returns a handle whose .release() removes the lock, or null if another start holds it.
125
+ */
126
+ async function acquireStartLock() {
127
+ await mkdir(DIR, { recursive: true });
128
+ for (let attempt = 0; attempt < 2; attempt++) {
129
+ try {
130
+ const fh = await open(LOCK_FILE, "wx");
131
+ await fh.writeFile(String(process.pid), "utf8");
132
+ return {
133
+ release: async () => {
134
+ await fh.close().catch(() => {});
135
+ await unlink(LOCK_FILE).catch(() => {});
136
+ },
137
+ };
138
+ } catch (e) {
139
+ if (!e || e.code !== "EEXIST") throw e;
140
+ // Held by someone else — steal it only if it's stale (a crashed start), then retry.
141
+ let age = 0;
142
+ try {
143
+ age = Date.now() - (await stat(LOCK_FILE)).mtimeMs;
144
+ } catch {
145
+ age = LOCK_STALE_MS + 1; // vanished between EEXIST and stat → treat as free
146
+ }
147
+ if (age > LOCK_STALE_MS) {
148
+ await unlink(LOCK_FILE).catch(() => {});
149
+ continue; // retry the atomic create
150
+ }
151
+ return null; // a fresh lock → another start is genuinely in progress
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * Start the polling daemon DETACHED (survives the launching shell, logs to ~/.gonext/
159
+ * worker.log). No-op when one is already running (BC4). Concurrency-safe (L2): a lock
160
+ * serializes the check→spawn window so two simultaneous starts can't both spawn.
161
+ * Returns { started, pid?, pids }.
162
+ */
163
+ export async function startDaemon() {
164
+ // Fast path: already running → no lock needed.
165
+ const pre = await daemonStatus();
166
+ if (pre.running) return { started: false, ...pre };
167
+ const lock = await acquireStartLock();
168
+ if (!lock) {
169
+ // A concurrent start holds the lock — let it win, then report the resulting state.
170
+ await sleep(700);
171
+ const s = await daemonStatus();
172
+ return { started: false, ...s };
173
+ }
174
+ try {
175
+ // Re-check UNDER the lock — the winner of a race must see the loser's daemon if it
176
+ // spawned in the gap (and vice-versa this is where the second caller no-ops).
177
+ const status = await daemonStatus();
178
+ if (status.running) return { started: false, ...status };
179
+ const out = await open(LOG_FILE, "a");
180
+ try {
181
+ const child = spawn(process.execPath, [DAEMON_SCRIPT], {
182
+ detached: true,
183
+ stdio: ["ignore", out.fd, out.fd],
184
+ env: process.env,
185
+ });
186
+ await writeFile(PID_FILE, String(child.pid ?? ""), "utf8");
187
+ child.unref();
188
+ return { started: true, pid: child.pid, pids: [] };
189
+ } finally {
190
+ await out.close();
191
+ }
192
+ } finally {
193
+ await lock.release();
194
+ }
195
+ }
196
+
197
+ /** Stop every running daemon (ours + any ps-found) and clear the pidfile. */
198
+ export async function stopDaemon() {
199
+ const status = await daemonStatus();
200
+ const targets = new Set(status.pids);
201
+ if (status.pidFilePid) targets.add(status.pidFilePid);
202
+ let killed = 0;
203
+ for (const pid of targets) {
204
+ try {
205
+ process.kill(pid, "SIGTERM");
206
+ killed++;
207
+ } catch {
208
+ /* already gone */
209
+ }
210
+ }
211
+ try {
212
+ await unlink(PID_FILE);
213
+ } catch {
214
+ /* no pidfile */
215
+ }
216
+ return { killed };
217
+ }
218
+
219
+ /** Last `n` lines of the daemon log (empty string when none yet). */
220
+ export async function tailLog(n = 40) {
221
+ try {
222
+ const txt = await readFile(LOG_FILE, "utf8");
223
+ const lines = txt.split("\n");
224
+ return lines.slice(Math.max(0, lines.length - n)).join("\n");
225
+ } catch {
226
+ return "";
227
+ }
228
+ }
@@ -0,0 +1,141 @@
1
+ // Device-pairing login for the gonext CLI (task #127, Phase 1). Shared by both bins
2
+ // (gonext-local-worker.mjs and gonext-repl.mjs) so `gonext login` and the first-run
3
+ // auto-prompt use the exact same flow. No secrets live here — the CLI obtains a worker key
4
+ // only after an authenticated human approves in the browser.
5
+ import { execFile } from "node:child_process";
6
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
7
+ import { homedir, platform } from "node:os";
8
+ import { join } from "node:path";
9
+ import dotenv from "dotenv";
10
+
11
+ export const DEFAULT_API_BASE = "https://chat-api-v2.gomarsic.cc";
12
+ const ENV_FILE = join(homedir(), ".gonext", "worker.env");
13
+ const POLL_INTERVAL_MS = 2500;
14
+ const POLL_TIMEOUT_MS = 10 * 60_000; // matches the server's pairing TTL
15
+
16
+ /** True when worker.env already has BOTH a key and an API base (task #127 BC1a). */
17
+ export async function hasWorkerConfig() {
18
+ try {
19
+ const parsed = dotenv.parse(await readFile(ENV_FILE, "utf8"));
20
+ return Boolean(
21
+ String(parsed.GONEXT_WORKER_KEY ?? "").trim() &&
22
+ String(parsed.GONEXT_API_BASE ?? "").trim()
23
+ );
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /** Write ~/.gonext/worker.env, preserving any existing GONEXT_POLL_MS. */
30
+ async function writeWorkerEnv(apiBase, workerKey) {
31
+ let pollMs = "500";
32
+ try {
33
+ const prev = dotenv.parse(await readFile(ENV_FILE, "utf8"));
34
+ if (String(prev.GONEXT_POLL_MS ?? "").trim()) pollMs = String(prev.GONEXT_POLL_MS).trim();
35
+ } catch {
36
+ /* first-time write */
37
+ }
38
+ await mkdir(join(homedir(), ".gonext"), { recursive: true });
39
+ await writeFile(
40
+ ENV_FILE,
41
+ `GONEXT_API_BASE=${apiBase.replace(/\/+$/, "")}\nGONEXT_WORKER_KEY=${workerKey}\nGONEXT_POLL_MS=${pollMs}\n`,
42
+ { encoding: "utf8", mode: 0o600 }
43
+ );
44
+ return ENV_FILE;
45
+ }
46
+
47
+ function openBrowser(url) {
48
+ const cmd =
49
+ platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
50
+ try {
51
+ execFile(cmd, [url], () => {}); // best-effort; the URL is also printed
52
+ } catch {
53
+ /* the user can open it manually */
54
+ }
55
+ }
56
+
57
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
58
+
59
+ /**
60
+ * Run the browser device-pairing flow and write worker.env on success.
61
+ *
62
+ * Returns { apiBase, workerKey, envFile } on success.
63
+ * Throws Error with .code === "PAIRING_UNSUPPORTED" when the API has no /pair routes
64
+ * (404), so callers can fall back to the manual `set` path (task #127 BC7).
65
+ * Throws Error with .code === "PAIRING_TIMEOUT" / "PAIRING_EXPIRED" otherwise.
66
+ *
67
+ * `log` is an injectable printer (defaults to console.log) so the REPL can style it.
68
+ */
69
+ export async function deviceLogin({ apiBase = DEFAULT_API_BASE, log = console.log } = {}) {
70
+ const base = String(apiBase || DEFAULT_API_BASE).trim().replace(/\/+$/, "");
71
+ let start;
72
+ try {
73
+ const res = await fetch(`${base}/api/pair/start`, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify({ apiBase: base }),
77
+ });
78
+ if (res.status === 404) {
79
+ const err = new Error("This API does not support CLI login (device pairing).");
80
+ err.code = "PAIRING_UNSUPPORTED";
81
+ throw err;
82
+ }
83
+ if (!res.ok) {
84
+ throw new Error(`pairing start failed (HTTP ${res.status})`);
85
+ }
86
+ start = await res.json();
87
+ } catch (e) {
88
+ if (e && e.code === "PAIRING_UNSUPPORTED") throw e;
89
+ // A network failure to a base that has no /pair yet also can't pair — surface as
90
+ // unsupported so the caller offers manual setup rather than looping.
91
+ const err = new Error(
92
+ `Could not reach ${base} to start login: ${e instanceof Error ? e.message : e}`
93
+ );
94
+ err.code = "PAIRING_UNSUPPORTED";
95
+ throw err;
96
+ }
97
+
98
+ const { pairId, pairCode, verificationUrlBase } = start;
99
+ const url = `${verificationUrlBase || `${base}/link`}?code=${encodeURIComponent(pairCode)}`;
100
+ log("");
101
+ log(" To connect this machine, sign in and approve this code in your browser:");
102
+ log("");
103
+ log(` code: ${pairCode}`);
104
+ log(` url: ${url}`);
105
+ log("");
106
+ log(" (opening your browser… if it doesn't, paste the url above)");
107
+ log("");
108
+ openBrowser(url);
109
+
110
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
111
+ while (Date.now() < deadline) {
112
+ await sleep(POLL_INTERVAL_MS);
113
+ let poll;
114
+ try {
115
+ const res = await fetch(`${base}/api/pair/poll`, {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({ pairId }),
119
+ });
120
+ if (!res.ok) continue; // transient — keep polling
121
+ poll = await res.json();
122
+ } catch {
123
+ continue; // network blip — keep polling
124
+ }
125
+ if (poll.status === "approved" && poll.workerApiKey) {
126
+ const finalBase = String(poll.apiBase || base).trim().replace(/\/+$/, "");
127
+ const envFile = await writeWorkerEnv(finalBase, poll.workerApiKey);
128
+ log(` ✓ Linked. Saved ${envFile}`);
129
+ return { apiBase: finalBase, workerKey: poll.workerApiKey, envFile };
130
+ }
131
+ if (poll.status === "expired") {
132
+ const err = new Error("Login code expired before it was approved. Run `gonext login` again.");
133
+ err.code = "PAIRING_EXPIRED";
134
+ throw err;
135
+ }
136
+ // "pending" / "consumed" → keep waiting (consumed by a racing poll is unlikely).
137
+ }
138
+ const err = new Error("Login timed out waiting for browser approval.");
139
+ err.code = "PAIRING_TIMEOUT";
140
+ throw err;
141
+ }
@@ -61,7 +61,11 @@ gonext-local-worker
61
61
 
62
62
  Usage:
63
63
  gonext-local-worker
64
+ gonext-local-worker login [--api-base <url>] # browser sign-in → writes ~/.gonext/worker.env
64
65
  gonext-local-worker set <workerKey> [--api-base <url>]
66
+ gonext-local-worker start | stop | status | restart # run the polling daemon in the background
67
+ gonext-local-worker logs [--lines N] # tail the background daemon log
68
+ gonext-local-worker doctor # download + start the base chat model if needed
65
69
  gonext-local-worker ws-ping-test # POST /api/worker/ws-ping-test (needs GONEXT_* env)
66
70
  gonext-local-worker simulate-chat [text...] # claim next chat job, fake reply (stop normal worker first)
67
71
 
@@ -127,6 +131,108 @@ if (args[0] === "set") {
127
131
  await setConfig();
128
132
  process.exit(0);
129
133
  }
134
+ if (args[0] === "login") {
135
+ // Task #127, Phase 1: browser device-pairing → writes worker.env. `--api-base` overrides
136
+ // which API to pair against (defaults to the production API).
137
+ const { deviceLogin, DEFAULT_API_BASE } = await import("./device-login.mjs");
138
+ const apiBaseFlag = parseFlag("--api-base");
139
+ try {
140
+ await deviceLogin({
141
+ apiBase:
142
+ apiBaseFlag ||
143
+ (process.env.GONEXT_API_BASE ?? "").trim() ||
144
+ DEFAULT_API_BASE,
145
+ });
146
+ process.exit(0);
147
+ } catch (e) {
148
+ console.error(`[gonext-worker] login failed: ${e instanceof Error ? e.message : e}`);
149
+ if (e && e.code === "PAIRING_UNSUPPORTED") {
150
+ console.error(
151
+ "Set it manually instead: gonext-local-worker set <workerKey> --api-base <url>"
152
+ );
153
+ }
154
+ process.exit(1);
155
+ }
156
+ }
157
+ // Task #127, Phase 2: daemon lifecycle from a plain terminal (no GoTerminal). start runs
158
+ // the polling worker DETACHED; stop/status/logs manage it. All no-op-safe (BC4: never
159
+ // double-spawn a second poller).
160
+ if (["start", "stop", "status", "logs", "restart"].includes(args[0])) {
161
+ const ctl = await import("./daemon-control.mjs");
162
+ if (args[0] === "status") {
163
+ const s = await ctl.daemonStatus();
164
+ if (s.running) {
165
+ const who = s.pidFilePid ? `pid ${s.pidFilePid}` : `pid ${s.pids.join(", ")}`;
166
+ console.log(`[gonext-worker] daemon RUNNING (${who}). Logs: ${ctl.LOG_FILE}`);
167
+ } else {
168
+ console.log("[gonext-worker] daemon is NOT running. Start it with: gonext-local-worker start");
169
+ }
170
+ process.exit(0);
171
+ }
172
+ if (args[0] === "logs") {
173
+ const n = Number(parseFlag("--lines")) || 60;
174
+ const txt = await ctl.tailLog(n);
175
+ console.log(txt || `(no daemon log yet at ${ctl.LOG_FILE})`);
176
+ process.exit(0);
177
+ }
178
+ if (args[0] === "stop") {
179
+ const { killed } = await ctl.stopDaemon();
180
+ console.log(killed ? `[gonext-worker] stopped ${killed} daemon process(es).` : "[gonext-worker] no daemon was running.");
181
+ process.exit(0);
182
+ }
183
+ // start / restart both ensure exactly one daemon is running, config permitting.
184
+ if (args[0] === "restart") await ctl.stopDaemon();
185
+ // Don't spawn a daemon that would just exit(1): require a complete worker.env first.
186
+ const { hasWorkerConfig } = await import("./device-login.mjs");
187
+ if (!(await hasWorkerConfig())) {
188
+ console.error(
189
+ "[gonext-worker] not configured — run `gonext-local-worker login` (or `set`) first."
190
+ );
191
+ process.exit(1);
192
+ }
193
+ const r = await ctl.startDaemon();
194
+ if (r.started) {
195
+ console.log(`[gonext-worker] daemon started (pid ${r.pid}). Logs: ${ctl.LOG_FILE}`);
196
+ } else {
197
+ console.log("[gonext-worker] daemon already running — nothing to do.");
198
+ }
199
+ process.exit(0);
200
+ }
201
+ // Task #127, Phase 3: ensure the base chat model is downloaded + running (no GoTerminal).
202
+ if (args[0] === "doctor") {
203
+ const { hasWorkerConfig } = await import("./device-login.mjs");
204
+ if (!(await hasWorkerConfig())) {
205
+ console.error("[gonext-worker] not configured — run `gonext-local-worker login` first.");
206
+ process.exit(1);
207
+ }
208
+ const { runDoctor } = await import("./model-doctor.mjs");
209
+ const rl0 = (await import("node:readline")).createInterface({
210
+ input: process.stdin,
211
+ output: process.stdout,
212
+ });
213
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
214
+ const confirm = (q, defYes) =>
215
+ // Non-interactive: never download GBs / start a server unattended — probe + advise only.
216
+ interactive
217
+ ? new Promise((res) =>
218
+ rl0.question(` ${q} [${defYes ? "Y/n" : "y/N"}] `, (a) => {
219
+ const s = a.trim().toLowerCase();
220
+ res(s ? s === "y" || s === "yes" : Boolean(defYes));
221
+ })
222
+ )
223
+ : Promise.resolve(false);
224
+ try {
225
+ await runDoctor({
226
+ apiBase: String(process.env.GONEXT_API_BASE ?? "").trim(),
227
+ workerKey: String(process.env.GONEXT_WORKER_KEY ?? "").trim(),
228
+ log: (s) => console.log(s),
229
+ confirm,
230
+ });
231
+ } finally {
232
+ rl0.close();
233
+ }
234
+ process.exit(0);
235
+ }
130
236
 
131
237
  const apiBase = String(process.env.GONEXT_API_BASE ?? "")
132
238
  .trim()
@@ -3068,6 +3174,32 @@ async function pollOnce() {
3068
3174
  }
3069
3175
 
3070
3176
  async function main() {
3177
+ // Task #127, Phase 2: record our own pidfile so `status`/`stop` find us whether we were
3178
+ // launched by `start` (detached), by the REPL's auto-ensure, or run in the foreground.
3179
+ // Best-effort; a failure here must not stop the worker from polling.
3180
+ try {
3181
+ const { PID_FILE } = await import("./daemon-control.mjs");
3182
+ const { unlinkSync } = await import("node:fs");
3183
+ await writeFile(PID_FILE, String(process.pid), "utf8");
3184
+ const cleanup = () => {
3185
+ try {
3186
+ unlinkSync(PID_FILE);
3187
+ } catch {
3188
+ /* already gone */
3189
+ }
3190
+ };
3191
+ process.on("SIGTERM", () => {
3192
+ cleanup();
3193
+ process.exit(0);
3194
+ });
3195
+ process.on("SIGINT", () => {
3196
+ cleanup();
3197
+ process.exit(0);
3198
+ });
3199
+ process.on("exit", cleanup);
3200
+ } catch {
3201
+ /* pidfile is a convenience; polling works without it */
3202
+ }
3071
3203
  console.log(
3072
3204
  `[gonext-worker] polling ${apiBase} every ${pollMs}ms (local_health concurrency=${localHealthConcurrency})`
3073
3205
  );
package/gonext-repl.mjs CHANGED
@@ -170,15 +170,42 @@ if (argv.includes("-v") || argv.includes("--verbose")) {
170
170
  // ---------- auth: worker key + API base from ~/.gonext/worker.env ----------
171
171
  dotenv.config({ path: ENV_FILE });
172
172
  dotenv.config();
173
- const workerKey = String(process.env.GONEXT_WORKER_KEY ?? "").trim();
174
- const apiBase = String(process.env.GONEXT_API_BASE ?? "").trim().replace(/\/+$/, "");
173
+ let workerKey = String(process.env.GONEXT_WORKER_KEY ?? "").trim();
174
+ let apiBase = String(process.env.GONEXT_API_BASE ?? "").trim().replace(/\/+$/, "");
175
+ // Task #127 (BC1a): "configured" = BOTH key AND api-base present. When either is missing,
176
+ // first-run login instead of a dead end — but ONLY interactively; a piped/CI invocation
177
+ // still fails fast with guidance (so scripts don't hang on a browser prompt). An EXISTING
178
+ // user with a full worker.env skips all of this and behaves exactly as before (BC1).
175
179
  if (!workerKey || !apiBase) {
176
- console.error(
177
- red("gonext: no worker key / API base found.") +
178
- `\nExpected ${ENV_FILE} (written by the Installation Wizard), or configure manually:` +
179
- "\n gonext-local-worker set <workerKey> --api-base https://chat-api-v2.gomarsic.cc"
180
- );
181
- process.exit(1);
180
+ if (process.stdin.isTTY && process.stdout.isTTY) {
181
+ try {
182
+ const { deviceLogin, DEFAULT_API_BASE } = await import("./device-login.mjs");
183
+ console.log(
184
+ dim("gonext: first-time setup — let's connect this machine to your account.")
185
+ );
186
+ const r = await deviceLogin({
187
+ apiBase: apiBase || DEFAULT_API_BASE,
188
+ log: (s) => console.log(s),
189
+ });
190
+ workerKey = r.workerKey;
191
+ apiBase = r.apiBase;
192
+ } catch (e) {
193
+ console.error(red(`gonext: login failed: ${e instanceof Error ? e.message : e}`));
194
+ if (e && e.code === "PAIRING_UNSUPPORTED") {
195
+ console.error(
196
+ "Configure manually: gonext-local-worker set <workerKey> --api-base https://chat-api-v2.gomarsic.cc"
197
+ );
198
+ }
199
+ process.exit(1);
200
+ }
201
+ } else {
202
+ console.error(
203
+ red("gonext: no worker key / API base found.") +
204
+ `\nRun ${cyan("gonext-local-worker login")} to sign in, or configure manually:` +
205
+ "\n gonext-local-worker set <workerKey> --api-base https://chat-api-v2.gomarsic.cc"
206
+ );
207
+ process.exit(1);
208
+ }
182
209
  }
183
210
 
184
211
  // Line intake: attach the listener IMMEDIATELY so lines that arrive while startup
@@ -950,9 +977,9 @@ async function chooseModel() {
950
977
  );
951
978
  // Task #125: the GLOBAL lifetime total is per-backend, so re-fetch it for the newly
952
979
  // selected backend NOW — otherwise the footer would keep showing the previous backend's
953
- // total until the next turn. Zero the live tail so the switch can't add the last turn's
954
- // output to the new backend's bucket on screen.
955
- latestOutputTokens = 0;
980
+ // total until the next turn. (The live per-turn tail, latestOutputTokens, is local to
981
+ // runAgentTurn and is already 0 here between turns — /model can't run mid-turn — so there
982
+ // is nothing to zero, and it isn't in scope from here.)
956
983
  const t = await fetchOutputTokenTotals(resolve(process.cwd()));
957
984
  { const g = pickGlobalTotal(t); if (g !== null) globalOutputTokens = g; }
958
985
  }
@@ -1053,6 +1080,17 @@ async function fetchAgentPayload(messages) {
1053
1080
  "the API does not have POST /api/worker/agent-payload yet — deploy the latest API (sam build && sam deploy)."
1054
1081
  );
1055
1082
  }
1083
+ // Task #127 (BC1b): a present-but-INVALID worker key (rotated/revoked while worker.env
1084
+ // kept the stale one) shows up as 401/403. Point the user at re-login rather than a
1085
+ // generic error — we don't proactively re-validate the key, so the first rejection is
1086
+ // where this surfaces.
1087
+ if (res.status === 401 || res.status === 403) {
1088
+ throw new Error(
1089
+ "your worker key was rejected (it may have been rotated or revoked). " +
1090
+ "Run `gonext-local-worker login` to sign in again, or update it with " +
1091
+ "`gonext-local-worker set <workerKey> --api-base <url>`."
1092
+ );
1093
+ }
1056
1094
  throw new Error(data?.error || `agent-payload failed (HTTP ${res.status})`);
1057
1095
  }
1058
1096
  return data;
@@ -2198,6 +2236,19 @@ async function runAgentTurn(history) {
2198
2236
  // ---------- REPL ----------
2199
2237
  async function main() {
2200
2238
  console.log(cyan("GoNext agent REPL") + dim(` v${REPL_VERSION} · API ${apiBase} · key ${workerKey.slice(0, 8)}…`));
2239
+ // Task #127, Phase 2: the REPL enqueues jobs that the polling DAEMON executes, so ensure
2240
+ // one is running — no GoTerminal needed. Idempotent + BC4-safe: startDaemon() no-ops when
2241
+ // a daemon (ours, GoTerminal's, or a manual one) is already alive. Best-effort; a failure
2242
+ // here shouldn't block the REPL (the user can `gonext-local-worker start` manually).
2243
+ try {
2244
+ const ctl = await import("./daemon-control.mjs");
2245
+ const r = await ctl.startDaemon();
2246
+ if (r.started) {
2247
+ console.log(dim(` started background worker (pid ${r.pid}) · logs ${ctl.LOG_FILE}`));
2248
+ }
2249
+ } catch {
2250
+ /* non-fatal — jobs just won't run until a daemon is started */
2251
+ }
2201
2252
  await ensureWorkspace();
2202
2253
  // Restore this folder's remembered auto-test + deploy-target so the banner + agent-ask
2203
2254
  // reflect them (both survive a fresh terminal in the same project).
@@ -2239,6 +2290,33 @@ async function main() {
2239
2290
  (selectedServer ? ` · deploy: ${selectedServer.name} (${selectedServer.user}@${selectedServer.host})` : "")
2240
2291
  ) + (sessionTestAuto ? "" : dim(" (/test-auto to enable)"))
2241
2292
  );
2293
+ // Task #127, Phase 3: if the base chat model server isn't answering, offer to set it up
2294
+ // (download + start mlx_lm.server). BC3: only runs when NOTHING answers — an already-up
2295
+ // model is left untouched, and this is skipped entirely. Interactive TTYs only.
2296
+ if (process.stdin.isTTY && process.stdout.isTTY) {
2297
+ try {
2298
+ const md = await import("./model-doctor.mjs");
2299
+ const chatUrl = String(p.agentBaseURL || "").trim() || `http://127.0.0.1:${md.DEFAULT_CHAT_PORT}`;
2300
+ if (!(await md.probeModelServer(chatUrl))) {
2301
+ console.log(
2302
+ dim(`\ngonext: the chat model server (${md.modelRoot(chatUrl)}) isn't responding.`)
2303
+ );
2304
+ const go = await askYesNo("Set up the base model now (download if needed + start)?", true);
2305
+ if (go) {
2306
+ await md.runDoctor({
2307
+ apiBase,
2308
+ workerKey,
2309
+ log: (s) => console.log(s),
2310
+ confirm: (q, defYes) => askYesNo(q, defYes),
2311
+ });
2312
+ } else {
2313
+ console.log(dim(" Skipped. Run `gonext-local-worker doctor` anytime to set it up.\n"));
2314
+ }
2315
+ }
2316
+ } catch {
2317
+ /* doctor is best-effort — never block the REPL from starting */
2318
+ }
2319
+ }
2242
2320
  } catch (err) {
2243
2321
  console.error(red(`gonext: ${err.message}`));
2244
2322
  process.exit(1);
@@ -0,0 +1,342 @@
1
+ // Model bootstrap for the gonext CLI (task #127, Phase 3). Ensures the base CHAT model
2
+ // (mlx_lm.server) is downloaded and RUNNING so a fresh Mac can answer without GoTerminal or
3
+ // the Wizard doing it. The agent CODE model (Ollama) is probed + guided, not auto-pulled.
4
+ //
5
+ // BC3 (task #127): NEVER touch a model that is already running. The doctor probes first and
6
+ // does nothing when a server answers — it will not kill, restart, re-port, or re-download a
7
+ // model the user already runs (many run mlx_lm.server themselves on 8090, or point at a
8
+ // remote box). Downloads happen only when the model dir is genuinely absent AND the user
9
+ // opts in.
10
+ import { spawn, execFile } from "node:child_process";
11
+ import { open, mkdir, readdir } from "node:fs/promises";
12
+ import { homedir, totalmem } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ // Minimum RAM to run the default 14B-4bit chat model comfortably on Apple Silicon.
16
+ const MIN_CHAT_RAM_GB = 16;
17
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
18
+
19
+ /**
20
+ * The yellow RAM notice shown before the multi-GB model download (user request): the
21
+ * default 14B-4bit model wants a Mac with ≥16 GB. When the machine has less, add a plain
22
+ * line stating its detected amount. Pure (RAM passed in) so it's unit-testable.
23
+ * Returns already-colored lines; empty array only if `repo` is falsy.
24
+ */
25
+ export function chatRamWarningLines(repo, totalBytes) {
26
+ if (!repo) return [];
27
+ const ramGb = Math.round((Number(totalBytes) || 0) / 1024 ** 3);
28
+ const name = String(repo).split("/").pop();
29
+ const out = [
30
+ yellow(` ⚠ ${name} needs a Mac with at least ${MIN_CHAT_RAM_GB} GB of RAM to run well.`),
31
+ ];
32
+ if (ramGb > 0 && ramGb < MIN_CHAT_RAM_GB) {
33
+ out.push(
34
+ yellow(
35
+ ` This machine has about ${ramGb} GB — the model may run very slowly or fail to load.`
36
+ )
37
+ );
38
+ }
39
+ return out;
40
+ }
41
+
42
+ const DIR = join(homedir(), ".gonext");
43
+ export const MODEL_PID_FILE = join(DIR, "model.pid");
44
+ export const MODEL_LOG_FILE = join(DIR, "model.log");
45
+
46
+ // The default base chat model (task #127 decision): matches the Wizard's model-chat step.
47
+ export const DEFAULT_CHAT_REPO = "mlx-community/Qwen3-14B-4bit";
48
+ export const DEFAULT_CHAT_DIR = join(homedir(), "mlx-models", "Qwen3-14B-4bit");
49
+ export const DEFAULT_CHAT_PORT = 8082;
50
+
51
+ // ---- pure helpers (unit-tested offline) -------------------------------------------------
52
+
53
+ /** The port from a base URL, or null. "http://127.0.0.1:8082/v1" → 8082. */
54
+ export function parsePort(baseUrl) {
55
+ try {
56
+ const u = new URL(String(baseUrl || "").includes("://") ? baseUrl : `http://${baseUrl}`);
57
+ if (u.port) return Number(u.port);
58
+ return u.protocol === "https:" ? 443 : 80;
59
+ } catch {
60
+ const m = String(baseUrl || "").match(/:(\d{2,5})(?:\/|$)/);
61
+ return m ? Number(m[1]) : null;
62
+ }
63
+ }
64
+
65
+ /** True when the URL points at this machine (loopback/LAN) — only then can we START it. */
66
+ export function isLocalModelUrl(baseUrl) {
67
+ let host = "";
68
+ try {
69
+ host = new URL(String(baseUrl || "").includes("://") ? baseUrl : `http://${baseUrl}`)
70
+ .hostname.toLowerCase()
71
+ .replace(/\.$/, "");
72
+ } catch {
73
+ return false;
74
+ }
75
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
76
+ if (!host) return false;
77
+ if (["localhost", "0.0.0.0", "::", "::1"].includes(host)) return true;
78
+ if (/^127(\.\d{1,3}){3}$/.test(host)) return true;
79
+ if (
80
+ /^(10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){2}|169\.254(\.\d{1,3}){2})$/.test(
81
+ host
82
+ )
83
+ )
84
+ return true;
85
+ if (host.includes(":") && (host.startsWith("fe80:") || /^f[cd]/.test(host))) return true;
86
+ if (/\.(local|localdomain|lan|home|internal)$/.test(host)) return true;
87
+ return !host.includes(".") && !host.includes(":");
88
+ }
89
+
90
+ /** Normalize a base URL to its root (strip a trailing /v1 and slashes). */
91
+ export function modelRoot(baseUrl) {
92
+ return String(baseUrl || "").replace(/\/+$/, "").replace(/\/v1$/i, "");
93
+ }
94
+
95
+ // ---- probing + process control ----------------------------------------------------------
96
+
97
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
98
+
99
+ /** True when an OpenAI-compatible model server answers GET {base}/v1/models. */
100
+ export async function probeModelServer(baseUrl, { timeoutMs = 3000 } = {}) {
101
+ const root = modelRoot(baseUrl);
102
+ if (!root) return false;
103
+ try {
104
+ const res = await fetch(`${root}/v1/models`, {
105
+ signal: AbortSignal.timeout(timeoutMs),
106
+ });
107
+ return res.ok;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ function which(cmd) {
114
+ return new Promise((resolve) => {
115
+ execFile("command", ["-v", cmd], { shell: "/bin/sh" }, (err, stdout) =>
116
+ resolve(!err && Boolean(String(stdout).trim()))
117
+ );
118
+ });
119
+ }
120
+
121
+ /** True when the model directory exists and is non-empty. */
122
+ export async function modelDirReady(dir) {
123
+ try {
124
+ const entries = await readdir(dir);
125
+ return entries.length > 0;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Start mlx_lm.server for `modelDir` on `port`, DETACHED (logs to model.log, pidfile). Waits
133
+ * until the server answers /v1/models (up to waitMs). No-op + { started:false } when a
134
+ * server already answers on that port (BC3). Returns { started, ready, pid?, reason? }.
135
+ */
136
+ export async function startChatModelServer({ modelDir, port, waitMs = 120_000, log = () => {} }) {
137
+ const base = `http://127.0.0.1:${port}`;
138
+ if (await probeModelServer(base, { timeoutMs: 1500 })) {
139
+ return { started: false, ready: true, reason: "already-running" };
140
+ }
141
+ if (!(await which("mlx_lm.server"))) {
142
+ return {
143
+ started: false,
144
+ ready: false,
145
+ reason: "mlx_lm.server not found — install it: pip install mlx-lm",
146
+ };
147
+ }
148
+ await mkdir(DIR, { recursive: true });
149
+ const out = await open(MODEL_LOG_FILE, "a");
150
+ let pid;
151
+ try {
152
+ const child = spawn("mlx_lm.server", ["--model", modelDir, "--port", String(port)], {
153
+ detached: true,
154
+ stdio: ["ignore", out.fd, out.fd],
155
+ env: process.env,
156
+ });
157
+ pid = child.pid;
158
+ const { writeFile } = await import("node:fs/promises");
159
+ await writeFile(MODEL_PID_FILE, String(pid ?? ""), "utf8");
160
+ child.unref();
161
+ } finally {
162
+ await out.close();
163
+ }
164
+ log(` starting mlx_lm.server (pid ${pid}) on port ${port} — waiting for it to load…`);
165
+ const deadline = Date.now() + waitMs;
166
+ while (Date.now() < deadline) {
167
+ await sleep(2500);
168
+ if (await probeModelServer(base, { timeoutMs: 2000 })) {
169
+ return { started: true, ready: true, pid };
170
+ }
171
+ }
172
+ return {
173
+ started: true,
174
+ ready: false,
175
+ pid,
176
+ reason: `model server did not answer within ${Math.round(waitMs / 1000)}s (still loading? see ${MODEL_LOG_FILE})`,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Download an MLX model repo to a local dir via the Hugging Face CLI. Streams progress to
182
+ * the terminal (inherit stdio) because it's a multi-GB, several-minute step. Resolves
183
+ * { ok } / rejects with guidance when `hf` is missing.
184
+ */
185
+ export async function downloadModel({ repo, dir, log = () => {} }) {
186
+ if (!(await which("hf"))) {
187
+ throw new Error(
188
+ "the Hugging Face CLI (hf) is required to download the model. Install it with: pip install huggingface_hub"
189
+ );
190
+ }
191
+ log(` downloading ${repo} → ${dir} (multi-GB, keep this open)…`);
192
+ return new Promise((resolve, reject) => {
193
+ const child = spawn("hf", ["download", repo, "--local-dir", dir], {
194
+ stdio: "inherit",
195
+ env: process.env,
196
+ });
197
+ child.on("exit", (code) =>
198
+ code === 0 ? resolve({ ok: true }) : reject(new Error(`hf download exited ${code}`))
199
+ );
200
+ child.on("error", (e) => reject(e));
201
+ });
202
+ }
203
+
204
+ /** Fetch the account's resolved model config from /agent-payload (best-effort). */
205
+ async function fetchModelConfig(apiBase, workerKey) {
206
+ try {
207
+ const res = await fetch(`${apiBase.replace(/\/+$/, "")}/api/worker/agent-payload`, {
208
+ method: "POST",
209
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
210
+ body: JSON.stringify({ messages: [{ role: "user", content: "ping" }] }),
211
+ });
212
+ if (!res.ok) return null;
213
+ const data = await res.json();
214
+ return data?.payload ?? null;
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * The Phase-3 orchestration (task #127). Ensures the base CHAT model is running (starting a
222
+ * local mlx_lm.server, downloading first if the dir is missing and the user opts in), and
223
+ * probes the CODE model, giving guidance for a remote one. BC3-safe throughout: an
224
+ * already-answering server is left untouched.
225
+ *
226
+ * `log(str)` prints; `confirm(question, defaultYes)→bool` gates the multi-GB download and
227
+ * the model-server start (non-interactive callers pass a confirm that returns false, so the
228
+ * doctor only PROBES + advises and never downloads/starts unattended).
229
+ */
230
+ export async function runDoctor({ apiBase, workerKey, log, confirm }) {
231
+ const p = (await fetchModelConfig(apiBase, workerKey)) ?? {};
232
+ // Chat model: prefer the account's configured URL; fall back to the default local one.
233
+ const configuredChat = String(p.agentBaseURL || "").trim();
234
+ const chatUrl = configuredChat || `http://127.0.0.1:${DEFAULT_CHAT_PORT}`;
235
+ const chatPort = parsePort(chatUrl) || DEFAULT_CHAT_PORT;
236
+ // Review H2: never serve a model the account didn't configure. Only the KNOWN default is
237
+ // auto-downloadable; for a specifically-named other model we start its dir if present but
238
+ // won't substitute Qwen3-14B. "default_model" is the direct-URL sentinel (the loaded
239
+ // model), which we treat as the default. modelId empty (fresh account) → default too.
240
+ const modelId = String(p.agentModelId || "").trim();
241
+ const isDefaultModel =
242
+ !modelId || modelId === "default_model" || /qwen3-?14b/i.test(modelId);
243
+ const chatDir = isDefaultModel
244
+ ? DEFAULT_CHAT_DIR
245
+ : join(homedir(), "mlx-models", modelId.replace(/[^A-Za-z0-9._-]/g, "-"));
246
+ const chatRepo = isDefaultModel ? DEFAULT_CHAT_REPO : null; // null = not auto-downloadable
247
+
248
+ // The chat handling sets `chat` but never returns early — the code-model check below is
249
+ // independent and must run even when the user declines the chat-model setup.
250
+ let chat = "checked";
251
+ log("");
252
+ log(" Checking the base chat model…");
253
+ if (await probeModelServer(chatUrl)) {
254
+ log(` ✓ chat model already running at ${modelRoot(chatUrl)} — leaving it alone.`);
255
+ chat = "already-running";
256
+ } else if (configuredChat && !isLocalModelUrl(chatUrl)) {
257
+ // Configured but REMOTE and not answering — we can't start a model on another machine.
258
+ log(` ⚠ your chat model server (${modelRoot(chatUrl)}) isn't answering.`);
259
+ log(" It's remote, so start it there — this machine can't launch it.");
260
+ chat = "remote";
261
+ } else {
262
+ // Local (or unconfigured → default local): ensure the RIGHT model, then offer to start.
263
+ const dir = chatDir;
264
+ let dirReady = await modelDirReady(dir);
265
+ if (!dirReady && !chatRepo) {
266
+ // A specifically-named, non-default model we don't know how to fetch — DON'T
267
+ // substitute the default. Tell the user how to bring their own (review H2).
268
+ log(` ⚠ your account uses the chat model "${modelId}", which isn't the built-in`);
269
+ log(` default, so I can't auto-download it. Put it at ${dir} (or any dir) and run:`);
270
+ log(` mlx_lm.server --model <that dir> --port ${chatPort}`);
271
+ chat = "unknown-model";
272
+ } else if (!dirReady) {
273
+ // Yellow RAM notice before the multi-GB download, THEN the confirm (user request).
274
+ for (const line of chatRamWarningLines(chatRepo, totalmem())) log(line);
275
+ const yes = await confirm(
276
+ `Download the base chat model ${chatRepo} (~8 GB) to ${dir}?`,
277
+ true
278
+ );
279
+ if (!yes) {
280
+ log(" Skipped. To do it yourself:");
281
+ log(` hf download ${chatRepo} --local-dir ${dir}`);
282
+ log(` mlx_lm.server --model ${dir} --port ${chatPort}`);
283
+ chat = "skipped";
284
+ } else {
285
+ try {
286
+ await downloadModel({ repo: chatRepo, dir, log });
287
+ dirReady = true;
288
+ } catch (e) {
289
+ log(` ⚠ ${e instanceof Error ? e.message : e}`);
290
+ chat = "download-failed";
291
+ }
292
+ }
293
+ }
294
+ if (dirReady && chat === "checked") {
295
+ const yes = await confirm(`Start the chat model server on port ${chatPort} now?`, true);
296
+ if (!yes) {
297
+ log(` Skipped. Start it later: mlx_lm.server --model ${dir} --port ${chatPort}`);
298
+ chat = "not-started";
299
+ } else {
300
+ // Review M2: don't block the caller (the REPL) for a full cold model load — that's
301
+ // minutes. Wait only long enough to confirm the server launched and didn't crash
302
+ // (~20s); if it's still loading, say so and return. The first agent turn will wait
303
+ // for it. A missing binary / immediate exit still surfaces as an error.
304
+ const r = await startChatModelServer({
305
+ modelDir: dir,
306
+ port: chatPort,
307
+ waitMs: 20_000,
308
+ log,
309
+ });
310
+ if (r.ready) {
311
+ log(` ✓ chat model is up on port ${chatPort}.`);
312
+ chat = "started";
313
+ } else if (r.started) {
314
+ log(` … chat model is loading in the background on port ${chatPort} (large model —`);
315
+ log(` the first answer may take a minute). Watch it: gonext-local-worker logs`);
316
+ chat = "loading";
317
+ } else {
318
+ log(` ⚠ ${r.reason}`);
319
+ chat = "start-failed";
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ // Code model (Ollama): probe + guide only — never auto-pull (BC3 + bounded scope).
326
+ const codeUrl = String(p.codingBaseURL || "").trim();
327
+ const codeModel = String(p.codingModelId || "").trim();
328
+ if (codeUrl) {
329
+ log("");
330
+ log(" Checking the agent code model…");
331
+ if (await probeModelServer(codeUrl)) {
332
+ log(` ✓ code model server is up at ${modelRoot(codeUrl)}.`);
333
+ } else if (isLocalModelUrl(codeUrl)) {
334
+ log(` ⚠ local code model server (${modelRoot(codeUrl)}) isn't answering.`);
335
+ if (codeModel) log(` Start Ollama and pull it: ollama serve & ollama pull ${codeModel}`);
336
+ } else {
337
+ log(` ⚠ your code model runs on a remote server (${modelRoot(codeUrl)}) that isn't`);
338
+ log(` answering. Start it there${codeModel ? ` (ollama pull ${codeModel})` : ""}.`);
339
+ }
340
+ }
341
+ return { chat };
342
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.335",
3
+ "version": "1.0.343",
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",
@@ -17,7 +17,8 @@
17
17
  "gonext": "./gonext-repl.mjs"
18
18
  },
19
19
  "scripts": {
20
- "test": "python3 -m unittest discover tests -v",
20
+ "test": "python3 -m unittest discover tests -v && node --test tests/*.test.mjs",
21
+ "test:js": "node --test tests/*.test.mjs",
21
22
  "deploy:local": "npm install -g .",
22
23
  "run": "gonext-local-worker",
23
24
  "publish:org": "npm publish --access public"
@@ -25,6 +26,9 @@
25
26
  "files": [
26
27
  "gonext-local-worker.mjs",
27
28
  "gonext-repl.mjs",
29
+ "device-login.mjs",
30
+ "daemon-control.mjs",
31
+ "model-doctor.mjs",
28
32
  "gonext_probe_agent.py",
29
33
  "gonext_agent_chat.py",
30
34
  "gonext_transcribe.py",