@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/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}) — see \`gu-cli doctor\` if \`gu\` is not found.`);
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
- if (!isExternallyManaged(r.err)) {
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(" this python is managed by your OS (PEP 668) — it will not accept packages directly.");
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
- export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxModels() }) {
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.792",
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",
@@ -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
- args: ["/create", "/tn", taskName, "/tr", scriptPath, "/sc", "once", "/st", "00:00", "/f"],
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 t = schtasksCommands(taskName, script);
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
@@ -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
- return { label: "client expert", detail: "(API + database here · models remote)" };
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)" };