@tiens.nguyen/gu-cli 1.0.792 → 1.0.794
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/clear-state.mjs +4 -0
- package/cli-token.mjs +25 -2
- package/client-expert-api.mjs +249 -6
- package/client-expert-run.mjs +39 -4
- package/client-expert-setup.mjs +21 -1
- package/first-run-steps.mjs +26 -0
- package/gonext_agent_chat.py +124 -0
- package/gu-cli.mjs +38 -0
- package/gu-repl.mjs +154 -17
- package/install-path.mjs +23 -1
- package/model-doctor.mjs +33 -4
- package/model-fallback.mjs +127 -0
- package/node-version.mjs +50 -2
- package/package.json +2 -1
- package/platform-tools.mjs +49 -3
- package/terminal-layout.mjs +46 -2
package/install-path.mjs
CHANGED
|
@@ -56,11 +56,33 @@ if (isEntryPoint) {
|
|
|
56
56
|
main();
|
|
57
57
|
} catch (e) {
|
|
58
58
|
// A postinstall that throws uninstalls the package. Report, swallow, succeed.
|
|
59
|
-
console.log(`gu-cli: PATH setup skipped (${e.message}) —
|
|
59
|
+
console.log(`gu-cli: PATH setup skipped (${e.message}) — run \`gu-cli fix-path\` if \`gu\` is not found.`);
|
|
60
60
|
}
|
|
61
61
|
process.exit(0);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The npm global prefix this package is installed under, from where its own files are — or "".
|
|
66
|
+
*
|
|
67
|
+
* For `gu-cli fix-path` (task #333): the same PATH repair as this postinstall, WITHOUT npm
|
|
68
|
+
* running it. npm 11 already prints "install scripts not yet covered by allowScripts" for this
|
|
69
|
+
* package on every install; it still runs the script today (checked on 11.19.1), but the day it
|
|
70
|
+
* stops, a custom-prefix user is back to "gu: command not found" with nothing to run. A global
|
|
71
|
+
* install lives at <prefix>/lib/node_modules/@tiens.nguyen/gu-cli; anything else (a checkout,
|
|
72
|
+
* `npm link`, a project's node_modules) is not one, and gets no PATH edit.
|
|
73
|
+
*/
|
|
74
|
+
export function globalPrefixOf(packageDir) {
|
|
75
|
+
const parts = resolve(String(packageDir ?? "")).split("/");
|
|
76
|
+
const n = parts.length;
|
|
77
|
+
if (n < 5 || parts[n - 3] !== "node_modules" || parts[n - 4] !== "lib") return "";
|
|
78
|
+
if (!parts[n - 2].startsWith("@")) return "";
|
|
79
|
+
// A project-local install is <project>/node_modules/… — never <x>/lib/node_modules, but a
|
|
80
|
+
// nested one could be; a prefix inside another node_modules tree is not a global prefix.
|
|
81
|
+
const prefix = parts.slice(0, n - 4);
|
|
82
|
+
if (prefix.includes("node_modules")) return "";
|
|
83
|
+
return prefix.join("/") || "/";
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
function main() {
|
|
65
87
|
if (platform() === "win32") return; // registry PATH + PowerShell alias: not here
|
|
66
88
|
if (process.env.CI) return; // no user shell to help in CI
|
package/model-doctor.mjs
CHANGED
|
@@ -130,6 +130,17 @@ export function isExternallyManaged(output) {
|
|
|
130
130
|
return /externally[\s-]?managed/i.test(String(output ?? ""));
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Is there no pip on this python at all? (task #333)
|
|
135
|
+
*
|
|
136
|
+
* A stock Ubuntu 24.04 ships python3 WITHOUT pip (python3-pip is a separate package), so the
|
|
137
|
+
* first install said "/usr/bin/python3: No module named pip" and gave up — although
|
|
138
|
+
* python3-venv was installed and a venv brings its own pip. Same remedy as PEP 668: a venv.
|
|
139
|
+
*/
|
|
140
|
+
export function isPipMissing(output) {
|
|
141
|
+
return /No module named pip\b/i.test(String(output ?? ""));
|
|
142
|
+
}
|
|
143
|
+
|
|
133
144
|
/** Run pip once. Resolves { code, err } — never rejects, so the caller owns the policy. */
|
|
134
145
|
function runPip(python, pkgs, extraArgs = []) {
|
|
135
146
|
return new Promise((resolve) => {
|
|
@@ -190,11 +201,16 @@ async function pipInstall(pkgs, log) {
|
|
|
190
201
|
log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
|
|
191
202
|
let r = await runPip(python3Bin(), pkgs);
|
|
192
203
|
if (r.code === 0) return;
|
|
193
|
-
|
|
204
|
+
const noPip = isPipMissing(r.err);
|
|
205
|
+
if (!noPip && !isExternallyManaged(r.err)) {
|
|
194
206
|
throw new Error(`pip install ${pkgs.join(" ")} exited ${r.code}`);
|
|
195
207
|
}
|
|
196
208
|
|
|
197
|
-
log(
|
|
209
|
+
log(
|
|
210
|
+
noPip
|
|
211
|
+
? " this python has no pip — using a private virtualenv, which brings its own."
|
|
212
|
+
: " this python is managed by your OS (PEP 668) — it will not accept packages directly."
|
|
213
|
+
);
|
|
198
214
|
const venvPy = await ensureVenv(log);
|
|
199
215
|
if (venvPy) {
|
|
200
216
|
r = await runPip(venvPy, pkgs);
|
|
@@ -208,6 +224,13 @@ async function pipInstall(pkgs, log) {
|
|
|
208
224
|
}
|
|
209
225
|
}
|
|
210
226
|
|
|
227
|
+
// Without pip AND without venv nothing here can install anything; --break-system-packages
|
|
228
|
+
// would only fail again with the same "No module named pip". Say what to install instead.
|
|
229
|
+
if (noPip) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
"this python has no pip and no venv module — on Debian/Ubuntu: sudo apt install -y python3-venv, then gu doctor"
|
|
232
|
+
);
|
|
233
|
+
}
|
|
211
234
|
log(" falling back to --break-system-packages (no virtualenv available)");
|
|
212
235
|
r = await runPip(python3Bin(), pkgs, ["--break-system-packages"]);
|
|
213
236
|
if (r.code === 0) return;
|
|
@@ -1246,7 +1269,13 @@ export async function ensureClaudeSdk({ log, confirm }) {
|
|
|
1246
1269
|
return (await pyHasModule("anthropic")) ? "installed" : "failed";
|
|
1247
1270
|
}
|
|
1248
1271
|
|
|
1249
|
-
|
|
1272
|
+
/**
|
|
1273
|
+
* `agreed` names packages the caller has ALREADY asked about (task #333): `gu` asks "Install the
|
|
1274
|
+
* agent framework now?" and then called this, which asked "Install the agent framework
|
|
1275
|
+
* (smolagents + openai) — required to run the agent?" — the same question twice in a row on
|
|
1276
|
+
* every first install. A yes that was just given is not asked for again.
|
|
1277
|
+
*/
|
|
1278
|
+
export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxModels(), agreed = [] }) {
|
|
1250
1279
|
const out = {};
|
|
1251
1280
|
log("");
|
|
1252
1281
|
log(" Checking the agent's dependencies…");
|
|
@@ -1264,7 +1293,7 @@ export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxMode
|
|
|
1264
1293
|
out[key] = "present";
|
|
1265
1294
|
return;
|
|
1266
1295
|
}
|
|
1267
|
-
if (!(await confirm(question, true))) {
|
|
1296
|
+
if (!agreed.includes(key) && !(await confirm(question, true))) {
|
|
1268
1297
|
out[key] = "skipped";
|
|
1269
1298
|
log(` Skipped ${pkgs.join(" ")} — install later: ${python3Bin()} -m pip install -U ${pkgs.join(" ")}`);
|
|
1270
1299
|
return;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANOTHER MODEL STOOD IN — and the screen says so (task #343).
|
|
3
|
+
*
|
|
4
|
+
* Reported: "When the coding model has an issue and it falls back to another model it should show
|
|
5
|
+
* a warning at the bottom bar in yellow." Until now a fallback was a line in the worker's log: the
|
|
6
|
+
* turn "worked", only with a model nobody picked, and worse code or an answer with no tools was
|
|
7
|
+
* the only evidence.
|
|
8
|
+
*
|
|
9
|
+
* The agent reports each fallback as one step — `__MODELFALLBACK__::{json}` (see MODEL_FALLBACK in
|
|
10
|
+
* gonext_agent_chat.py) — and this module turns it into the bar's row and the turn's history line.
|
|
11
|
+
* Pure: no terminal, no colour. The caller paints it yellow.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IS A FALLBACK. Only "a different model did the work, or answered". A retry on the same
|
|
14
|
+
* model is not one, and a turn that failed outright (429 overloaded, 502) is not one either —
|
|
15
|
+
* nothing answered, and the failure notice already says that.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const MODELFALLBACK_PREFIX = "__MODELFALLBACK__::";
|
|
19
|
+
|
|
20
|
+
const text = (v) => String(v ?? "").replace(/\s+/g, " ").trim();
|
|
21
|
+
|
|
22
|
+
/** One step line → the fallback it reports, or null when it is not one (or is malformed). */
|
|
23
|
+
export function parseModelFallback(line) {
|
|
24
|
+
const s = String(line ?? "");
|
|
25
|
+
if (!s.startsWith(MODELFALLBACK_PREFIX)) return null;
|
|
26
|
+
let body;
|
|
27
|
+
try {
|
|
28
|
+
body = JSON.parse(s.slice(MODELFALLBACK_PREFIX.length));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
33
|
+
const from = text(body.from);
|
|
34
|
+
const to = text(body.to);
|
|
35
|
+
// Without the model that was used instead there is nothing true to say.
|
|
36
|
+
if (!from && !to) return null;
|
|
37
|
+
return {
|
|
38
|
+
role: body.role === "chat" ? "chat" : "coder",
|
|
39
|
+
from,
|
|
40
|
+
to,
|
|
41
|
+
reason: text(body.reason),
|
|
42
|
+
// Absent = the stand-in had tools / did answer: only an explicit false changes the wording.
|
|
43
|
+
tools: body.tools !== false,
|
|
44
|
+
answered: body.answered !== false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const clipName = (name, max) => (name.length <= max ? name : name.slice(0, Math.max(1, max - 1)) + "…");
|
|
49
|
+
|
|
50
|
+
/** A model name is never shortened below this — fewer characters no longer identify it. */
|
|
51
|
+
const NAME_FLOOR = 5;
|
|
52
|
+
|
|
53
|
+
/** The sentence's three parts, so the fitting below can shorten each on its own. */
|
|
54
|
+
function parts(fb, { fromMax = Infinity, toMax = Infinity } = {}) {
|
|
55
|
+
const from = clipName(fb.from || (fb.role === "chat" ? "the chat model" : "the coding model"), fromMax);
|
|
56
|
+
const to = clipName(fb.to || "another model", toMax);
|
|
57
|
+
const same = Boolean(fb.from) && fb.from === fb.to;
|
|
58
|
+
const subject = fb.role === "chat" ? `chat model ${from}` : same ? from : `coder ${from}`;
|
|
59
|
+
// "unavailable": it never started (its server named no model). "failed": it was asked and broke.
|
|
60
|
+
const verb = fb.tools && fb.answered ? "unavailable" : "failed";
|
|
61
|
+
let outcome;
|
|
62
|
+
if (!fb.answered) outcome = same ? "and its plain reply failed too" : `${to} failed too`;
|
|
63
|
+
else if (fb.role === "chat") outcome = `${to} answered`;
|
|
64
|
+
else if (fb.tools) outcome = `coding with ${to}`;
|
|
65
|
+
else outcome = same ? "answered without tools" : `${to} answered without tools`;
|
|
66
|
+
return { subject, verb, outcome };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const join = ({ subject, verb, outcome }, reason) =>
|
|
70
|
+
`⚠ ${subject} ${verb}${reason ? ` (${reason})` : ""} — ${outcome}`;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The bar's row for a fallback, FITTED to `width` columns (after the gutter): every bar row must
|
|
74
|
+
* stay one physical row (#109), and a row clipped mid-name hides the one thing it is for. So the
|
|
75
|
+
* REASON is shortened first, then dropped, and only then are the model names shortened — the
|
|
76
|
+
* longer one first.
|
|
77
|
+
*/
|
|
78
|
+
export function fallbackNotice(fb, { width = 80, gutter = " " } = {}) {
|
|
79
|
+
if (!fb) return "";
|
|
80
|
+
const room = Math.max(20, (width || 80) - 1 - gutter.length);
|
|
81
|
+
const full = join(parts(fb), fb.reason);
|
|
82
|
+
if (full.length <= room) return full;
|
|
83
|
+
const bare = join(parts(fb), "");
|
|
84
|
+
if (fb.reason) {
|
|
85
|
+
const left = room - bare.length - 3; // " (" + ")"
|
|
86
|
+
if (left >= 6) return join(parts(fb), clipName(fb.reason, left));
|
|
87
|
+
}
|
|
88
|
+
if (bare.length <= room) return bare;
|
|
89
|
+
let fromMax = Math.max(NAME_FLOOR, (fb.from || "").length);
|
|
90
|
+
let toMax = Math.max(NAME_FLOOR, (fb.to || "").length);
|
|
91
|
+
while (join(parts(fb, { fromMax, toMax }), "").length > room && (fromMax > NAME_FLOOR || toMax > NAME_FLOOR)) {
|
|
92
|
+
if (fromMax >= toMax && fromMax > NAME_FLOOR) fromMax -= 1;
|
|
93
|
+
else toMax -= 1;
|
|
94
|
+
}
|
|
95
|
+
// A window too narrow even for that: cut the row rather than let it wrap onto a second one.
|
|
96
|
+
return clipName(join(parts(fb, { fromMax, toMax }), ""), room);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The line a fallback leaves in the turn's history — whole, wrapped by the caller. */
|
|
100
|
+
export function fallbackHistoryLine(fb) {
|
|
101
|
+
return fb ? join(parts(fb), fb.reason) : "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* THE CODER WAS DROPPED FOR WANT OF A KEY (task #222): the API omits a coding backend whose API
|
|
106
|
+
* key is missing and the chat model does the coding. Nothing failed, so the agent has nothing to
|
|
107
|
+
* report — the terminal knows it from the payload and says it on the same row.
|
|
108
|
+
*/
|
|
109
|
+
export function keyMissingNotice({ coder = "", width = 80, gutter = " " } = {}) {
|
|
110
|
+
const room = Math.max(20, (width || 80) - 1 - gutter.length);
|
|
111
|
+
const tail = " — coding with the chat model · ctrl+k";
|
|
112
|
+
const named = `⚠ no API key for coder ${text(coder)}${tail}`;
|
|
113
|
+
if (text(coder) && named.length <= room) return named;
|
|
114
|
+
const plain = `⚠ no API key for the coding model${tail}`;
|
|
115
|
+
if (plain.length <= room) return plain;
|
|
116
|
+
return clipName("⚠ no coder API key — on the chat model · ctrl+k", room);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What the bar shows after a turn ended. A fallback reported THIS turn stays; a turn that was
|
|
121
|
+
* answered with none reported means the model works again, so the warning goes; a turn that
|
|
122
|
+
* failed or was stopped proves nothing either way and leaves it as it was.
|
|
123
|
+
*/
|
|
124
|
+
export function fallbackAfterTurn({ current = null, reportedThisTurn = null, answered = false } = {}) {
|
|
125
|
+
if (reportedThisTurn) return reportedThisTurn;
|
|
126
|
+
return answered ? null : current;
|
|
127
|
+
}
|
package/node-version.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export const MIN_NODE_MAJOR = 18;
|
|
|
17
17
|
/** The message for a too-old runtime, or "" when this one is fine. Pure, so it is testable. */
|
|
18
18
|
export function nodeVersionProblem(version = process.version) {
|
|
19
19
|
const major = Number.parseInt(String(version).replace(/^v/, "").split(".")[0], 10);
|
|
20
|
-
if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) return "";
|
|
20
|
+
if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) {return "";}
|
|
21
21
|
return (
|
|
22
22
|
`gu needs Node ${MIN_NODE_MAJOR} or newer — this is ${version}.\n` +
|
|
23
23
|
` Node ${major} has no global fetch, which the setup and update paths rely on.\n` +
|
|
@@ -30,10 +30,58 @@ export function nodeVersionProblem(version = process.version) {
|
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* LOCAL MODE NEEDS MORE THAN GU DOES (task #333). The Local API ships the mongodb 7 driver, whose
|
|
35
|
+
* floor is Node 20.19 — and setup loads that driver IN THIS PROCESS to seed the database, then
|
|
36
|
+
* starts the API with this same node. On Node 18 the first sign was "Could not prepare the
|
|
37
|
+
* database: crypto is not defined", halfway through a first install, after MongoDB and the API
|
|
38
|
+
* were already in place. Found on a stock Ubuntu 24.04, whose own `nodejs` package is 18.19.
|
|
39
|
+
*
|
|
40
|
+
* Kept apart from MIN_NODE_MAJOR on purpose: the CLI itself (Cloud mode) still runs on 18.
|
|
41
|
+
*/
|
|
42
|
+
export const LOCAL_API_MIN_NODE = "20.19.0";
|
|
43
|
+
|
|
44
|
+
/** True when `version` is at least `min` (both "x.y.z", a leading v allowed). */
|
|
45
|
+
export function nodeAtLeast(version, min) {
|
|
46
|
+
const parts = (v) => String(v).replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
47
|
+
const a = parts(version);
|
|
48
|
+
const b = parts(min);
|
|
49
|
+
for (let i = 0; i < 3; i++) {
|
|
50
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0)) {return (a[i] ?? 0) > (b[i] ?? 0);}
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Why Local mode cannot run on this node, with what to do — or "" when it can. Pure. */
|
|
56
|
+
export function localModeNodeProblem(version = process.version, platform = process.platform) {
|
|
57
|
+
if (nodeAtLeast(version, LOCAL_API_MIN_NODE)) {return "";}
|
|
58
|
+
const how =
|
|
59
|
+
platform === "win32"
|
|
60
|
+
? [" winget install OpenJS.NodeJS.LTS (or: nvm install lts with nvm-windows)"]
|
|
61
|
+
: platform === "darwin"
|
|
62
|
+
? [" brew install node (or: nvm install --lts)"]
|
|
63
|
+
: [
|
|
64
|
+
" nvm install --lts && nvm alias default lts/*",
|
|
65
|
+
" or NodeSource:",
|
|
66
|
+
" curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -",
|
|
67
|
+
" sudo apt install -y nodejs",
|
|
68
|
+
];
|
|
69
|
+
return (
|
|
70
|
+
`Local mode needs Node ${LOCAL_API_MIN_NODE} or newer — this is ${version}.\n` +
|
|
71
|
+
" Its API uses the MongoDB 7 driver, which does not run on older Node.\n" +
|
|
72
|
+
(platform === "linux" ? " (Ubuntu 24.04's own nodejs package is 18.)\n" : "") +
|
|
73
|
+
"\n" +
|
|
74
|
+
how.join("\n") +
|
|
75
|
+
"\n\n" +
|
|
76
|
+
" Then reinstall gu with that Node: npm i -g @tiens.nguyen/gu-cli\n" +
|
|
77
|
+
" Nothing was installed or changed."
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
33
81
|
/** Print the problem and exit, or return quietly. */
|
|
34
82
|
export function requireSupportedNode() {
|
|
35
83
|
const problem = nodeVersionProblem();
|
|
36
|
-
if (!problem) return;
|
|
84
|
+
if (!problem) {return;}
|
|
37
85
|
// console.error, not the REPL's own writer: this runs before any of that exists.
|
|
38
86
|
console.error("\n" + problem + "\n");
|
|
39
87
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.794",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
"proxy-dispatcher.mjs",
|
|
106
106
|
"model-doctor.mjs",
|
|
107
107
|
"coding-key-prompt.mjs",
|
|
108
|
+
"model-fallback.mjs",
|
|
108
109
|
"server-keys.mjs",
|
|
109
110
|
"add-server.mjs",
|
|
110
111
|
"powershell-alias.mjs",
|
package/platform-tools.mjs
CHANGED
|
@@ -298,23 +298,68 @@ export function windowsLauncherScript({ cmd, args = [], cwd, env = {}, logFile }
|
|
|
298
298
|
return lines.join("\r\n") + "\r\n";
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* WHAT THE TASK RUNS — the launcher script, with NO WINDOW (task #334).
|
|
303
|
+
*
|
|
304
|
+
* The task used to run the .cmd itself. A task runs in the user's desktop session, so cmd.exe got
|
|
305
|
+
* a console window there, and `start /b` keeps node attached to that console: the window stayed
|
|
306
|
+
* for as long as the API or the worker lived. One per launch — the API, the worker, a worker
|
|
307
|
+
* restart, an API restart — "3-4 command windows" on a Windows 11 desktop after a first install,
|
|
308
|
+
* each of which kills its process when closed. Measured on the box: two visible windows titled
|
|
309
|
+
* C:\WINDOWS\SYSTEM32\cmd.exe, owned by the two node processes.
|
|
310
|
+
*
|
|
311
|
+
* `conhost.exe --headless` is Windows' own way to give a console program a console with no
|
|
312
|
+
* window (Windows 10 1809 and later). Not wscript/VBScript — it is being removed from Windows —
|
|
313
|
+
* and not `powershell -WindowStyle Hidden`, which flashes a window before hiding it.
|
|
314
|
+
*
|
|
315
|
+
* THE QUOTES ARE THE OTHER FIX. The bare script path was split by schtasks at the first space,
|
|
316
|
+
* so from a home like C:\Users\Jo Smith nothing started at all (measured: no log, no process).
|
|
317
|
+
*/
|
|
318
|
+
export function hiddenTaskAction(scriptPath, systemRoot) {
|
|
319
|
+
const root = String(systemRoot || "C:\\Windows").replace(/[\\/]+$/, "");
|
|
320
|
+
return `"${root}\\System32\\conhost.exe" --headless cmd.exe /c "${scriptPath}"`;
|
|
321
|
+
}
|
|
322
|
+
|
|
301
323
|
/**
|
|
302
324
|
* Register → run → deregister. The task is a LAUNCH MECHANISM, not a persistent registration:
|
|
303
325
|
* it exists only to start the process outside our job, and is removed immediately afterwards
|
|
304
326
|
* so nothing is left scheduled on the user's machine. The started process keeps running —
|
|
305
327
|
* deleting a task does not kill what it launched (verified).
|
|
306
328
|
*/
|
|
307
|
-
export function schtasksCommands(taskName, scriptPath) {
|
|
329
|
+
export function schtasksCommands(taskName, scriptPath, { elevated = false, systemRoot = process.env.SystemRoot } = {}) {
|
|
308
330
|
return {
|
|
309
331
|
create: {
|
|
310
332
|
cmd: "schtasks.exe",
|
|
311
|
-
|
|
333
|
+
// `/rl highest` ONLY from an elevated terminal — see isElevatedGroups. From an ordinary one
|
|
334
|
+
// it is refused for an administrator ("Access is denied") and the task is never created.
|
|
335
|
+
args: [
|
|
336
|
+
"/create", "/tn", taskName, "/tr", hiddenTaskAction(scriptPath, systemRoot), "/sc", "once", "/st", "00:00",
|
|
337
|
+
...(elevated ? ["/rl", "highest"] : []),
|
|
338
|
+
"/f",
|
|
339
|
+
],
|
|
312
340
|
},
|
|
313
341
|
run: { cmd: "schtasks.exe", args: ["/run", "/tn", taskName] },
|
|
314
342
|
remove: { cmd: "schtasks.exe", args: ["/delete", "/tn", taskName, "/f"] },
|
|
315
343
|
};
|
|
316
344
|
}
|
|
317
345
|
|
|
346
|
+
/**
|
|
347
|
+
* IS THIS TERMINAL ELEVATED? — from `whoami /groups` (task #333).
|
|
348
|
+
*
|
|
349
|
+
* A scheduled task runs with the user's ORDINARY token unless it is created `/rl highest`, even
|
|
350
|
+
* when the terminal that created it is elevated — and an SSH session of an administrator always
|
|
351
|
+
* is. So the background process had FEWER rights than the `gu` that started it. On the Windows
|
|
352
|
+
* 11 test box node.exe belongs to another user's nvm and only Administrators may run it: `gu`
|
|
353
|
+
* ran, the API it launched got "Access is denied.", and setup stopped at "not up yet".
|
|
354
|
+
* A background process should have the rights of the terminal that started it — as it does on
|
|
355
|
+
* macOS and Linux, where it is simply a child.
|
|
356
|
+
*
|
|
357
|
+
* S-1-16-12288 is the High Mandatory Level label (S-1-16-16384 = System). Pure, so it is testable.
|
|
358
|
+
*/
|
|
359
|
+
export function isElevatedGroups(whoamiGroups) {
|
|
360
|
+
return /S-1-16-(12288|16384)\b/.test(String(whoamiGroups ?? ""));
|
|
361
|
+
}
|
|
362
|
+
|
|
318
363
|
/**
|
|
319
364
|
* Which pid owns a listening port, from `netstat -ano`.
|
|
320
365
|
*
|
|
@@ -382,7 +427,8 @@ export async function spawnSurviving({ cmd, args = [], cwd, env = {}, logFile, t
|
|
|
382
427
|
"utf8"
|
|
383
428
|
);
|
|
384
429
|
|
|
385
|
-
const
|
|
430
|
+
const elevated = isElevatedGroups(await run("whoami.exe", ["/groups"]));
|
|
431
|
+
const t = schtasksCommands(taskName, script, { elevated });
|
|
386
432
|
const created = await run(t.create.cmd, t.create.args);
|
|
387
433
|
if (created === null) {
|
|
388
434
|
// No scheduler (or no rights): fall back to a plain detached spawn. It dies with an SSH
|
package/terminal-layout.mjs
CHANGED
|
@@ -528,6 +528,21 @@ export function buildFooterRows({
|
|
|
528
528
|
];
|
|
529
529
|
}
|
|
530
530
|
|
|
531
|
+
/**
|
|
532
|
+
* The bytes that print a NEW thought block into the transcript (task #344): its painted rows,
|
|
533
|
+
* with ONE empty line above when the row above is not already blank (`gap`).
|
|
534
|
+
*
|
|
535
|
+
* A thought used to go directly under whatever was last printed. Under "✓ Command finished → …"
|
|
536
|
+
* it then read as that command's output, and under a bright "✗ Command failed → …" as its error
|
|
537
|
+
* text — but a thought is printed when its step's reasoning ends, before that step's tools
|
|
538
|
+
* report: it introduces what follows. Never two blanks: the caller passes gap=false when the
|
|
539
|
+
* last row is already empty. No blank BELOW either — the next bullet opens its own.
|
|
540
|
+
*/
|
|
541
|
+
export function thoughtPrintSeq(paintedRows, { gap = false } = {}) {
|
|
542
|
+
if (!paintedRows.length) return "";
|
|
543
|
+
return (gap ? "\n" : "") + paintedRows.join("\n") + "\n";
|
|
544
|
+
}
|
|
545
|
+
|
|
531
546
|
/**
|
|
532
547
|
* Bytes that draw the bar below the input line and put the caret back exactly where readline
|
|
533
548
|
* left it. Relative moves only: the block must survive the terminal scrolling — writing past
|
|
@@ -2156,6 +2171,24 @@ export const HIDDEN_COMMANDS = [
|
|
|
2156
2171
|
// the INPUT total — which does not exist yet as a counter
|
|
2157
2172
|
];
|
|
2158
2173
|
|
|
2174
|
+
/**
|
|
2175
|
+
* NOTHING TO ANSWER WITH (#99): an agent payload with no model server in it at all.
|
|
2176
|
+
*
|
|
2177
|
+
* A missing AGENT model alone is fine — the API puts the coding model in its place (and sets
|
|
2178
|
+
* `agentMissing`). With no coder either, `agentBaseURL` is empty: there is no server to send a
|
|
2179
|
+
* question to. The API (from 1.0.220) refuses such a question in words; this lets the terminal
|
|
2180
|
+
* say the same against an API that still queues a job nothing can read ("✗ Job not found.").
|
|
2181
|
+
*/
|
|
2182
|
+
export function nothingToAnswerWith(payload) {
|
|
2183
|
+
if (!payload || typeof payload !== "object") return false; // unknown is not "none"
|
|
2184
|
+
return !String(payload.agentBaseURL ?? "").trim();
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
export const NO_MODEL_LINES = [
|
|
2188
|
+
"No model is set for this account, so there is nothing to answer with.",
|
|
2189
|
+
"Pick a coding model with /model (and an agent model with /agent-model), then ask again.",
|
|
2190
|
+
];
|
|
2191
|
+
|
|
2159
2192
|
/**
|
|
2160
2193
|
* Does this API base point at a server on THIS machine? (task #154)
|
|
2161
2194
|
*
|
|
@@ -2177,9 +2210,20 @@ export function isLocalApiBase(apiBase) {
|
|
|
2177
2210
|
* entire distinction — go unmentioned. Reported live on a fully set-up Ubuntu box that said
|
|
2178
2211
|
* plain "client".
|
|
2179
2212
|
*/
|
|
2180
|
-
export function modeRow({ clientExpert, clientMode }) {
|
|
2213
|
+
export function modeRow({ clientExpert, clientMode, modelUrls = [], coderWhere }) {
|
|
2181
2214
|
if (clientExpert) {
|
|
2182
|
-
|
|
2215
|
+
// "models remote" WAS A CONSTANT (task #333): a box running its own Ollama on 127.0.0.1 —
|
|
2216
|
+
// the whole stack on one machine — was told its models were somewhere else. Said only when
|
|
2217
|
+
// EVERY configured model server is this machine; one cloud coder is still "remote".
|
|
2218
|
+
const urls = modelUrls.map((u) => String(u ?? "").trim()).filter(Boolean);
|
|
2219
|
+
// `coderWhere` is the active coder's "host[:port]" label (coding-choice-tags.mjs), which
|
|
2220
|
+
// names this machine as 127.0.0.1. Passed but empty = not known → not claimed to be here.
|
|
2221
|
+
const coderHere = coderWhere === undefined || /^127\.0\.0\.1(:|$)/.test(String(coderWhere));
|
|
2222
|
+
const allHere = urls.length > 0 && urls.every(isLocalApiBase) && coderHere;
|
|
2223
|
+
return {
|
|
2224
|
+
label: "client expert",
|
|
2225
|
+
detail: allHere ? "(API, database and models here)" : "(API + database here · models remote)",
|
|
2226
|
+
};
|
|
2183
2227
|
}
|
|
2184
2228
|
if (clientMode) {
|
|
2185
2229
|
return { label: "client", detail: "(agent runs here · models remote)" };
|