@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.
- package/README.md +52 -0
- package/agent-model-command.mjs +259 -0
- package/agent-model-label.mjs +159 -0
- package/clear-state.mjs +149 -0
- package/client-expert-api.mjs +736 -0
- package/client-expert-run.mjs +892 -0
- package/client-expert-setup.mjs +616 -0
- package/coding-choice-tags.mjs +69 -0
- package/coding-key-prompt.mjs +229 -0
- package/coding-provider-setup.mjs +808 -0
- package/completed-flush.mjs +105 -0
- package/daemon-control.mjs +462 -0
- package/device-login.mjs +212 -0
- package/doctor-check.mjs +239 -0
- package/embed-model-command.mjs +157 -0
- package/first-run-steps.mjs +171 -0
- package/gonext_agent_chat.py +12299 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/gu-cli.mjs +4930 -0
- package/gu-repl.mjs +10326 -0
- package/job-pools.mjs +89 -0
- package/model-doctor.mjs +1494 -0
- package/node-version.mjs +40 -0
- package/ollama-setup.mjs +832 -0
- package/package.json +100 -0
- package/platform-tools.mjs +520 -0
- package/poll-errors.mjs +141 -0
- package/proxy-command.mjs +165 -0
- package/proxy-config.mjs +255 -0
- package/proxy-dispatcher.mjs +132 -0
- package/proxy-selftest.mjs +234 -0
- package/proxy-store.mjs +69 -0
- package/rag-job-config.mjs +59 -0
- package/rag-selftest.mjs +215 -0
- package/s3-setup.mjs +85 -0
- package/terminal-copy.mjs +248 -0
- package/terminal-hover.mjs +153 -0
- package/terminal-layout.mjs +2507 -0
- package/terminal-viewport.mjs +602 -0
- package/thinking_words.txt +1003 -0
- 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
|
+
}
|