@tiens.nguyen/gonext-local-worker 1.0.337 → 1.0.346

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
@@ -350,6 +377,9 @@ function slashRestoreHistory() {
350
377
  // which the earlier relative-cursor overlay hit whenever readline refreshed or the
351
378
  // menu was near the bottom of the screen and scrolled).
352
379
  let slashOverlayActive = false;
380
+ // While true, readline's echo + line-refresh are muted so a typed secret (an API key) never
381
+ // reaches the terminal or scrollback (task #127 — see askSecret + the _writeToOutput guards).
382
+ let secretInput = false;
353
383
 
354
384
  // Visible width of PROMPT (">> ") — used to put the caret back on the input line by
355
385
  // COLUMN, since the string itself carries color escapes that don't occupy cells.
@@ -581,6 +611,20 @@ const ask = async (q) => {
581
611
  // re-uses the default. This replaces a fragile `(await ask()) || "y"` idiom where any
582
612
  // leftover character (a stray escape byte, a stray space) silently defeated the
583
613
  // default — the reported "[Y/n] + Enter registered as No" bug.
614
+ // Masked prompt for a secret (an API key). Mutes readline's echo/refresh (secretInput)
615
+ // so the typed value never hits the terminal or scrollback (task #127). Returns "" on a
616
+ // bare Enter (skip).
617
+ const askSecret = async (q) => {
618
+ process.stdout.write(q);
619
+ secretInput = true;
620
+ try {
621
+ const a = await nextLine();
622
+ return (a ?? "").trim();
623
+ } finally {
624
+ secretInput = false;
625
+ process.stdout.write("\n"); // the muted Enter left the cursor mid-line
626
+ }
627
+ };
584
628
  const askYesNo = async (question, defaultYes) => {
585
629
  const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
586
630
  const raw = (await ask(question + suffix)).trim().toLowerCase();
@@ -741,6 +785,22 @@ async function addOutputTokens(cwd, delta) {
741
785
  }
742
786
  }
743
787
 
788
+ // Save the agent coding-model API key (task #127): the terminal prompts for the Kimi K3 key
789
+ // when the default cloud coder has none, and POSTs it here (worker-key authed). Returns true
790
+ // on success. Best-effort — a failure just leaves the reminder for next startup.
791
+ async function saveCodingKey(apiKey) {
792
+ try {
793
+ const r = await fetch(`${apiBase}/api/worker/coding-key`, {
794
+ method: "POST",
795
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
796
+ body: JSON.stringify({ apiKey }),
797
+ });
798
+ return r.ok;
799
+ } catch {
800
+ return false;
801
+ }
802
+ }
803
+
744
804
  async function resetGlobalOutputTokens() {
745
805
  try {
746
806
  const r = await fetch(`${apiBase}/api/worker/output-tokens/reset-global`, {
@@ -1053,6 +1113,17 @@ async function fetchAgentPayload(messages) {
1053
1113
  "the API does not have POST /api/worker/agent-payload yet — deploy the latest API (sam build && sam deploy)."
1054
1114
  );
1055
1115
  }
1116
+ // Task #127 (BC1b): a present-but-INVALID worker key (rotated/revoked while worker.env
1117
+ // kept the stale one) shows up as 401/403. Point the user at re-login rather than a
1118
+ // generic error — we don't proactively re-validate the key, so the first rejection is
1119
+ // where this surfaces.
1120
+ if (res.status === 401 || res.status === 403) {
1121
+ throw new Error(
1122
+ "your worker key was rejected (it may have been rotated or revoked). " +
1123
+ "Run `gonext-local-worker login` to sign in again, or update it with " +
1124
+ "`gonext-local-worker set <workerKey> --api-base <url>`."
1125
+ );
1126
+ }
1056
1127
  throw new Error(data?.error || `agent-payload failed (HTTP ${res.status})`);
1057
1128
  }
1058
1129
  return data;
@@ -1357,6 +1428,9 @@ rl._writeToOutput = (s) => {
1357
1428
  // slashOverlayActive: the slash-command overlay owns drawing (input line + menu), so
1358
1429
  // readline's own echo must be swallowed or it fights the overlay (bug #109).
1359
1430
  if (following || listPickerActive || slashOverlayActive) return;
1431
+ // secretInput (task #127): the user is typing an API key — swallow the echo so it never
1432
+ // appears in the terminal or scrollback.
1433
+ if (secretInput) return;
1360
1434
  if (_origWriteToOutput) _origWriteToOutput(s);
1361
1435
  else process.stdout.write(s);
1362
1436
  };
@@ -1369,6 +1443,7 @@ rl._refreshLine = () => {
1369
1443
  // Suppress readline's line refresh (which does clearScreenDown → would wipe our menu)
1370
1444
  // while the slash overlay owns the screen. We redraw the input line ourselves (#109).
1371
1445
  if (following || listPickerActive || slashOverlayActive) return;
1446
+ if (secretInput) return; // don't redraw the typed key (task #127)
1372
1447
  if (_origRefreshLine) _origRefreshLine();
1373
1448
  };
1374
1449
 
@@ -2198,6 +2273,19 @@ async function runAgentTurn(history) {
2198
2273
  // ---------- REPL ----------
2199
2274
  async function main() {
2200
2275
  console.log(cyan("GoNext agent REPL") + dim(` v${REPL_VERSION} · API ${apiBase} · key ${workerKey.slice(0, 8)}…`));
2276
+ // Task #127, Phase 2: the REPL enqueues jobs that the polling DAEMON executes, so ensure
2277
+ // one is running — no GoTerminal needed. Idempotent + BC4-safe: startDaemon() no-ops when
2278
+ // a daemon (ours, GoTerminal's, or a manual one) is already alive. Best-effort; a failure
2279
+ // here shouldn't block the REPL (the user can `gonext-local-worker start` manually).
2280
+ try {
2281
+ const ctl = await import("./daemon-control.mjs");
2282
+ const r = await ctl.startDaemon();
2283
+ if (r.started) {
2284
+ console.log(dim(` started background worker (pid ${r.pid}) · logs ${ctl.LOG_FILE}`));
2285
+ }
2286
+ } catch {
2287
+ /* non-fatal — jobs just won't run until a daemon is started */
2288
+ }
2201
2289
  await ensureWorkspace();
2202
2290
  // Restore this folder's remembered auto-test + deploy-target so the banner + agent-ask
2203
2291
  // reflect them (both survive a fresh terminal in the same project).
@@ -2239,8 +2327,76 @@ async function main() {
2239
2327
  (selectedServer ? ` · deploy: ${selectedServer.name} (${selectedServer.user}@${selectedServer.host})` : "")
2240
2328
  ) + (sessionTestAuto ? "" : dim(" (/test-auto to enable)"))
2241
2329
  );
2330
+ // Task #127, Phase 3: if the base chat model server isn't answering, offer to set it up
2331
+ // (download + start mlx_lm.server). BC3: only runs when NOTHING answers — an already-up
2332
+ // model is left untouched, and this is skipped entirely. Interactive TTYs only.
2333
+ if (process.stdin.isTTY && process.stdout.isTTY) {
2334
+ try {
2335
+ const md = await import("./model-doctor.mjs");
2336
+ const chatUrl = String(p.agentBaseURL || "").trim() || `http://127.0.0.1:${md.DEFAULT_CHAT_PORT}`;
2337
+ if (!(await md.probeModelServer(chatUrl))) {
2338
+ console.log(
2339
+ dim(`\ngonext: the chat model server (${md.modelRoot(chatUrl)}) isn't responding.`)
2340
+ );
2341
+ const go = await askYesNo("Set up the base model now (download if needed + start)?", true);
2342
+ if (go) {
2343
+ await md.runDoctor({
2344
+ apiBase,
2345
+ workerKey,
2346
+ log: (s) => console.log(s),
2347
+ confirm: (q, defYes) => askYesNo(q, defYes),
2348
+ });
2349
+ } else {
2350
+ console.log(dim(" Skipped. Run `gonext-local-worker doctor` anytime to set it up.\n"));
2351
+ }
2352
+ }
2353
+ } catch {
2354
+ /* doctor is best-effort — never block the REPL from starting */
2355
+ }
2356
+ // Task #127: the default coding model is a cloud coder (Kimi K3) with no API key yet.
2357
+ // Ask for it. If skipped, the account stays in this "waiting" state (server-side:
2358
+ // coder configured, no key) and we ASK AGAIN on every startup until it's set. Until
2359
+ // then the agent falls back to the chat model for coding, so the terminal still works.
2360
+ if (p.codingKeyMissing) {
2361
+ const model = String(p.codingKeyModel || "the coding model").trim();
2362
+ console.log(
2363
+ dim(`\ngonext: your agent coding model (`) + cyan(model) + dim(`) needs an API key.`)
2364
+ );
2365
+ console.log(
2366
+ dim(" Until it's set, coding falls back to your local model. Enter the key now,")
2367
+ );
2368
+ console.log(dim(" or press Enter to skip (I'll ask again next time).\n"));
2369
+ const key = await askSecret(` ${model} API key: `);
2370
+ if (key) {
2371
+ const ok = await saveCodingKey(key);
2372
+ console.log(
2373
+ ok
2374
+ ? green(` ✓ saved — ${model} is now your coding model.\n`)
2375
+ : red(" couldn't save the key — try again next start, or set it in web Settings → Agent.\n")
2376
+ );
2377
+ } else {
2378
+ console.log(
2379
+ dim(" ⏳ waiting for your ") + cyan(model) + dim(" API key — I'll ask again next time.\n")
2380
+ );
2381
+ }
2382
+ }
2383
+ }
2242
2384
  } catch (err) {
2243
- console.error(red(`gonext: ${err.message}`));
2385
+ // Task #127 (zero-config): a freshly-paired account is seeded with a default local
2386
+ // agent model, so this "no model" path is normally unreachable. An account paired
2387
+ // BEFORE that change has none — point them at re-login, which now seeds it, rather than
2388
+ // only the raw web-Settings instruction.
2389
+ if (/no agent model configured/i.test(err?.message ?? "")) {
2390
+ console.error(
2391
+ red("gonext: no agent model is configured for your account yet.") +
2392
+ dim(
2393
+ "\n Run `gonext-local-worker login` to re-connect — new logins set up a local" +
2394
+ "\n default automatically. Or set the Agent model URL in the web app → Settings → Agent."
2395
+ )
2396
+ );
2397
+ } else {
2398
+ console.error(red(`gonext: ${err.message}`));
2399
+ }
2244
2400
  process.exit(1);
2245
2401
  }
2246
2402
  console.log(dim("Ask about this repo. Type / to see commands (Tab completes). Ctrl-C aborts a running turn.\n"));
@@ -0,0 +1,571 @@
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, writeFile } from "node:fs/promises";
12
+ import { homedir, totalmem } from "node:os";
13
+ import { join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ // Minimum RAM to run the default 14B-4bit chat model comfortably on Apple Silicon.
17
+ const MIN_CHAT_RAM_GB = 16;
18
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
19
+
20
+ // The RAG embedding model + server (task #127 — parity with the Wizard's model-embed step).
21
+ // EMBED_PID_FILE / EMBED_LOG_FILE (which need DIR) are defined below, after DIR.
22
+ export const EMBED_REPO = "mlx-community/Qwen3-Embedding-8B-4bit-DWQ";
23
+ export const EMBED_DIR = join(homedir(), "mlx-models", "Qwen3-Embedding-8B-4bit-DWQ");
24
+ export const EMBED_PORT = 8085;
25
+ const EMBED_SCRIPT = fileURLToPath(new URL("./gonext_mlx_embed.py", import.meta.url));
26
+
27
+ /** The python3 the worker uses to run its agent / model tooling (mirrors the daemon). */
28
+ function python3Bin() {
29
+ return (
30
+ (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
31
+ "python3"
32
+ );
33
+ }
34
+
35
+ /** True when `python3 -c "import <mod>"` succeeds — i.e. the module is installed. */
36
+ function pyHasModule(mod) {
37
+ return new Promise((resolve) => {
38
+ execFile(python3Bin(), ["-c", `import ${mod}`], (err) => resolve(!err));
39
+ });
40
+ }
41
+
42
+ /** pip install -U <pkgs> with live progress (inherit stdio). Rejects on non-zero exit. */
43
+ function pipInstall(pkgs, log) {
44
+ log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
45
+ return new Promise((resolve, reject) => {
46
+ const child = spawn(python3Bin(), ["-m", "pip", "install", "-U", ...pkgs], {
47
+ stdio: "inherit",
48
+ env: process.env,
49
+ });
50
+ child.on("exit", (code) =>
51
+ code === 0 ? resolve() : reject(new Error(`pip install ${pkgs.join(" ")} exited ${code}`))
52
+ );
53
+ child.on("error", reject);
54
+ });
55
+ }
56
+
57
+ /**
58
+ * The yellow RAM notice shown before the multi-GB model download (user request): the
59
+ * default 14B-4bit model wants a Mac with ≥16 GB. When the machine has less, add a plain
60
+ * line stating its detected amount. Pure (RAM passed in) so it's unit-testable.
61
+ * Returns already-colored lines; empty array only if `repo` is falsy.
62
+ */
63
+ export function chatRamWarningLines(repo, totalBytes) {
64
+ if (!repo) return [];
65
+ const ramGb = Math.round((Number(totalBytes) || 0) / 1024 ** 3);
66
+ const name = String(repo).split("/").pop();
67
+ const out = [
68
+ yellow(` ⚠ ${name} needs a Mac with at least ${MIN_CHAT_RAM_GB} GB of RAM to run well.`),
69
+ ];
70
+ if (ramGb > 0 && ramGb < MIN_CHAT_RAM_GB) {
71
+ out.push(
72
+ yellow(
73
+ ` This machine has about ${ramGb} GB — the model may run very slowly or fail to load.`
74
+ )
75
+ );
76
+ }
77
+ return out;
78
+ }
79
+
80
+ const DIR = join(homedir(), ".gonext");
81
+ export const MODEL_PID_FILE = join(DIR, "model.pid");
82
+ export const MODEL_LOG_FILE = join(DIR, "model.log");
83
+ const EMBED_PID_FILE = join(DIR, "embed.pid");
84
+ const EMBED_LOG_FILE = join(DIR, "embed.log");
85
+
86
+ // The default base chat model (task #127 decision): matches the Wizard's model-chat step.
87
+ export const DEFAULT_CHAT_REPO = "mlx-community/Qwen3-14B-4bit";
88
+ export const DEFAULT_CHAT_DIR = join(homedir(), "mlx-models", "Qwen3-14B-4bit");
89
+ export const DEFAULT_CHAT_PORT = 8082;
90
+
91
+ // ---- pure helpers (unit-tested offline) -------------------------------------------------
92
+
93
+ /** The port from a base URL, or null. "http://127.0.0.1:8082/v1" → 8082. */
94
+ export function parsePort(baseUrl) {
95
+ try {
96
+ const u = new URL(String(baseUrl || "").includes("://") ? baseUrl : `http://${baseUrl}`);
97
+ if (u.port) return Number(u.port);
98
+ return u.protocol === "https:" ? 443 : 80;
99
+ } catch {
100
+ const m = String(baseUrl || "").match(/:(\d{2,5})(?:\/|$)/);
101
+ return m ? Number(m[1]) : null;
102
+ }
103
+ }
104
+
105
+ /** True when the URL points at this machine (loopback/LAN) — only then can we START it. */
106
+ export function isLocalModelUrl(baseUrl) {
107
+ let host = "";
108
+ try {
109
+ host = new URL(String(baseUrl || "").includes("://") ? baseUrl : `http://${baseUrl}`)
110
+ .hostname.toLowerCase()
111
+ .replace(/\.$/, "");
112
+ } catch {
113
+ return false;
114
+ }
115
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
116
+ if (!host) return false;
117
+ if (["localhost", "0.0.0.0", "::", "::1"].includes(host)) return true;
118
+ if (/^127(\.\d{1,3}){3}$/.test(host)) return true;
119
+ if (
120
+ /^(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(
121
+ host
122
+ )
123
+ )
124
+ return true;
125
+ if (host.includes(":") && (host.startsWith("fe80:") || /^f[cd]/.test(host))) return true;
126
+ if (/\.(local|localdomain|lan|home|internal)$/.test(host)) return true;
127
+ return !host.includes(".") && !host.includes(":");
128
+ }
129
+
130
+ /** Normalize a base URL to its root (strip a trailing /v1 and slashes). */
131
+ export function modelRoot(baseUrl) {
132
+ return String(baseUrl || "").replace(/\/+$/, "").replace(/\/v1$/i, "");
133
+ }
134
+
135
+ // ---- probing + process control ----------------------------------------------------------
136
+
137
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
138
+
139
+ /** True when an OpenAI-compatible model server answers GET {base}/v1/models. */
140
+ export async function probeModelServer(baseUrl, { timeoutMs = 3000 } = {}) {
141
+ const root = modelRoot(baseUrl);
142
+ if (!root) return false;
143
+ try {
144
+ const res = await fetch(`${root}/v1/models`, {
145
+ signal: AbortSignal.timeout(timeoutMs),
146
+ });
147
+ return res.ok;
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+
153
+ function which(cmd) {
154
+ return new Promise((resolve) => {
155
+ execFile("command", ["-v", cmd], { shell: "/bin/sh" }, (err, stdout) =>
156
+ resolve(!err && Boolean(String(stdout).trim()))
157
+ );
158
+ });
159
+ }
160
+
161
+ /** True when the model directory exists and is non-empty. */
162
+ export async function modelDirReady(dir) {
163
+ try {
164
+ const entries = await readdir(dir);
165
+ return entries.length > 0;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Start mlx_lm.server for `modelDir` on `port`, DETACHED (logs to model.log, pidfile). Waits
173
+ * until the server answers /v1/models (up to waitMs). No-op + { started:false } when a
174
+ * server already answers on that port (BC3). Returns { started, ready, pid?, reason? }.
175
+ */
176
+ export async function startChatModelServer({ modelDir, port, waitMs = 120_000, log = () => {} }) {
177
+ const base = `http://127.0.0.1:${port}`;
178
+ if (await probeModelServer(base, { timeoutMs: 1500 })) {
179
+ return { started: false, ready: true, reason: "already-running" };
180
+ }
181
+ if (!(await which("mlx_lm.server"))) {
182
+ return {
183
+ started: false,
184
+ ready: false,
185
+ reason: "mlx_lm.server not found — install it: pip install mlx-lm",
186
+ };
187
+ }
188
+ await mkdir(DIR, { recursive: true });
189
+ const out = await open(MODEL_LOG_FILE, "a");
190
+ let pid;
191
+ try {
192
+ const child = spawn("mlx_lm.server", ["--model", modelDir, "--port", String(port)], {
193
+ detached: true,
194
+ stdio: ["ignore", out.fd, out.fd],
195
+ env: process.env,
196
+ });
197
+ pid = child.pid;
198
+ const { writeFile } = await import("node:fs/promises");
199
+ await writeFile(MODEL_PID_FILE, String(pid ?? ""), "utf8");
200
+ child.unref();
201
+ } finally {
202
+ await out.close();
203
+ }
204
+ log(` starting mlx_lm.server (pid ${pid}) on port ${port} — waiting for it to load…`);
205
+ const deadline = Date.now() + waitMs;
206
+ while (Date.now() < deadline) {
207
+ await sleep(2500);
208
+ if (await probeModelServer(base, { timeoutMs: 2000 })) {
209
+ return { started: true, ready: true, pid };
210
+ }
211
+ }
212
+ return {
213
+ started: true,
214
+ ready: false,
215
+ pid,
216
+ reason: `model server did not answer within ${Math.round(waitMs / 1000)}s (still loading? see ${MODEL_LOG_FILE})`,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Download an MLX model repo to a local dir via the Hugging Face CLI. Streams progress to
222
+ * the terminal (inherit stdio) because it's a multi-GB, several-minute step. Resolves
223
+ * { ok } / rejects with guidance when `hf` is missing.
224
+ */
225
+ export async function downloadModel({ repo, dir, log = () => {} }) {
226
+ if (!(await which("hf"))) {
227
+ throw new Error(
228
+ "the Hugging Face CLI (hf) is required to download the model. Install it with: pip install huggingface_hub"
229
+ );
230
+ }
231
+ log(` downloading ${repo} → ${dir} (multi-GB, keep this open)…`);
232
+ return new Promise((resolve, reject) => {
233
+ const child = spawn("hf", ["download", repo, "--local-dir", dir], {
234
+ stdio: "inherit",
235
+ env: process.env,
236
+ });
237
+ child.on("exit", (code) =>
238
+ code === 0 ? resolve({ ok: true }) : reject(new Error(`hf download exited ${code}`))
239
+ );
240
+ child.on("error", (e) => reject(e));
241
+ });
242
+ }
243
+
244
+ /** Fetch the account's resolved model config from /agent-payload (best-effort). */
245
+ async function fetchModelConfig(apiBase, workerKey) {
246
+ try {
247
+ const res = await fetch(`${apiBase.replace(/\/+$/, "")}/api/worker/agent-payload`, {
248
+ method: "POST",
249
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
250
+ body: JSON.stringify({ messages: [{ role: "user", content: "ping" }] }),
251
+ });
252
+ if (!res.ok) return null;
253
+ const data = await res.json();
254
+ return data?.payload ?? null;
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * The Phase-3 orchestration (task #127). Ensures the base CHAT model is running (starting a
262
+ * local mlx_lm.server, downloading first if the dir is missing and the user opts in), and
263
+ * probes the CODE model, giving guidance for a remote one. BC3-safe throughout: an
264
+ * already-answering server is left untouched.
265
+ *
266
+ * `log(str)` prints; `confirm(question, defaultYes)→bool` gates the multi-GB download and
267
+ * the model-server start (non-interactive callers pass a confirm that returns false, so the
268
+ * doctor only PROBES + advises and never downloads/starts unattended).
269
+ */
270
+ export async function runDoctor({ apiBase, workerKey, log, confirm }) {
271
+ const p = (await fetchModelConfig(apiBase, workerKey)) ?? {};
272
+
273
+ // Step 0: the agent's PYTHON deps must be present or every agent turn crashes at import
274
+ // — and the model download/start needs `hf`/`mlx-lm`. Ensure these FIRST (task #127:
275
+ // parity with the Wizard's pip-mlx-lm / pip-agent-libs / hf-cli steps). Fatal-ish: if the
276
+ // core framework is missing, say so, but keep going so the model steps still run.
277
+ const deps = await ensureAgentDeps({ log, confirm });
278
+
279
+ // Chat model: prefer the account's configured URL; fall back to the default local one.
280
+ const configuredChat = String(p.agentBaseURL || "").trim();
281
+ const chatUrl = configuredChat || `http://127.0.0.1:${DEFAULT_CHAT_PORT}`;
282
+ const chatPort = parsePort(chatUrl) || DEFAULT_CHAT_PORT;
283
+ // Review H2: never serve a model the account didn't configure. Only the KNOWN default is
284
+ // auto-downloadable; for a specifically-named other model we start its dir if present but
285
+ // won't substitute Qwen3-14B. "default_model" is the direct-URL sentinel (the loaded
286
+ // model), which we treat as the default. modelId empty (fresh account) → default too.
287
+ const modelId = String(p.agentModelId || "").trim();
288
+ const isDefaultModel =
289
+ !modelId || modelId === "default_model" || /qwen3-?14b/i.test(modelId);
290
+ const chatDir = isDefaultModel
291
+ ? DEFAULT_CHAT_DIR
292
+ : join(homedir(), "mlx-models", modelId.replace(/[^A-Za-z0-9._-]/g, "-"));
293
+ const chatRepo = isDefaultModel ? DEFAULT_CHAT_REPO : null; // null = not auto-downloadable
294
+
295
+ // The chat handling sets `chat` but never returns early — the code-model check below is
296
+ // independent and must run even when the user declines the chat-model setup.
297
+ let chat = "checked";
298
+ log("");
299
+ log(" Checking the base chat model…");
300
+ if (await probeModelServer(chatUrl)) {
301
+ log(` ✓ chat model already running at ${modelRoot(chatUrl)} — leaving it alone.`);
302
+ chat = "already-running";
303
+ } else if (configuredChat && !isLocalModelUrl(chatUrl)) {
304
+ // Configured but REMOTE and not answering — we can't start a model on another machine.
305
+ log(` ⚠ your chat model server (${modelRoot(chatUrl)}) isn't answering.`);
306
+ log(" It's remote, so start it there — this machine can't launch it.");
307
+ chat = "remote";
308
+ } else {
309
+ // Local (or unconfigured → default local): ensure the RIGHT model, then offer to start.
310
+ const dir = chatDir;
311
+ let dirReady = await modelDirReady(dir);
312
+ if (!dirReady && !chatRepo) {
313
+ // A specifically-named, non-default model we don't know how to fetch — DON'T
314
+ // substitute the default. Tell the user how to bring their own (review H2).
315
+ log(` ⚠ your account uses the chat model "${modelId}", which isn't the built-in`);
316
+ log(` default, so I can't auto-download it. Put it at ${dir} (or any dir) and run:`);
317
+ log(` mlx_lm.server --model <that dir> --port ${chatPort}`);
318
+ chat = "unknown-model";
319
+ } else if (!dirReady) {
320
+ // Yellow RAM notice before the multi-GB download, THEN the confirm (user request).
321
+ for (const line of chatRamWarningLines(chatRepo, totalmem())) log(line);
322
+ const yes = await confirm(
323
+ `Download the base chat model ${chatRepo} (~8 GB) to ${dir}?`,
324
+ true
325
+ );
326
+ if (!yes) {
327
+ log(" Skipped. To do it yourself:");
328
+ log(` hf download ${chatRepo} --local-dir ${dir}`);
329
+ log(` mlx_lm.server --model ${dir} --port ${chatPort}`);
330
+ chat = "skipped";
331
+ } else {
332
+ try {
333
+ await downloadModel({ repo: chatRepo, dir, log });
334
+ dirReady = true;
335
+ } catch (e) {
336
+ log(` ⚠ ${e instanceof Error ? e.message : e}`);
337
+ chat = "download-failed";
338
+ }
339
+ }
340
+ }
341
+ if (dirReady && chat === "checked") {
342
+ const yes = await confirm(`Start the chat model server on port ${chatPort} now?`, true);
343
+ if (!yes) {
344
+ log(` Skipped. Start it later: mlx_lm.server --model ${dir} --port ${chatPort}`);
345
+ chat = "not-started";
346
+ } else {
347
+ // Review M2: don't block the caller (the REPL) for a full cold model load — that's
348
+ // minutes. Wait only long enough to confirm the server launched and didn't crash
349
+ // (~20s); if it's still loading, say so and return. The first agent turn will wait
350
+ // for it. A missing binary / immediate exit still surfaces as an error.
351
+ const r = await startChatModelServer({
352
+ modelDir: dir,
353
+ port: chatPort,
354
+ waitMs: 20_000,
355
+ log,
356
+ });
357
+ if (r.ready) {
358
+ log(` ✓ chat model is up on port ${chatPort}.`);
359
+ chat = "started";
360
+ } else if (r.started) {
361
+ log(` … chat model is loading in the background on port ${chatPort} (large model —`);
362
+ log(` the first answer may take a minute). Watch it: gonext-local-worker logs`);
363
+ chat = "loading";
364
+ } else {
365
+ log(` ⚠ ${r.reason}`);
366
+ chat = "start-failed";
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ // Code model (Ollama): probe + guide only — never auto-pull (BC3 + bounded scope).
373
+ const codeUrl = String(p.codingBaseURL || "").trim();
374
+ const codeModel = String(p.codingModelId || "").trim();
375
+ if (codeUrl) {
376
+ log("");
377
+ log(" Checking the agent code model…");
378
+ if (await probeModelServer(codeUrl)) {
379
+ log(` ✓ code model server is up at ${modelRoot(codeUrl)}.`);
380
+ } else if (isLocalModelUrl(codeUrl)) {
381
+ log(` ⚠ local code model server (${modelRoot(codeUrl)}) isn't answering.`);
382
+ if (codeModel) log(` Start Ollama and pull it: ollama serve & ollama pull ${codeModel}`);
383
+ } else {
384
+ log(` ⚠ your code model runs on a remote server (${modelRoot(codeUrl)}) that isn't`);
385
+ log(` answering. Start it there${codeModel ? ` (ollama pull ${codeModel})` : ""}.`);
386
+ }
387
+ }
388
+
389
+ // RAG (task #127: the user wants the terminal RAG-ready). Ensure the embedding model +
390
+ // server so rag_index / rag_search work. Uses the account's configured embed URL when
391
+ // local, else the default local one on EMBED_PORT.
392
+ const rag = await ensureRag({ p, log, confirm });
393
+
394
+ return { chat, deps, rag };
395
+ }
396
+
397
+ /**
398
+ * Ensure the agent's required runtime deps (task #127 — parity with the Wizard's pip steps).
399
+ * python3 can't be auto-installed (guidance only); hf / mlx-lm / smolagents+openai are
400
+ * offered via pip. Each install is confirmed. Returns a status map; missing deps are
401
+ * reported, never silently ignored — the agent hard-fails without smolagents+openai.
402
+ */
403
+ export async function ensureAgentDeps({ log, confirm }) {
404
+ const out = {};
405
+ log("");
406
+ log(" Checking the agent's dependencies…");
407
+ // python3 — the whole agent runs on it; we can't install it for the user.
408
+ if (!(await pyHasModule("sys"))) {
409
+ log(` ⚠ ${python3Bin()} not found. Install Python 3 (Xcode Command Line Tools or`);
410
+ log(" python.org), then run `gonext-local-worker doctor` again.");
411
+ out.python3 = "missing";
412
+ return out; // nothing else can be installed without python
413
+ }
414
+ out.python3 = "ok";
415
+
416
+ const ensurePkg = async (key, probeMod, pkgs, question) => {
417
+ if (await probeMod()) {
418
+ out[key] = "present";
419
+ return;
420
+ }
421
+ if (!(await confirm(question, true))) {
422
+ out[key] = "skipped";
423
+ log(` Skipped ${pkgs.join(" ")} — install later: ${python3Bin()} -m pip install -U ${pkgs.join(" ")}`);
424
+ return;
425
+ }
426
+ try {
427
+ await pipInstall(pkgs, log);
428
+ out[key] = "installed";
429
+ } catch (e) {
430
+ log(` ⚠ ${e instanceof Error ? e.message : e}`);
431
+ out[key] = "failed";
432
+ }
433
+ };
434
+
435
+ // hf CLI (model downloads). `huggingface_hub` provides the `hf` command.
436
+ await ensurePkg(
437
+ "hf",
438
+ () => which("hf"),
439
+ ["huggingface_hub"],
440
+ "Install the Hugging Face CLI (needed to download models)?"
441
+ );
442
+ // mlx-lm — runs the local chat + embedding model servers.
443
+ await ensurePkg("mlxLm", () => pyHasModule("mlx_lm"), ["mlx-lm"], "Install mlx-lm (runs the local model servers)?");
444
+ // The agent framework itself — WITHOUT this every agent turn crashes at import.
445
+ await ensurePkg(
446
+ "agentLibs",
447
+ async () => (await pyHasModule("smolagents")) && (await pyHasModule("openai")),
448
+ ["smolagents", "openai"],
449
+ "Install the agent framework (smolagents + openai) — required to run the agent?"
450
+ );
451
+ if (out.agentLibs === "skipped" || out.agentLibs === "failed") {
452
+ log(yellow(" ⚠ Without smolagents + openai the agent can't run — install them before asking a question."));
453
+ }
454
+ return out;
455
+ }
456
+
457
+ /**
458
+ * Start the MLX embeddings server (gonext_mlx_embed.py) DETACHED on `port` for `modelDir`,
459
+ * with its own pidfile/log. No-op + { ready:true } when one already answers (BC3). Waits up
460
+ * to waitMs for /v1/models to respond. Mirrors startChatModelServer.
461
+ */
462
+ export async function startEmbedServer({ modelDir, port, waitMs = 60_000, log = () => {} }) {
463
+ const base = `http://127.0.0.1:${port}`;
464
+ if (await probeModelServer(base, { timeoutMs: 1500 })) {
465
+ return { started: false, ready: true, reason: "already-running" };
466
+ }
467
+ if (!(await pyHasModule("mlx_lm"))) {
468
+ return { started: false, ready: false, reason: "mlx-lm not installed (pip install mlx-lm)" };
469
+ }
470
+ await mkdir(DIR, { recursive: true });
471
+ const outLog = await open(EMBED_LOG_FILE, "a");
472
+ let pid;
473
+ try {
474
+ const child = spawn(
475
+ python3Bin(),
476
+ [EMBED_SCRIPT, "--model", modelDir, "--port", String(port), "--host", "127.0.0.1"],
477
+ { detached: true, stdio: ["ignore", outLog.fd, outLog.fd], env: process.env }
478
+ );
479
+ pid = child.pid;
480
+ await writeFile(EMBED_PID_FILE, String(pid ?? ""), "utf8");
481
+ child.unref();
482
+ } finally {
483
+ await outLog.close();
484
+ }
485
+ log(` starting embeddings server (pid ${pid}) on port ${port} — waiting for it to load…`);
486
+ const deadline = Date.now() + waitMs;
487
+ while (Date.now() < deadline) {
488
+ await sleep(2500);
489
+ if (await probeModelServer(base, { timeoutMs: 2000 })) return { started: true, ready: true, pid };
490
+ }
491
+ return { started: true, ready: false, pid, reason: `embeddings server still loading (see ${EMBED_LOG_FILE})` };
492
+ }
493
+
494
+ /**
495
+ * Ensure RAG is ready for the agent terminal (task #127): boto3 (cloud/S3 storage), the
496
+ * embedding model, and a running embeddings server. Uses the account's configured embed URL
497
+ * when it's local; otherwise the default local server on EMBED_PORT. BC3: an already-running
498
+ * embed server is left alone. Returns a status map.
499
+ */
500
+ export async function ensureRag({ p, log, confirm }) {
501
+ const out = {};
502
+ log("");
503
+ log(" Checking RAG (agent knowledge base)…");
504
+ // boto3 — needed only for CLOUD (S3) RAG; local RAG stores on disk. Cheap + harmless, so
505
+ // offer it so cloud RAG works later without another setup pass.
506
+ if (!(await pyHasModule("boto3"))) {
507
+ if (await confirm("Install boto3 (needed only if you use cloud/S3 RAG)?", true)) {
508
+ try {
509
+ await pipInstall(["boto3"], log);
510
+ out.boto3 = "installed";
511
+ } catch (e) {
512
+ log(` ⚠ ${e instanceof Error ? e.message : e}`);
513
+ out.boto3 = "failed";
514
+ }
515
+ } else out.boto3 = "skipped";
516
+ } else out.boto3 = "present";
517
+
518
+ // Embed server URL: the account's configured local one, else the default local server.
519
+ const configuredEmbed = String(p?.ragEmbedUrl || "").trim();
520
+ const embedUrl =
521
+ configuredEmbed && isLocalModelUrl(configuredEmbed)
522
+ ? configuredEmbed
523
+ : `http://127.0.0.1:${EMBED_PORT}`;
524
+ const embedPort = parsePort(embedUrl) || EMBED_PORT;
525
+
526
+ if (await probeModelServer(embedUrl)) {
527
+ log(` ✓ embeddings server already running at ${modelRoot(embedUrl)} — leaving it alone.`);
528
+ out.embed = "already-running";
529
+ return out;
530
+ }
531
+ if (configuredEmbed && !isLocalModelUrl(configuredEmbed)) {
532
+ log(` ⚠ your embeddings server (${modelRoot(configuredEmbed)}) is remote and isn't`);
533
+ log(" answering. Start it there — this machine can't launch it.");
534
+ out.embed = "remote";
535
+ return out;
536
+ }
537
+ // Download the embed model if missing.
538
+ if (!(await modelDirReady(EMBED_DIR))) {
539
+ if (!(await confirm(`Download the RAG embedding model ${EMBED_REPO.split("/").pop()} (~4 GB)?`, true))) {
540
+ log(" Skipped. To do it yourself:");
541
+ log(` hf download ${EMBED_REPO} --local-dir ${EMBED_DIR}`);
542
+ log(` gonext-local-worker embed --model ${EMBED_DIR} --port ${embedPort}`);
543
+ out.embed = "skipped-model";
544
+ return out;
545
+ }
546
+ try {
547
+ await downloadModel({ repo: EMBED_REPO, dir: EMBED_DIR, log });
548
+ } catch (e) {
549
+ log(` ⚠ ${e instanceof Error ? e.message : e}`);
550
+ out.embed = "download-failed";
551
+ return out;
552
+ }
553
+ }
554
+ if (!(await confirm(`Start the RAG embeddings server on port ${embedPort} now?`, true))) {
555
+ log(` Skipped. Start it later: gonext-local-worker embed --model ${EMBED_DIR} --port ${embedPort}`);
556
+ out.embed = "not-started";
557
+ return out;
558
+ }
559
+ const r = await startEmbedServer({ modelDir: EMBED_DIR, port: embedPort, log });
560
+ if (r.ready) {
561
+ log(` ✓ embeddings server is up on port ${embedPort}.`);
562
+ out.embed = "started";
563
+ } else if (r.started) {
564
+ log(` … embeddings server is loading in the background on port ${embedPort}.`);
565
+ out.embed = "loading";
566
+ } else {
567
+ log(` ⚠ ${r.reason}`);
568
+ out.embed = "start-failed";
569
+ }
570
+ return out;
571
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.337",
3
+ "version": "1.0.346",
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",