@tiens.nguyen/gu-cli 1.0.686

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.
Files changed (43) hide show
  1. package/README.md +52 -0
  2. package/agent-model-command.mjs +259 -0
  3. package/agent-model-label.mjs +159 -0
  4. package/clear-state.mjs +149 -0
  5. package/client-expert-api.mjs +736 -0
  6. package/client-expert-run.mjs +892 -0
  7. package/client-expert-setup.mjs +616 -0
  8. package/coding-choice-tags.mjs +69 -0
  9. package/coding-key-prompt.mjs +229 -0
  10. package/coding-provider-setup.mjs +808 -0
  11. package/completed-flush.mjs +105 -0
  12. package/daemon-control.mjs +462 -0
  13. package/device-login.mjs +212 -0
  14. package/doctor-check.mjs +239 -0
  15. package/embed-model-command.mjs +157 -0
  16. package/first-run-steps.mjs +171 -0
  17. package/gonext_agent_chat.py +12299 -0
  18. package/gonext_mlx_embed.py +155 -0
  19. package/gonext_probe_agent.py +93 -0
  20. package/gonext_transcribe.py +130 -0
  21. package/gu-cli.mjs +4930 -0
  22. package/gu-repl.mjs +10326 -0
  23. package/job-pools.mjs +89 -0
  24. package/model-doctor.mjs +1494 -0
  25. package/node-version.mjs +40 -0
  26. package/ollama-setup.mjs +832 -0
  27. package/package.json +100 -0
  28. package/platform-tools.mjs +520 -0
  29. package/poll-errors.mjs +141 -0
  30. package/proxy-command.mjs +165 -0
  31. package/proxy-config.mjs +255 -0
  32. package/proxy-dispatcher.mjs +132 -0
  33. package/proxy-selftest.mjs +234 -0
  34. package/proxy-store.mjs +69 -0
  35. package/rag-job-config.mjs +59 -0
  36. package/rag-selftest.mjs +215 -0
  37. package/s3-setup.mjs +85 -0
  38. package/terminal-copy.mjs +248 -0
  39. package/terminal-hover.mjs +153 -0
  40. package/terminal-layout.mjs +2507 -0
  41. package/terminal-viewport.mjs +602 -0
  42. package/thinking_words.txt +1003 -0
  43. package/version-check.mjs +72 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * What is still unshown when a job flips to "completed" (#173, and #72 from the other side).
3
+ *
4
+ * THE ONE INVARIANT EVERYTHING HERE DEPENDS ON: the completed `resultText` must EXTEND the text
5
+ * that was streamed. The REPL renders a poll by printing `resultText.slice(charsAlreadyShown)`,
6
+ * so a completed payload that REPLACES the stream with a shorter, different string breaks the
7
+ * arithmetic in both directions:
8
+ *
9
+ * · too short → the slice is empty, and a plain reply renders cut mid-word. That is #72,
10
+ * reported live as "Hello! How" out of a 36-character answer.
11
+ * · different → the slice is a garbage tail from the middle of a sentence.
12
+ *
13
+ * #72 fixed the STREAMED case by sending fullText. The non-streamed case still sent the bare
14
+ * answer, which is how #173 happened: an agent turn wraps every step line in a <think> fence and
15
+ * appends the answer after it, so the bare answer drops the entire fence. Any step emitted inside
16
+ * the last poll window was therefore never delivered — which is why "Composing answer…" has never
17
+ * once appeared on screen despite being named in task #41 as a bullet that prints.
18
+ *
19
+ * WHY THE AGENT TAIL CANNOT SIMPLY BE FLUSHED. The tail usually contains the answer too, and the
20
+ * caller prints the answer itself (renderAnswer, wrapped and indented, from the same resultText).
21
+ * Flushing the whole tail would print it twice. So the agent branch flushes only what is still
22
+ * INSIDE the fence and stops at `</think>`; everything after it belongs to the caller.
23
+ *
24
+ * Pure, and separate from gu-repl.mjs because that file starts a REPL on import and cannot be
25
+ * reached by a test — the same reason job-pools.mjs and rag-job-config.mjs exist.
26
+ */
27
+
28
+ /** Strip whole <think> blocks, and an unclosed one, exactly as the REPL's answerFrom does. */
29
+ export function answerFrom(s) {
30
+ return String(s ?? "")
31
+ .replace(/<think>[\s\S]*?<\/think>/gi, "")
32
+ .replace(/<think>[\s\S]*$/i, "")
33
+ .trim();
34
+ }
35
+
36
+ /**
37
+ * Does this completed payload continue the stream, or replace it?
38
+ *
39
+ * `seenRaw` is the first ≤4KB of what was streamed — a fingerprint, not the whole thing, so this
40
+ * stays cheap on a long turn. A payload SHORTER than what we already showed cannot be an
41
+ * extension whatever it starts with, hence both halves.
42
+ */
43
+ export function extendsStream({ resultText = "", shownChars = 0, seenRaw = "" } = {}) {
44
+ const text = String(resultText ?? "");
45
+ return text.length >= shownChars && text.startsWith(String(seenRaw ?? ""));
46
+ }
47
+
48
+ /**
49
+ * What the completed poll should print. Returns { kind, text }:
50
+ *
51
+ * "none" — nothing left to show
52
+ * "suffix" — plain reply: feed `text` to the live consumer, it continues the stream
53
+ * "recover" — plain reply whose payload did NOT extend the stream (an older worker): print
54
+ * `text`, the part of the final answer that was never shown live
55
+ * "steps" — agent turn: feed `text` (step lines only, never the answer) to the live consumer
56
+ *
57
+ * The caller still owns cursor/gutter mechanics; this owns the decision and the string.
58
+ */
59
+ export function completedFlush({
60
+ resultText = "",
61
+ shownChars = 0,
62
+ seenRaw = "",
63
+ plainReplyFlow = false,
64
+ answerShownLive = false,
65
+ liveAnswer = "",
66
+ streamPlainReply = true,
67
+ } = {}) {
68
+ const text = String(resultText ?? "");
69
+ const shown = Math.max(0, Number(shownChars) || 0);
70
+ const extended = extendsStream({ resultText: text, shownChars: shown, seenRaw });
71
+
72
+ if (plainReplyFlow) {
73
+ // Unchanged from #72 — both branches, including the gate on STREAM_PLAIN_REPLY, which is why
74
+ // it is threaded through rather than assumed.
75
+ if (!streamPlainReply) return { kind: "none", text: "" };
76
+ if (extended && text.length > shown) {
77
+ return { kind: "suffix", text: text.slice(shown) };
78
+ }
79
+ if (!extended && answerShownLive) {
80
+ const ans = answerFrom(text);
81
+ const live = String(liveAnswer ?? "");
82
+ let rest = ans.startsWith(live) ? ans.slice(live.length) : null;
83
+ if (rest === null) {
84
+ // The live text may carry trailing whitespace the trimmed answer lacks.
85
+ const printed = live.replace(/\s+$/, "");
86
+ rest = ans.startsWith(printed) ? ans.slice(printed.length) : null;
87
+ }
88
+ return rest ? { kind: "recover", text: rest } : { kind: "none", text: "" };
89
+ }
90
+ return { kind: "none", text: "" };
91
+ }
92
+
93
+ // ---- agent turn (#173) ----
94
+ // Only ever a continuation. There is no recovery branch here on purpose: when the payload does
95
+ // not extend the stream we cannot tell which step lines were already shown, and printing the
96
+ // fence again would duplicate the whole action list. Silence beats a double transcript.
97
+ if (!extended || text.length <= shown) return { kind: "none", text: "" };
98
+ const tail = text.slice(shown);
99
+ const close = tail.search(/<\/think>/i);
100
+ // No closing tag in the tail means the fence closed before this point, so the tail is the
101
+ // ANSWER — the caller's job, not ours.
102
+ if (close < 0) return { kind: "none", text: "" };
103
+ const steps = tail.slice(0, close);
104
+ return steps ? { kind: "steps", text: steps } : { kind: "none", text: "" };
105
+ }
@@ -0,0 +1,462 @@
1
+ // Worker-daemon lifecycle for the gu CLI (task #127, Phase 2). Lets a plain terminal
2
+ // keep the polling worker running WITHOUT the GoTerminal desktop app: `gu-cli
3
+ // start|stop|status|logs`, and `gu` (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
+ import {
16
+ processListCommand,
17
+ processCommandCommand,
18
+ spawnSurviving,
19
+ } from "./platform-tools.mjs";
20
+ import { isLocalApiBase } from "./terminal-layout.mjs";
21
+ const PROC_LIST = processListCommand();
22
+
23
+ const DIR = join(homedir(), ".gonext");
24
+ export const PID_FILE = join(DIR, "worker.pid");
25
+ /**
26
+ * What the worker is RUNNING RIGHT NOW, published for other processes (task #209 req 1).
27
+ *
28
+ * stopDaemon lives in a DIFFERENT process from the worker, so it cannot see the in-flight job
29
+ * map and has been SIGTERMing turns with no idea they existed. `doctor` reaches that path at the
30
+ * end of every run, which is how a user lost an hour-long turn to a dependency check.
31
+ *
32
+ * A FILE RATHER THAN AN API CALL, deliberately: the question "is a turn running on this machine"
33
+ * must be answerable when the API is down, which is exactly when someone is restarting things.
34
+ * The pid is stored WITH it so a file left behind by a SIGKILLed worker is ignored rather than
35
+ * blocking every future stop — a stale busy-file must never be able to wedge the CLI.
36
+ */
37
+ export const BUSY_FILE = join(DIR, "worker-busy.json");
38
+ export const LOG_FILE = join(DIR, "worker.log");
39
+ const LOCK_FILE = join(DIR, "worker.start.lock");
40
+ const LOCK_STALE_MS = 30_000; // a lock older than this is from a crashed start → steal it
41
+ const DAEMON_SCRIPT = fileURLToPath(new URL("./gu-cli.mjs", import.meta.url));
42
+ // The daemon script was named gonext-local-worker.mjs before the CLI was renamed to
43
+ // gu-cli. Match BOTH so BC4 (never double-spawn) still detects a daemon started by an
44
+ // OLDER install across the upgrade window.
45
+ // A CLOSING QUOTE COUNTS AS A BOUNDARY. Windows command lines quote every path, so the real
46
+ // cmdline is `"…\node.exe" "…\gu-cli.mjs"` — the character after `.mjs` is a QUOTE, not
47
+ // whitespace. Requiring `\s|$` therefore matched nothing on Windows, and the consequences were
48
+ // not subtle: `status` reported no daemon while one was polling, `stop` could not find it to
49
+ // stop it, and every `start` believed there was none — so they accumulated. Observed with TWO
50
+ // daemons running and gu insisting there were none.
51
+ const DAEMON_FILE_RE = /gu-(cli|local-worker)\.mjs(["'\s]|$)/;
52
+
53
+ // Any cmdline containing one of these is a SUBCOMMAND invocation (login, start, …), not the
54
+ // polling daemon — so it must not be mistaken for a running worker in the ps scan.
55
+ const SUBCOMMANDS = [
56
+ "login", "set", "start", "stop", "status", "logs", "restart",
57
+ "ws-ping-test", "simulate-chat", "embed", "workspace",
58
+ ];
59
+
60
+ // ---- client-only ("agent") mode -----------------------------------------------------------
61
+ // A machine that does NOT host models: the terminal here just enqueues turns, and a daemon on
62
+ // the machine that DOES have the models executes them. Machine-level on purpose (unlike the
63
+ // per-folder prompt memory in task #135): "this box has no models" is a fact about the box,
64
+ // and storing it per folder would let a second folder start a competing daemon again.
65
+ const PREFS_FILE = join(DIR, "prefs.json");
66
+
67
+ async function readPrefs() {
68
+ try {
69
+ return JSON.parse(await readFile(PREFS_FILE, "utf8")) || {};
70
+ } catch {
71
+ return {};
72
+ }
73
+ }
74
+
75
+ /**
76
+ * WHY A "CLIENT OVERRIDE" EXISTED, AND WHY IT IS GONE (task #134 → removed 2026-09-06).
77
+ *
78
+ * THE ORIGINAL PROBLEM WAS REAL. "Client mode" means this box hosts no models; it shares an
79
+ * account with the machine that does. That machine's settings said
80
+ * `agentModelUrl = http://127.0.0.1:11434/v1` — correct THERE, and on a client it resolves to
81
+ * the client itself, which has nothing listening. So a client substituted a shared, reachable
82
+ * Ollama locally and never wrote that back to the account.
83
+ *
84
+ * WHY IT HAD TO GO. The premise stopped being true. Since task #220 setup ASKS for the Ollama
85
+ * URL and stores a routable one, and since the agent model became a choice there is a chosen
86
+ * name as well. But the substitution never checked any of that — it replaced the URL AND the
87
+ * model on every client turn, so a machine configured with a reachable server and a deliberate
88
+ * model silently ran something else, and the banner reported the constant it had substituted.
89
+ * That was reported three separate times as "why does it still say <model>", because the value
90
+ * on screen was never the value the user had set. A setting that cannot be observed cannot be
91
+ * changed, and one the machine quietly discards should not be offered.
92
+ *
93
+ * WHAT REPLACES IT: nothing. The configured URL and model are used, always. When they cannot
94
+ * work from here this says so — see clientAgentProblem — because a substitution that fixes the
95
+ * turn while hiding the cause is what made this take three rounds to find.
96
+ */
97
+
98
+ /**
99
+ * Is the account's agent URL unusable FROM THIS MACHINE? → a sentence to show, or "".
100
+ *
101
+ * Only loopback qualifies, and only in client mode: that is the exact shape task #134 existed
102
+ * for — the model host's own address, meaningless on a box that is not it. Everything else is
103
+ * the user's own answer and is left alone, including addresses that turn out to be down, which
104
+ * is a different problem with a different message (see probeReachable).
105
+ */
106
+ export function clientAgentProblem({ accountUrl = "", clientMode = false } = {}) {
107
+ if (!clientMode) return "";
108
+ const url = String(accountUrl ?? "").trim();
109
+ if (!url) {
110
+ return "no agent model server is configured — run /model to point this machine at one.";
111
+ }
112
+ if (isLocalApiBase(url)) {
113
+ return (
114
+ `the agent model server is ${url}, which on this machine means THIS machine — and it ` +
115
+ "hosts no models. Run /model to point it at the box that does."
116
+ );
117
+ }
118
+ return "";
119
+ }
120
+
121
+ /**
122
+ * A stable id for THIS MACHINE, generated locally and stored in ~/.gonext/instance.
123
+ *
124
+ * Deliberately NOT derived from the worker key: client mode exists so a second machine can
125
+ * reuse the same key without logging in, which makes the key — and therefore workerHostId —
126
+ * identical on both. Job pinning needs something that actually differs per machine, and this
127
+ * is it. Created once, then read; losing the file just mints a new id (jobs pinned to the
128
+ * old one would be orphaned, so it is written atomically-enough and never rewritten).
129
+ */
130
+ const INSTANCE_FILE = join(DIR, "instance");
131
+
132
+ export async function getInstanceId() {
133
+ try {
134
+ const id = String(await readFile(INSTANCE_FILE, "utf8")).trim();
135
+ if (id) return id;
136
+ } catch {
137
+ /* first run */
138
+ }
139
+ const { randomUUID } = await import("node:crypto");
140
+ const id = randomUUID();
141
+ try {
142
+ await mkdir(DIR, { recursive: true });
143
+ await writeFile(INSTANCE_FILE, id + "\n", { encoding: "utf8", mode: 0o600 });
144
+ } catch {
145
+ /* not fatal — an in-memory id still separates this run from another machine */
146
+ }
147
+ return id;
148
+ }
149
+
150
+ /** True when this machine is marked client-only. */
151
+ export async function isClientMode() {
152
+ return (await readPrefs()).clientMode === true;
153
+ }
154
+
155
+ /** Mark (or unmark) this machine as client-only. Read-merge-write. */
156
+ export async function setClientMode(on) {
157
+ const prefs = await readPrefs();
158
+ prefs.clientMode = Boolean(on);
159
+ prefs.updatedAt = new Date().toISOString();
160
+ await mkdir(DIR, { recursive: true });
161
+ await writeFile(PREFS_FILE, JSON.stringify(prefs, null, 2) + "\n");
162
+ return PREFS_FILE;
163
+ }
164
+
165
+ /**
166
+ * The coding model's context window, as stated by the user (`/context-window`). 0 = not set.
167
+ *
168
+ * PER MACHINE, not per account, and that is a compromise worth naming. The window is really a
169
+ * property of the MODEL, so the account would be the tidier home — but writing it there means a
170
+ * new allow-listed settings field and therefore an API publish before it works anywhere, and
171
+ * this exists precisely because a probe cannot learn the number from a hosted endpoint. A file
172
+ * on the machine that needs it takes effect on the next turn. Set it once per machine; the
173
+ * env var still wins for anyone scripting it.
174
+ */
175
+ export async function getCodingContextWindow() {
176
+ const v = Number((await readPrefs()).codingContextWindow ?? 0);
177
+ return Number.isFinite(v) && v > 4096 ? Math.floor(v) : 0;
178
+ }
179
+
180
+ /**
181
+ * Store it, or clear it with 0/null. Rejects anything at or below 4096 rather than storing it.
182
+ *
183
+ * A window set too HIGH is worse than unknown — the model refuses mid-turn instead of
184
+ * compacting early — and one set absurdly LOW compacts a turn into uselessness. Both are
185
+ * refused at the door so a typo cannot become a budget.
186
+ */
187
+ export async function setCodingContextWindow(tokens) {
188
+ const n = Number(tokens);
189
+ // CLEAR IS AN EXPLICIT SET, not "anything falsy". `!tokens` also catches NaN and false, so
190
+ // setCodingContextWindow(NaN) — a parse that failed — silently WIPED the setting instead of
191
+ // being refused. Garbage must not be able to unset a working value.
192
+ const clear = tokens === 0 || tokens === "0" || tokens === null || tokens === undefined
193
+ || tokens === "";
194
+ if (!clear && (!Number.isFinite(n) || n <= 4096)) return null;
195
+ const prefs = await readPrefs();
196
+ if (clear) delete prefs.codingContextWindow;
197
+ else prefs.codingContextWindow = Math.floor(n);
198
+ prefs.updatedAt = new Date().toISOString();
199
+ await mkdir(DIR, { recursive: true });
200
+ await writeFile(PREFS_FILE, JSON.stringify(prefs, null, 2) + "\n");
201
+ return clear ? 0 : Math.floor(n);
202
+ }
203
+
204
+ function pidAlive(pid) {
205
+ if (!pid || !Number.isFinite(pid)) return false;
206
+ try {
207
+ process.kill(pid, 0);
208
+ return true;
209
+ } catch (e) {
210
+ // EPERM = the process exists but we can't signal it (still "alive" for our purposes).
211
+ return e && e.code === "EPERM";
212
+ }
213
+ }
214
+
215
+ function psList() {
216
+ return new Promise((resolve) => {
217
+ execFile(
218
+ // Windows has no `ps` — the command and its parsing live in platform-tools.mjs so the
219
+ // difference is in one place rather than at every call site (#157).
220
+ PROC_LIST.cmd,
221
+ PROC_LIST.args,
222
+ { maxBuffer: 8 * 1024 * 1024 },
223
+ (err, stdout) => {
224
+ if (err) return resolve([]);
225
+ const rows = [];
226
+ for (const line of String(stdout).split(/\r?\n/)) {
227
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
228
+ if (m) rows.push({ pid: Number(m[1]), cmd: m[2] });
229
+ }
230
+ resolve(rows);
231
+ }
232
+ );
233
+ });
234
+ }
235
+
236
+ /**
237
+ * PIDs of polling worker daemons currently running (excluding THIS process and any
238
+ * subcommand invocation). Catches a daemon started by GoTerminal / by hand, not just ours.
239
+ */
240
+ export async function findRunningDaemonPids() {
241
+ const rows = await psList();
242
+ const self = process.pid;
243
+ return rows
244
+ .filter((r) => r.pid !== self)
245
+ .filter((r) => DAEMON_FILE_RE.test(r.cmd))
246
+ .filter((r) => !SUBCOMMANDS.some((sc) => new RegExp(`(\\s|/)${sc}(\\s|$)`).test(r.cmd)))
247
+ .map((r) => r.pid);
248
+ }
249
+
250
+ /**
251
+ * True when `pid` is actually a gu polling daemon (not an unrelated process that reused
252
+ * the PID). Reads that one pid's command line — cheap and exact (review M1).
253
+ */
254
+ function pidIsGonextDaemon(pid) {
255
+ if (!pid || !Number.isFinite(pid)) return Promise.resolve(false);
256
+ return new Promise((resolve) => {
257
+ const one = processCommandCommand(pid);
258
+ execFile(one.cmd, one.args, (err, stdout) => {
259
+ const cmd = String(stdout || "").trim();
260
+ resolve(
261
+ !err &&
262
+ DAEMON_FILE_RE.test(cmd) &&
263
+ !SUBCOMMANDS.some((sc) => new RegExp(`(\\s|/)${sc}(\\s|$)`).test(cmd))
264
+ );
265
+ });
266
+ });
267
+ }
268
+
269
+ async function readPidFile() {
270
+ try {
271
+ const n = Number(String(await readFile(PID_FILE, "utf8")).trim());
272
+ return Number.isFinite(n) ? n : null;
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * { running, pids, pidFilePid }. The `ps` scan is the primary signal. The pidfile is trusted
280
+ * ONLY after VERIFYING that its PID is genuinely a gu daemon (review M1) — this both
281
+ * rescues the case where the full-table scan missed it and rejects a stale pidfile whose PID
282
+ * was reused by an unrelated process (which would otherwise wrongly suppress auto-start).
283
+ */
284
+ /** Publish (or clear) the jobs this worker is running. Best-effort: never break a turn. */
285
+ export async function writeBusyState(jobIds) {
286
+ const ids = Array.isArray(jobIds) ? jobIds.filter(Boolean) : [];
287
+ try {
288
+ if (!ids.length) {
289
+ await unlink(BUSY_FILE).catch(() => {});
290
+ return;
291
+ }
292
+ await mkdir(DIR, { recursive: true });
293
+ await writeFile(
294
+ BUSY_FILE,
295
+ JSON.stringify({ pid: process.pid, jobIds: ids, at: new Date().toISOString() }, null, 2)
296
+ );
297
+ } catch {
298
+ /* the turn matters more than the bookkeeping */
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Is a turn running on this machine? → { busy, jobIds, pid } — busy false when nothing is.
304
+ *
305
+ * IGNORES A FILE WHOSE PROCESS IS GONE. A worker killed with -9 (or a machine that lost power)
306
+ * leaves this behind, and a stale file that made every later `stop` ask a question would be a
307
+ * worse bug than the one this prevents. The pid is the liveness test, so the file can only ever
308
+ * report on a process that still exists.
309
+ */
310
+ export async function readBusyState() {
311
+ const none = { busy: false, jobIds: [], pid: null };
312
+ try {
313
+ const raw = JSON.parse(await readFile(BUSY_FILE, "utf8"));
314
+ const pid = Number(raw?.pid);
315
+ const jobIds = Array.isArray(raw?.jobIds) ? raw.jobIds.filter(Boolean) : [];
316
+ if (!Number.isFinite(pid) || pid <= 0 || !jobIds.length) return none;
317
+ if (!pidAlive(pid)) return none; // the worker is gone; the file is a leftover
318
+ return { busy: true, jobIds, pid };
319
+ } catch {
320
+ return none; // no file, unreadable, or malformed — all mean "nothing running"
321
+ }
322
+ }
323
+
324
+ export async function daemonStatus() {
325
+ const [pids, pidFilePid] = await Promise.all([findRunningDaemonPids(), readPidFile()]);
326
+ const pidFileIsDaemon =
327
+ pidFilePid != null && (pids.includes(pidFilePid) || (await pidIsGonextDaemon(pidFilePid)));
328
+ const allPids = pidFileIsDaemon && !pids.includes(pidFilePid) ? [...pids, pidFilePid] : pids;
329
+ return {
330
+ running: allPids.length > 0,
331
+ pids: allPids,
332
+ pidFilePid: pidFileIsDaemon ? pidFilePid : null,
333
+ };
334
+ }
335
+
336
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
337
+
338
+ /**
339
+ * Acquire an exclusive single-flight lock over the check→spawn window (review L2). O_EXCL
340
+ * (`wx`) is atomic, so of two concurrent starts exactly one wins. A lock left by a crashed
341
+ * start is stolen once it's older than LOCK_STALE_MS so it can never wedge startup.
342
+ * Returns a handle whose .release() removes the lock, or null if another start holds it.
343
+ */
344
+ async function acquireStartLock() {
345
+ await mkdir(DIR, { recursive: true });
346
+ for (let attempt = 0; attempt < 2; attempt++) {
347
+ try {
348
+ const fh = await open(LOCK_FILE, "wx");
349
+ await fh.writeFile(String(process.pid), "utf8");
350
+ return {
351
+ release: async () => {
352
+ await fh.close().catch(() => {});
353
+ await unlink(LOCK_FILE).catch(() => {});
354
+ },
355
+ };
356
+ } catch (e) {
357
+ if (!e || e.code !== "EEXIST") throw e;
358
+ // Held by someone else — steal it only if it's stale (a crashed start), then retry.
359
+ let age = 0;
360
+ try {
361
+ age = Date.now() - (await stat(LOCK_FILE)).mtimeMs;
362
+ } catch {
363
+ age = LOCK_STALE_MS + 1; // vanished between EEXIST and stat → treat as free
364
+ }
365
+ if (age > LOCK_STALE_MS) {
366
+ await unlink(LOCK_FILE).catch(() => {});
367
+ continue; // retry the atomic create
368
+ }
369
+ return null; // a fresh lock → another start is genuinely in progress
370
+ }
371
+ }
372
+ return null;
373
+ }
374
+
375
+ /**
376
+ * Start the polling daemon DETACHED (survives the launching shell, logs to ~/.gonext/
377
+ * worker.log). No-op when one is already running (BC4). Concurrency-safe (L2): a lock
378
+ * serializes the check→spawn window so two simultaneous starts can't both spawn.
379
+ * Returns { started, pid?, pids }.
380
+ */
381
+ export async function startDaemon() {
382
+ // Fast path: already running → no lock needed.
383
+ const pre = await daemonStatus();
384
+ if (pre.running) return { started: false, ...pre };
385
+ const lock = await acquireStartLock();
386
+ if (!lock) {
387
+ // A concurrent start holds the lock — let it win, then report the resulting state.
388
+ await sleep(700);
389
+ const s = await daemonStatus();
390
+ return { started: false, ...s };
391
+ }
392
+ try {
393
+ // Re-check UNDER the lock — the winner of a race must see the loser's daemon if it
394
+ // spawned in the gap (and vice-versa this is where the second caller no-ops).
395
+ const status = await daemonStatus();
396
+ if (status.running) return { started: false, ...status };
397
+ // spawnSurviving, not a bare detached spawn: on Windows an SSH session runs inside a JOB
398
+ // OBJECT and every process in it dies when the session ends. This daemon was the PROOF —
399
+ // started over SSH with the detached spawn it had always used, it was gone the moment the
400
+ // session closed, while the same code from a console window runs for days.
401
+ const res = await spawnSurviving({
402
+ // No env: the daemon reads worker.env itself, and spawnSurviving inherits the parent's
403
+ // environment on POSIX. Handing it process.env put HUNDREDS of variables into a Windows
404
+ // launcher script — including names cmd cannot set — and the script died before reaching
405
+ // the command. Pass extras, never the whole environment.
406
+ cmd: process.execPath,
407
+ args: [DAEMON_SCRIPT],
408
+ logFile: LOG_FILE,
409
+ taskName: "gu-worker-daemon",
410
+ });
411
+ if (res.pid) {
412
+ await writeFile(PID_FILE, String(res.pid), "utf8");
413
+ return { started: true, pid: res.pid, pids: [] };
414
+ }
415
+ // Task-launched: not our child, so wait for it to appear and record what it turned out
416
+ // to be. A pidfile that never gets written would make `stop` a no-op forever.
417
+ for (let i = 0; i < 20; i++) {
418
+ await sleep(250);
419
+ const pids = await findRunningDaemonPids();
420
+ if (pids.length) {
421
+ await writeFile(PID_FILE, String(pids[0]), "utf8");
422
+ return { started: true, pid: pids[0], pids };
423
+ }
424
+ }
425
+ return { started: true, pid: null, pids: [] };
426
+ } finally {
427
+ await lock.release();
428
+ }
429
+ }
430
+
431
+ /** Stop every running daemon (ours + any ps-found) and clear the pidfile. */
432
+ export async function stopDaemon() {
433
+ const status = await daemonStatus();
434
+ const targets = new Set(status.pids);
435
+ if (status.pidFilePid) targets.add(status.pidFilePid);
436
+ let killed = 0;
437
+ for (const pid of targets) {
438
+ try {
439
+ process.kill(pid, "SIGTERM");
440
+ killed++;
441
+ } catch {
442
+ /* already gone */
443
+ }
444
+ }
445
+ try {
446
+ await unlink(PID_FILE);
447
+ } catch {
448
+ /* no pidfile */
449
+ }
450
+ return { killed };
451
+ }
452
+
453
+ /** Last `n` lines of the daemon log (empty string when none yet). */
454
+ export async function tailLog(n = 40) {
455
+ try {
456
+ const txt = await readFile(LOG_FILE, "utf8");
457
+ const lines = txt.split("\n");
458
+ return lines.slice(Math.max(0, lines.length - n)).join("\n");
459
+ } catch {
460
+ return "";
461
+ }
462
+ }