@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
package/package.json ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "name": "@tiens.nguyen/gu-cli",
3
+ "version": "1.0.686",
4
+ "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/tiennsloit/gu.git"
10
+ },
11
+ "homepage": "https://github.com/tiennsloit/gu#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/tiennsloit/gu/issues"
14
+ },
15
+ "bin": {
16
+ "gu-cli": "./gu-cli.mjs",
17
+ "gu-local-worker": "./gu-cli.mjs",
18
+ "gu": "./gu-repl.mjs"
19
+ },
20
+ "scripts": {
21
+ "test": "python3 -m unittest discover tests -v && node --test",
22
+ "test:js": "node --test",
23
+ "test:keys": "node --test tests/key-choice-regression.test.mjs tests/coding-key-line.test.mjs tests/coding-key-picker.test.mjs tests/coding-key-prompt.test.mjs tests/coding-key-notice.test.mjs tests/coding-key-fallback-line.test.mjs tests/manage-coding-providers.test.mjs tests/add-coding-provider.test.mjs tests/coding-provider-setup.test.mjs",
24
+ "deploy:local": "npm install -g .",
25
+ "run": "gu-cli",
26
+ "publish:org": "npm publish --access public",
27
+ "preflight": "node scripts/preflight.mjs",
28
+ "test:e2e": "GONEXT_E2E=1 node --test tests/client-expert-e2e.test.mjs",
29
+ "test:linux": "bash scripts/e2e-linux.sh",
30
+ "test:rag": "node scripts/rag-e2e.mjs",
31
+ "test:ollama1-e2e": "GONEXT_OLLAMA1_LIVE=1 node --test --test-timeout=1800000 tests/ollama1-e2e.test.mjs",
32
+ "test:ollama1": "GONEXT_OLLAMA1_LIVE=1 python3 -m unittest discover tests -p \"test_ollama1_live.py\" -v",
33
+ "test:proxy": "python3 -m unittest discover tests -p \"test_proxy.py\" && node --test --test-timeout=30000 tests/proxy-config.test.mjs tests/proxy-transport.test.mjs tests/proxy-local-bypass.test.mjs tests/proxy-selftest.test.mjs tests/proxy-command.test.mjs",
34
+ "coverage": "node scripts/coverage.mjs",
35
+ "capture:cache": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-cache-fixture.mjs",
36
+ "test:cache-e2e": "GONEXT_OLLAMA1_LIVE=1 node --test --test-timeout=1800000 tests/prefix-cache-e2e.test.mjs",
37
+ "test:tokens": "python3 -m unittest discover tests -p \"test_cached_tokens.py\" -v && node --test tests/prefix-cache.test.mjs tests/agent-turn-shape.test.mjs",
38
+ "test:selection": "node --test tests/selection-scrollback.test.mjs tests/selection-while-thinking.test.mjs tests/terminal-selection.test.mjs tests/terminal-copy.test.mjs tests/terminal-viewport.test.mjs tests/hover-repaint.test.mjs",
39
+ "capture:turn": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-turn-fixture.mjs",
40
+ "test:bearer": "node --test tests/bearer-routing.test.mjs tests/bearer-verify.test.mjs",
41
+ "test:bearer-e2e": "bash scripts/bearer-e2e.sh"
42
+ },
43
+ "files": [
44
+ "gu-cli.mjs",
45
+ "gu-repl.mjs",
46
+ "terminal-hover.mjs",
47
+ "terminal-layout.mjs",
48
+ "terminal-viewport.mjs",
49
+ "terminal-copy.mjs",
50
+ "version-check.mjs",
51
+ "rag-selftest.mjs",
52
+ "device-login.mjs",
53
+ "daemon-control.mjs",
54
+ "poll-errors.mjs",
55
+ "node-version.mjs",
56
+ "clear-state.mjs",
57
+ "platform-tools.mjs",
58
+ "job-pools.mjs",
59
+ "rag-job-config.mjs",
60
+ "completed-flush.mjs",
61
+ "agent-model-label.mjs",
62
+ "coding-choice-tags.mjs",
63
+ "coding-provider-setup.mjs",
64
+ "client-expert-setup.mjs",
65
+ "client-expert-run.mjs",
66
+ "client-expert-api.mjs",
67
+ "first-run-steps.mjs",
68
+ "proxy-config.mjs",
69
+ "agent-model-command.mjs",
70
+ "ollama-setup.mjs",
71
+ "s3-setup.mjs",
72
+ "doctor-check.mjs",
73
+ "embed-model-command.mjs",
74
+ "proxy-command.mjs",
75
+ "proxy-selftest.mjs",
76
+ "proxy-store.mjs",
77
+ "proxy-dispatcher.mjs",
78
+ "model-doctor.mjs",
79
+ "coding-key-prompt.mjs",
80
+ "gonext_probe_agent.py",
81
+ "gonext_agent_chat.py",
82
+ "gonext_transcribe.py",
83
+ "gonext_mlx_embed.py",
84
+ "thinking_words.txt",
85
+ "README.md",
86
+ "launchd/"
87
+ ],
88
+ "publishConfig": {
89
+ "access": "public"
90
+ },
91
+ "dependencies": {
92
+ "dotenv": "^16.4.5",
93
+ "express": "^4.21.0",
94
+ "openai": "^4.77.0",
95
+ "undici": "^7.29.0"
96
+ },
97
+ "engines": {
98
+ "node": ">=18"
99
+ }
100
+ }
@@ -0,0 +1,520 @@
1
+ /**
2
+ * The three questions the CLI asks the operating system, each answered in ONE place (#157).
3
+ *
4
+ * · which processes are running, and what command started them
5
+ * · is this particular pid still one of ours
6
+ * · what is listening on a port
7
+ *
8
+ * They were previously answered inline with `ps` and `lsof`, which do not exist on Windows —
9
+ * so the platform difference lived at every call site, and the next POSIX assumption would
10
+ * have been added somewhere nobody thinks to look. One concept, one function, the platform
11
+ * inside it.
12
+ *
13
+ * COMMAND SELECTION AND OUTPUT PARSING ARE PURE and are tested on macOS against recorded
14
+ * Windows output. Actually running them is not testable from here: nothing in this file has
15
+ * been executed on Windows, and a parser with tests is not a working port.
16
+ */
17
+ import { execFile } from "node:child_process";
18
+
19
+ const IS_WINDOWS = process.platform === "win32";
20
+
21
+ const run = (cmd, args, opts = {}) =>
22
+ new Promise((resolve) => {
23
+ execFile(cmd, args, { maxBuffer: 8 * 1024 * 1024, ...opts }, (err, stdout) =>
24
+ resolve(err ? null : String(stdout ?? ""))
25
+ );
26
+ });
27
+
28
+ /* ------------------------------------------------------------------ process listing ----- */
29
+
30
+ /** The command that lists every process with its full command line. */
31
+ export function processListCommand(platform = process.platform) {
32
+ return platform === "win32"
33
+ ? {
34
+ // CIM over WMIC: wmic is deprecated and absent from newer Windows 11 images, and
35
+ // `tasklist` gives no command LINE — only an image name, which cannot distinguish our
36
+ // daemon from any other node.exe.
37
+ cmd: "powershell.exe",
38
+ args: [
39
+ "-NoProfile", "-NonInteractive", "-Command",
40
+ "Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId) $($_.CommandLine)\" }",
41
+ ],
42
+ }
43
+ : { cmd: "ps", args: ["-A", "-o", "pid=,command="] };
44
+ }
45
+
46
+ /**
47
+ * Parse a process listing into {pid, command} rows.
48
+ *
49
+ * Both platforms are made to produce "<pid> <command line>" so there is ONE parser: a second
50
+ * one would drift, and the thing it decides — is our daemon running — is not something to be
51
+ * wrong about differently per platform.
52
+ */
53
+ export function parseProcessList(stdout) {
54
+ const rows = [];
55
+ for (const line of String(stdout ?? "").split(/\r?\n/)) {
56
+ const m = /^\s*(\d+)\s+(.*)$/.exec(line);
57
+ if (!m) continue;
58
+ const command = m[2].trim();
59
+ if (!command) continue; // a pid with no command line (a protected process) tells us nothing
60
+ rows.push({ pid: Number(m[1]), command });
61
+ }
62
+ return rows;
63
+ }
64
+
65
+ /** Every running process, as {pid, command}. Empty when the platform tool is unavailable. */
66
+ export async function listProcesses() {
67
+ const { cmd, args } = processListCommand();
68
+ const out = await run(cmd, args);
69
+ return out === null ? [] : parseProcessList(out);
70
+ }
71
+
72
+ /** The command line of ONE pid, or "" if it is gone. */
73
+ export function processCommandCommand(pid, platform = process.platform) {
74
+ return platform === "win32"
75
+ ? {
76
+ cmd: "powershell.exe",
77
+ args: [
78
+ "-NoProfile", "-NonInteractive", "-Command",
79
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${Number(pid)}").CommandLine`,
80
+ ],
81
+ }
82
+ : { cmd: "ps", args: ["-p", String(pid), "-o", "command="] };
83
+ }
84
+
85
+ export async function processCommand(pid) {
86
+ const { cmd, args } = processCommandCommand(pid);
87
+ const out = await run(cmd, args);
88
+ return out === null ? "" : out.trim();
89
+ }
90
+
91
+ /* ------------------------------------------------------------------ listening ports ----- */
92
+
93
+ /** What is listening, for a set of pids. */
94
+ export function listeningPortsCommand(pids, platform = process.platform) {
95
+ const list = pids.map(Number).filter(Number.isFinite);
96
+ return platform === "win32"
97
+ ? // -ano gives numeric addresses and the owning pid, and needs no elevation.
98
+ { cmd: "netstat.exe", args: ["-ano", "-p", "TCP"], pids: list }
99
+ : {
100
+ cmd: "lsof",
101
+ args: ["-a", "-p", list.join(","), "-iTCP", "-sTCP:LISTEN", "-P", "-n"],
102
+ pids: list,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Ports being LISTENED on by `pids`, from either tool's output.
108
+ *
109
+ * Behavioural on both platforms, deliberately: "did this command start a server" is answered
110
+ * by a socket existing, never by guessing from the command name. That is what lets
111
+ * `npm start`, `flask run` and a hand-rolled server all be recognised without a list.
112
+ */
113
+ export function parseListeningPorts(stdout, pids, platform = process.platform) {
114
+ const want = new Set(pids.map(Number));
115
+ const ports = new Set();
116
+ for (const line of String(stdout ?? "").split(/\r?\n/)) {
117
+ if (platform === "win32") {
118
+ // Proto Local Address Foreign Address State PID
119
+ // TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 1234
120
+ const m = /^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i.exec(line);
121
+ if (m && want.has(Number(m[2]))) ports.add(Number(m[1]));
122
+ } else {
123
+ const m = /:(\d+)\s+\(LISTEN\)/.exec(line);
124
+ if (m) ports.add(Number(m[1]));
125
+ }
126
+ }
127
+ return [...ports].sort((a, b) => a - b);
128
+ }
129
+
130
+ /** TCP ports the given pids are listening on. */
131
+ export async function listeningPorts(pids) {
132
+ const { cmd, args } = listeningPortsCommand(pids);
133
+ const out = await run(cmd, args);
134
+ return out === null ? [] : parseListeningPorts(out, pids);
135
+ }
136
+
137
+ /* ------------------------------------------------------------------ killing a tree ------ */
138
+
139
+ /**
140
+ * Stop a process AND its children.
141
+ *
142
+ * POSIX gets one handle for the whole tree (setsid + killpg). Windows has no equivalent worth
143
+ * emulating: `taskkill /T` walks the tree by parent-pid, which is the honest substitute —
144
+ * imperfect if a child re-parents, but it does not silently leave a dev server holding a port,
145
+ * which is the failure that actually costs someone an afternoon.
146
+ */
147
+ export function killTreeCommand(pid, { force = false, platform = process.platform } = {}) {
148
+ return platform === "win32"
149
+ ? { cmd: "taskkill.exe", args: ["/PID", String(Number(pid)), "/T", ...(force ? ["/F"] : [])] }
150
+ : { cmd: "kill", args: [force ? "-9" : "-15", String(Number(pid))] };
151
+ }
152
+
153
+ export const isWindows = () => IS_WINDOWS;
154
+
155
+
156
+ /* ------------------------------------------------------------------ python ------------- */
157
+
158
+ /**
159
+ * Interpreters to try, in order, for the platform.
160
+ *
161
+ * `python3` is hardcoded across the worker and does not exist on Windows — and worse, typing
162
+ * it there usually hits the Microsoft Store alias stub, which runs nothing. The launcher
163
+ * (`py -3`) is the reliable entry point on Windows and is what the official installer
164
+ * registers.
165
+ *
166
+ * MEASURED ON REAL HARDWARE 2026-08-29 (Windows 11 Pro 26100, admin@192.168.1.117), because
167
+ * this file was written against recorded output and never executed there:
168
+ *
169
+ * python3 --version → exit 9009, "Python was not found; run without arguments to
170
+ * install from the Microsoft Store…" (C:\…\WindowsApps\python3.exe)
171
+ * py -3 -c "…" → exit 0, "3" (C:\Python314\python.exe)
172
+ *
173
+ * So on THIS build the stub exits 9009, not 0 — an earlier note here said it "looks like a
174
+ * success", and that half is wrong. It does not matter to the code, and that is the point of
175
+ * how the check is built: pythonVersionProbe asks for `sys.version_info[0]` and isPython3
176
+ * tests the OUTPUT, so the stub is rejected for printing prose whatever it exits with. A check
177
+ * that trusted the exit code would be a check that depends on a Windows build's mood.
178
+ */
179
+ export function pythonCandidates(platform = process.platform) {
180
+ return platform === "win32"
181
+ ? [
182
+ { cmd: "py", args: ["-3"] }, // the launcher: present with any official install
183
+ { cmd: "python", args: [] }, // a python on PATH
184
+ { cmd: "python3", args: [] }, // last: often the Store stub, which runs nothing
185
+ ]
186
+ : [{ cmd: "python3", args: [] }, { cmd: "python", args: [] }];
187
+ }
188
+
189
+ /**
190
+ * Does this candidate actually RUN python? Not "is it on PATH" — the Store stub is on PATH
191
+ * and answers nothing, which is the exact failure this exists to avoid.
192
+ */
193
+ export function pythonVersionProbe(candidate) {
194
+ return { cmd: candidate.cmd, args: [...candidate.args, "-c", "import sys; print(sys.version_info[0])"] };
195
+ }
196
+
197
+ /** True when the probe's output really came from a Python 3. */
198
+ export function isPython3(stdout) {
199
+ return /^3\s*$/.test(String(stdout ?? "").trim());
200
+ }
201
+
202
+ let _python = null;
203
+
204
+ /**
205
+ * The interpreter to spawn python with, as a SPAWN-READY {cmd, args}.
206
+ *
207
+ * Not a bare string: the Windows answer is `py -3`, and every call site here does
208
+ * spawn(python, [script, ...]) — a string with a space in it would be looked up as a single
209
+ * executable named "py -3" and fail. Returning the pair makes that impossible to get wrong:
210
+ *
211
+ * const py = await resolvePython();
212
+ * spawn(py.cmd, [...py.args, script, ...rest])
213
+ *
214
+ * An explicit GONEXT_PROBE_PYTHON still wins, and is passed through untouched.
215
+ * Cached: otherwise this probes a subprocess on every job.
216
+ */
217
+ export async function resolvePython() {
218
+ const explicit = (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim();
219
+ if (explicit) return { cmd: explicit, args: [] };
220
+ if (_python) return _python;
221
+ for (const candidate of pythonCandidates()) {
222
+ const probe = pythonVersionProbe(candidate);
223
+ const out = await run(probe.cmd, probe.args, { timeout: 8000 });
224
+ if (out !== null && isPython3(out)) {
225
+ _python = { cmd: candidate.cmd, args: [...candidate.args] };
226
+ return _python;
227
+ }
228
+ }
229
+ // Nothing answered. Keep today's behaviour rather than inventing one, so the failure stays
230
+ // the familiar "python3: not found" instead of a new thing to diagnose.
231
+ return process.platform === "win32"
232
+ ? { cmd: "py", args: ["-3"] }
233
+ : { cmd: "python3", args: [] };
234
+ }
235
+
236
+ /** How to SAY the resolved interpreter in a log line or error message. */
237
+ export const pythonLabel = (py) => [py.cmd, ...py.args].join(" ");
238
+
239
+ /* ------------------------------------------------- surviving the session --------------- */
240
+
241
+ /**
242
+ * Does a background process on this platform need help to outlive the session that starts it?
243
+ *
244
+ * ONLY WINDOWS, and not for the reason it looks like. `detached: true` is enough for a POSIX
245
+ * daemon, and on Windows it correctly detaches from the console — but an SSH session there runs
246
+ * inside a JOB OBJECT, and Windows kills every process in a job when the job ends. A detached
247
+ * child is still IN the job, so `gu-cli api start` over SSH starts a server that is dead
248
+ * the moment you disconnect.
249
+ *
250
+ * Proved on a real Windows 11 box: the API died, and so did the worker DAEMON — which uses the
251
+ * same detached spawn that survives perfectly when started from an ordinary console window.
252
+ * Same code, same flags; the only difference was who its parent was.
253
+ */
254
+ /** How long to let a scheduled task's process get going before removing the task. */
255
+ export const TASK_SETTLE_MS = Number(process.env.GONEXT_TASK_SETTLE_MS ?? "6000") || 6000;
256
+
257
+ export function needsJobBreakaway(platform = process.platform) {
258
+ return platform === "win32";
259
+ }
260
+
261
+ /**
262
+ * The launcher script contents for a Windows background process.
263
+ *
264
+ * A FILE, not an inline command, because `schtasks /tr` takes one quoted string and the command
265
+ * needs its own quotes for paths with spaces — nesting them is where this breaks. A script has
266
+ * no quoting problem at all, and it can be read afterwards to see exactly what was run.
267
+ */
268
+ export function windowsLauncherScript({ cmd, args = [], cwd, env = {}, logFile }) {
269
+ const lines = ["@echo off"];
270
+ for (const [k, v] of Object.entries(env)) {
271
+ // SKIP names cmd cannot set. Windows really has `ProgramFiles(x86)` and
272
+ // `CommonProgramFiles(x86)`; `set ProgramFiles(x86)=...` is a syntax error, and one bad
273
+ // line does not fail loudly — the script keeps going and the process never starts. That is
274
+ // what happened when the daemon was handed the whole of process.env: the API, which passes
275
+ // four variables, worked; the daemon, which passed hundreds, silently did not.
276
+ //
277
+ // Nothing is lost by skipping them: the scheduler runs the task AS THE USER, so the real
278
+ // environment is inherited anyway. This block is only for the EXTRAS a caller adds.
279
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
280
+ // A newline in a value would end the `set` and turn the rest into a command.
281
+ if (/[\r\n]/.test(String(v))) continue;
282
+ // No quotes around the value: `set X="v"` puts the QUOTES in the variable. A trailing
283
+ // space is equally fatal — `set PORT=4100 ` sets PORT to "4100 ".
284
+ lines.push(`set ${k}=${String(v).replace(/\s+$/, "")}`);
285
+ }
286
+ if (cwd) lines.push(`cd /d "${cwd}"`);
287
+ const command = [`"${cmd}"`, ...args.map((a) => `"${a}"`)].join(" ");
288
+ const redirected = logFile ? `${command} >> "${logFile}" 2>&1` : command;
289
+ // `start "" /b` so THIS SCRIPT RETURNS IMMEDIATELY and the task instance completes.
290
+ //
291
+ // Without it the task stays in state Running for as long as the server does — and removing a
292
+ // RUNNING task terminates its instance, taking the process with it. That is precisely what
293
+ // happened: with a long settle the daemon started and its log grew; with the normal 6s settle
294
+ // the task was deleted while still running and the daemon vanished, leaving no error anywhere.
295
+ // The empty "" is the window TITLE argument — omit it and `start` treats a quoted path as the
296
+ // title and runs nothing.
297
+ lines.push(`start "" /b ${redirected}`);
298
+ return lines.join("\r\n") + "\r\n";
299
+ }
300
+
301
+ /**
302
+ * Register → run → deregister. The task is a LAUNCH MECHANISM, not a persistent registration:
303
+ * it exists only to start the process outside our job, and is removed immediately afterwards
304
+ * so nothing is left scheduled on the user's machine. The started process keeps running —
305
+ * deleting a task does not kill what it launched (verified).
306
+ */
307
+ export function schtasksCommands(taskName, scriptPath) {
308
+ return {
309
+ create: {
310
+ cmd: "schtasks.exe",
311
+ args: ["/create", "/tn", taskName, "/tr", scriptPath, "/sc", "once", "/st", "00:00", "/f"],
312
+ },
313
+ run: { cmd: "schtasks.exe", args: ["/run", "/tn", taskName] },
314
+ remove: { cmd: "schtasks.exe", args: ["/delete", "/tn", taskName, "/f"] },
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Which pid owns a listening port, from `netstat -ano`.
320
+ *
321
+ * Needed because a task-launched process is not our child — there is no pid to record. The
322
+ * port is the only handle we have on it, and it is the one that matters anyway: what we care
323
+ * about is "who is serving this", not "who did we spawn".
324
+ */
325
+ export function portOwnerPid(stdout, port) {
326
+ const want = Number(port);
327
+ for (const line of String(stdout ?? "").split(/\r?\n/)) {
328
+ const m = /^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i.exec(line);
329
+ if (m && Number(m[1]) === want) return Number(m[2]);
330
+ }
331
+ return null;
332
+ }
333
+
334
+ /**
335
+ * Start a long-lived background process that OUTLIVES this session, on any platform.
336
+ *
337
+ * POSIX: an ordinary detached spawn — nothing else is needed.
338
+ * Windows: written to a launcher script and started by the TASK SCHEDULER, because the
339
+ * scheduler's process is not in our job object and neither is what it starts. The task is
340
+ * removed immediately afterwards; it is a launch mechanism, not a registration, and deleting
341
+ * it does not kill what it started.
342
+ *
343
+ * Returns { ok, pid, how } — `pid` may be null on Windows, where the process is not our child.
344
+ * The caller finds it by the port it is serving, which is the better question anyway.
345
+ */
346
+ export async function spawnSurviving({ cmd, args = [], cwd, env = {}, logFile, taskName }) {
347
+ if (!needsJobBreakaway()) {
348
+ const { spawn } = await import("node:child_process");
349
+ const { open } = await import("node:fs/promises");
350
+ const log = logFile ? await open(logFile, "a") : null;
351
+ const child = spawn(cmd, args, {
352
+ cwd,
353
+ env: { ...process.env, ...env },
354
+ stdio: ["ignore", log ? log.fd : "ignore", log ? log.fd : "ignore"],
355
+ detached: true,
356
+ });
357
+ child.unref();
358
+ await log?.close().catch(() => {});
359
+ return { ok: true, pid: child.pid ?? null, how: "detached" };
360
+ }
361
+
362
+ const { writeFile, mkdir, rm } = await import("node:fs/promises");
363
+ const { dirname, join } = await import("node:path");
364
+ const script = join(dirname(logFile || cwd || "."), `${taskName}.cmd`);
365
+ await mkdir(dirname(script), { recursive: true }).catch(() => {});
366
+
367
+ // THE SCHEDULER'S ENVIRONMENT IS NOT YOURS. A task launched this way starts with a minimal
368
+ // environment — no USERPROFILE among it — so `os.homedir()` does not resolve to the user's
369
+ // profile and anything that reads ~/.gonext (worker.env, the sessions, the RAG index) fails.
370
+ // The daemon exited 1 that way, silently, with "Last Result: 1" the only trace.
371
+ //
372
+ // Carried EXPLICITLY, and only these: passing the whole of process.env is what put names cmd
373
+ // cannot set (`ProgramFiles(x86)`) into the script. Identity and PATH is what a child needs;
374
+ // everything else is the caller's business.
375
+ const IDENTITY = ["USERPROFILE", "HOMEDRIVE", "HOMEPATH", "HOME", "APPDATA", "LOCALAPPDATA", "PATH", "SystemRoot", "TEMP"];
376
+ const carried = {};
377
+ for (const k of IDENTITY) if (process.env[k]) carried[k] = process.env[k];
378
+
379
+ await writeFile(
380
+ script,
381
+ windowsLauncherScript({ cmd, args, cwd, env: { ...carried, ...env }, logFile }),
382
+ "utf8"
383
+ );
384
+
385
+ const t = schtasksCommands(taskName, script);
386
+ const created = await run(t.create.cmd, t.create.args);
387
+ if (created === null) {
388
+ // No scheduler (or no rights): fall back to a plain detached spawn. It dies with an SSH
389
+ // session, but it is correct from a console window — better than not starting at all.
390
+ const { spawn } = await import("node:child_process");
391
+ const child = spawn(cmd, args, {
392
+ cwd, env: { ...process.env, ...env }, stdio: "ignore", detached: true, windowsHide: true,
393
+ });
394
+ child.unref();
395
+ return { ok: true, pid: child.pid ?? null, how: "detached (schtasks unavailable)" };
396
+ }
397
+ await run(t.run.cmd, t.run.args);
398
+
399
+ // LET IT GET GOING BEFORE DEREGISTERING. `schtasks /run` returns as soon as the request is
400
+ // accepted, not when the process is up — and deleting the task while its instance is still
401
+ // starting kills what it started. Observed exactly that: the API logged "API listening" and
402
+ // "[mongo] connected", then vanished, with an immediate delete. The same sequence with a few
403
+ // seconds in between survived. The wait is empirical, not principled, which is why it is
404
+ // generous: this runs once per start, and being slow here is free.
405
+ await new Promise((r) => setTimeout(r, TASK_SETTLE_MS));
406
+
407
+ // Then deregister: nothing should be left scheduled on the user's machine. `/sc once` with a
408
+ // start time in the past would otherwise re-fire, and a task named after gu sitting in
409
+ // Task Scheduler forever is not something anyone asked for.
410
+ await run(t.remove.cmd, t.remove.args);
411
+ await rm(script).catch(() => {});
412
+ return { ok: true, pid: null, how: "scheduled task" };
413
+ }
414
+
415
+ /** The pid serving `port`, or null. The handle we have on a process we did not spawn. */
416
+ export async function pidOnPort(port) {
417
+ if (needsJobBreakaway()) {
418
+ const out = await run("netstat.exe", ["-ano", "-p", "TCP"]);
419
+ return out === null ? null : portOwnerPid(out, port);
420
+ }
421
+ const out = await run("lsof", ["-nP", `-iTCP:${Number(port)}`, "-sTCP:LISTEN", "-t"]);
422
+ const pid = Number(String(out ?? "").trim().split(/\s+/)[0]);
423
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
424
+ }
425
+
426
+ /* ------------------------------------------------------- "View logs" (task #166) ---------- */
427
+
428
+ /**
429
+ * Shell-quote one argument for the shell that will actually run it.
430
+ *
431
+ * NOT COSMETIC. A home directory with a space in it is routine on macOS ("/Users/Jo Smith") and
432
+ * normal on Windows, and every branch below builds a COMMAND STRING for a shell rather than an
433
+ * argv array — so an unquoted path silently becomes two arguments and the window opens tailing
434
+ * the wrong file, or nothing.
435
+ *
436
+ * POSIX uses single quotes (with the standard '\'' escape); cmd.exe has no escape inside double
437
+ * quotes, so an embedded '"' is simply dropped — it cannot appear in a Windows path anyway.
438
+ */
439
+ export function shellQuote(arg, platform = process.platform) {
440
+ const s = String(arg ?? "");
441
+ if (platform === "win32") return `"${s.replace(/"/g, "")}"`;
442
+ return `'${s.replace(/'/g, `'\\''`)}'`;
443
+ }
444
+
445
+ /**
446
+ * The command that FOLLOWS the log, per platform.
447
+ *
448
+ * Windows has no `tail`. PowerShell's `Get-Content -Wait` is the equivalent, and -Tail seeds it
449
+ * with the recent lines so the window is not blank until the next write — which is what makes it
450
+ * feel broken. Shipping `tail -f` everywhere would work on two of the three platforms the
451
+ * feature was asked for.
452
+ */
453
+ export function logTailCommand(logFile, platform = process.platform) {
454
+ if (platform === "win32") {
455
+ // SINGLE quotes for the path, inside the double-quoted -Command block. Using shellQuote()
456
+ // here produced nested double quotes —
457
+ // powershell -Command "Get-Content … -Path "C:\Users\Jo Smith\…""
458
+ // — which cmd.exe cannot parse, so the window opened on nothing. PowerShell reads '…' as a
459
+ // literal string, and doubling an embedded quote ('') is its own escape.
460
+ const psPath = `'${String(logFile ?? "").replace(/'/g, "''")}'`;
461
+ return `powershell -NoLogo -NoExit -Command "Get-Content -Wait -Tail 50 -Path ${psPath}"`;
462
+ }
463
+ return `tail -n 50 -f ${shellQuote(logFile, platform)}`;
464
+ }
465
+
466
+ /**
467
+ * How to open a NEW terminal window running `inner`, or null when this machine has none.
468
+ *
469
+ * NULL IS A REAL ANSWER and the most important one here. A headless Linux box, a container, or a
470
+ * plain SSH session has no display at all: spawning gnome-terminal there fails with a confusing
471
+ * error, or appears to succeed and does nothing. The caller prints the command to paste instead,
472
+ * which is a complete answer — a button that silently does nothing is not.
473
+ *
474
+ * `available` lists the emulators actually found on PATH; the caller supplies it so this stays
475
+ * pure and testable. Order is deliberate: the generic Debian/Ubuntu alternative first (it points
476
+ * at whatever the user actually chose), then the common desktops.
477
+ */
478
+ export function terminalOpenCommand({
479
+ platform = process.platform,
480
+ inner,
481
+ hasDisplay = true,
482
+ available = [],
483
+ } = {}) {
484
+ if (!inner) return null;
485
+ if (platform === "darwin") {
486
+ // osascript rather than `open -a Terminal <file>`: open needs a FILE, so running a command
487
+ // that way means writing a temp script and leaving it behind.
488
+ const script = `tell application "Terminal" to do script ${JSON.stringify(inner)}`;
489
+ return `osascript -e ${shellQuote(script, "darwin")}`;
490
+ }
491
+ if (platform === "win32") {
492
+ // `start ""` — the empty first argument is the WINDOW TITLE. Without it, cmd treats a quoted
493
+ // command as the title and opens an empty window, which is the classic version of this bug.
494
+ return `start "" ${inner}`;
495
+ }
496
+ // Linux and friends: a terminal is not guaranteed to exist at all.
497
+ if (!hasDisplay) return null;
498
+ for (const term of ["x-terminal-emulator", "gnome-terminal", "konsole", "xfce4-terminal", "xterm"]) {
499
+ if (available.includes(term)) {
500
+ // gnome-terminal needs `--` before the command; the others take -e.
501
+ return term === "gnome-terminal"
502
+ ? `gnome-terminal -- bash -lc ${shellQuote(inner, platform)}`
503
+ : `${term} -e bash -lc ${shellQuote(inner, platform)}`;
504
+ }
505
+ }
506
+ return null;
507
+ }
508
+
509
+ /**
510
+ * What "View logs" should DO on this machine: open a window, or tell the user what to type.
511
+ *
512
+ * Returns { action: "spawn", command } or { action: "print", command } — never throws and never
513
+ * silently does nothing, because the whole point of the link is that the log is otherwise hard
514
+ * to reach.
515
+ */
516
+ export function viewLogsPlan({ logFile, platform = process.platform, hasDisplay = true, available = [] } = {}) {
517
+ const inner = logTailCommand(logFile, platform);
518
+ const open = terminalOpenCommand({ platform, inner, hasDisplay, available });
519
+ return open ? { action: "spawn", command: open, inner } : { action: "print", command: inner, inner };
520
+ }