@tiens.nguyen/gu-cli 1.0.870 → 1.0.871

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.
@@ -355,73 +355,6 @@ export function agentRecoveryOptions({ keyProblem = false, host = "" } = {}) {
355
355
  ];
356
356
  }
357
357
 
358
- // ---------------------------------------------------------------------------------------------
359
- // #43 — /model's "change the Ollama server" flow, when the agent is NOT on an Ollama.
360
- // ---------------------------------------------------------------------------------------------
361
-
362
- /**
363
- * Is this machine's agent model on an OpenAI-compatible PROVIDER rather than an Ollama?
364
- *
365
- * Reported (#43): client mode, /model → change the Ollama server offered the agent's URL —
366
- * https://api.openai.com — as the "agent + embedding server", probed it for Ollama's /api/tags, got
367
- * 404, said "check that Ollama is listening", and asked again, with no way forward but typing "no".
368
- * A provider is known two ways: a provider row for that URL says so, or the URL is one of the hosted
369
- * APIs everyone knows is not an Ollama.
370
- */
371
- const KNOWN_PROVIDER_HOSTS = [
372
- "api.openai.com",
373
- "openrouter.ai",
374
- "api.together.xyz",
375
- "api.moonshot.ai",
376
- "api.groq.com",
377
- "api.deepseek.com",
378
- "api.mistral.ai",
379
- "generativelanguage.googleapis.com",
380
- ];
381
-
382
- export function agentIsOnProvider(settings) {
383
- const source = agentListSource(settings);
384
- if (!source.url) return false;
385
- if (source.dialect === "openai") return true;
386
- let host = "";
387
- try {
388
- host = new URL(String(settings?.agentModelUrl ?? "")).hostname.toLowerCase();
389
- } catch {
390
- return false;
391
- }
392
- return KNOWN_PROVIDER_HOSTS.some((h) => host === h || host.endsWith(`.${h}`));
393
- }
394
-
395
- /**
396
- * The address to offer for the CODING server: the coder's own URL when it is not a known provider,
397
- * else this machine's Ollama. Never the agent's provider URL — that is what put api.openai.com in an
398
- * Ollama prompt.
399
- */
400
- export function coderServerDefault(settings) {
401
- const coder = String(settings?.agentCodingModelUrl ?? "").trim();
402
- if (coder && !agentIsOnProvider({ ...settings, agentModelUrl: coder })) return coder;
403
- const agent = String(settings?.agentModelUrl ?? "").trim();
404
- if (agent && !agentIsOnProvider(settings)) return agent;
405
- return "http://127.0.0.1:11434";
406
- }
407
-
408
- /**
409
- * A server that ANSWERED but has no /api/tags is not an Ollama — an OpenAI-compatible API answers 404
410
- * there. That is a different fix from "not listening", and saying the wrong one sends people to check
411
- * a server that is working fine.
412
- */
413
- export function describeNotOllama(host, role) {
414
- return role === "agent"
415
- ? [
416
- `${host} answers, but it is not an Ollama server (no /api/tags).`,
417
- "an OpenAI-compatible agent is changed with /agent-model — or type 'no' to skip this role.",
418
- ]
419
- : [
420
- `${host} answers, but it is not an Ollama server (no /api/tags).`,
421
- "for an OpenAI-compatible coder use /model → add or change an OpenAI-compatible provider — or type 'no' to skip.",
422
- ];
423
- }
424
-
425
358
  // ---------------------------------------------------------------------------------------------
426
359
  // #44 — WHAT TO CHANGE comes first.
427
360
  // ---------------------------------------------------------------------------------------------
@@ -1138,39 +1138,3 @@ export async function saveSetupKeys({ apiBase = LOCAL_API_BASE, workerKey, model
1138
1138
  return out;
1139
1139
  }
1140
1140
 
1141
- /**
1142
- * THE NEW OLLAMA SERVER'S ROWS, MERGED INTO WHAT IS ALREADY THERE — never replacing it (#43).
1143
- *
1144
- * Found while fixing #43: choosing a coding server in /model → "change the Ollama server" saved
1145
- * codingBackendsForClientExpert(...) AS the whole list — one Ollama row and the Kimi row — deleting
1146
- * every provider the user had added: an api.openai.com row the AGENT's key is found through, an
1147
- * ollama.com cloud row. Their keys, bound to those row ids, were orphaned.
1148
- *
1149
- * Replaced: the local/LAN Ollama row(s) — that is what "the Ollama server this machine uses" means —
1150
- * with the new one, which keeps the first replaced row's id so anything bound to it still resolves.
1151
- * Kept, ids and all: every OpenAI-compatible row, and any Ollama CLOUD row (a paid provider, not
1152
- * "this machine's server"). The template's default provider row is added only when no row already
1153
- * has its URL.
1154
- */
1155
- export function mergeOllamaServerBackends(existing, fresh, { isCloud } = {}) {
1156
- const cloud = typeof isCloud === "function" ? isCloud : () => false;
1157
- const norm = (u) => String(u ?? "").trim().replace(/\/+$/, "").toLowerCase();
1158
- const rows = Array.isArray(existing) ? existing.filter(Boolean) : [];
1159
- const freshOllama = (Array.isArray(fresh) ? fresh : []).filter((f) => f.kind === "ollama");
1160
- const newUrls = new Set(freshOllama.map((f) => norm(f.url)));
1161
- // A row already at the NEW server's URL is the same provider — the new server takes it over, id and
1162
- // all, so a key bound to it (the Ollama cloud's own key) keeps working and no twin row appears.
1163
- const sameServer = rows.filter((b) => newUrls.has(norm(b.url)));
1164
- const replaced = [...sameServer, ...rows.filter((b) => b.kind === "ollama" && !cloud(b.url) && !sameServer.includes(b))];
1165
- const kept = rows.filter((b) => !replaced.includes(b));
1166
- const out = [];
1167
- for (const f of freshOllama) {
1168
- const id = (sameServer.find((b) => b.id && norm(b.url) === norm(f.url)) ?? replaced.find((b) => b.id))?.id;
1169
- out.push(id ? { ...f, id } : f);
1170
- }
1171
- out.push(...kept);
1172
- for (const f of Array.isArray(fresh) ? fresh : []) {
1173
- if (f.kind !== "ollama" && !out.some((b) => norm(b.url) === norm(f.url))) out.push(f);
1174
- }
1175
- return out;
1176
- }
package/doctor-check.mjs CHANGED
@@ -220,6 +220,13 @@ export async function runDoctor(settings, opts = {}) {
220
220
  return { results, ok: results.every((r) => r.status === "ok") };
221
221
  }
222
222
 
223
+ /** Where an unset role is set from the terminal. */
224
+ const SET_WITH = {
225
+ chat: "/agent-model",
226
+ code: "/model → add a provider",
227
+ embed: "/embed-model",
228
+ };
229
+
223
230
  /** One line per role, for the terminal. Pure, so the wording is testable. */
224
231
  export function doctorLines(results) {
225
232
  const mark = { ok: "✓", unset: "·", timeout: "✗", unreachable: "✗", error: "✗" };
@@ -233,7 +240,8 @@ export function doctorLines(results) {
233
240
  out.push(" could not be scheduled. This is not a network problem.");
234
241
  if (r.busy?.hint) out.push(` ${r.busy.hint}`);
235
242
  }
236
- if (r.status === "unset") out.push(" set it with /model → change the Ollama server");
243
+ // Each role has its own command since #412 removed /model's all-in-one Ollama flow.
244
+ if (r.status === "unset") out.push(` set it with ${SET_WITH[r.role] ?? "/model"}`);
237
245
  }
238
246
  return out;
239
247
  }
package/gu-cli.mjs CHANGED
@@ -1799,6 +1799,25 @@ if (args[0] === "mongo") {
1799
1799
  process.exit(0);
1800
1800
  }
1801
1801
 
1802
+ /** The gu-service check (#413), as lines — shared by `gu doctor` and the terminal's /doctor. */
1803
+ async function guServiceLines() {
1804
+ const sc = await import("./service-check.mjs");
1805
+ const { hostedApiBase } = await import("./client-expert-api.mjs");
1806
+ const { isLocalApiBase } = await import("./terminal-layout.mjs");
1807
+ const { loadProxy } = await import("./proxy-store.mjs");
1808
+ const { dispatcherFor } = await import("./proxy-dispatcher.mjs");
1809
+ const apiBase = String(process.env.GONEXT_API_BASE ?? "").trim();
1810
+ const { lines } = await sc.checkGuService({
1811
+ hostedBase: hostedApiBase(),
1812
+ apiBase,
1813
+ workerKey: String(process.env.GONEXT_WORKER_KEY ?? "").trim(),
1814
+ localApi: isLocalApiBase(apiBase),
1815
+ proxy: await loadProxy().catch(() => null),
1816
+ dispatcherFor,
1817
+ });
1818
+ return lines;
1819
+ }
1820
+
1802
1821
  if (args[0] === "doctor") {
1803
1822
  // Task #304: a Mac whose MongoDB runs in Docker is told the native option, once, up front —
1804
1823
  // a working Docker MongoDB is left alone (it is a choice), a silent one is named as stuck.
@@ -2001,6 +2020,15 @@ if (args[0] === "doctor") {
2001
2020
  console.log(` (local stack check skipped: ${e.message})`);
2002
2021
  }
2003
2022
  }
2023
+ // CAN A CODING TURN START? (#413) Last, after the local API: its instructions come from the
2024
+ // gu service, through that API. Reported on a Windows VM where doctor passed everything, "hi"
2025
+ // worked, and every coding turn failed — this was the one thing doctor never looked at.
2026
+ try {
2027
+ console.log("\n Checking the gu service (a coding turn needs it)…");
2028
+ for (const l of await guServiceLines()) console.log(` ${l}`);
2029
+ } catch (e) {
2030
+ console.log(` (gu service check skipped: ${e?.message ?? e})`);
2031
+ }
2004
2032
  } finally {
2005
2033
  rl0.close();
2006
2034
  }
package/gu-repl.mjs CHANGED
@@ -157,6 +157,7 @@ import { globalPrefixOf } from "./install-path.mjs";
157
157
  import { indentBlock, commandBlockRows } from "./terminal-layout.mjs"; // every line of an Allow-prompt detail indented (#189); wrapped + collapsible command (#204)
158
158
  import { firstGuOnPath, pathGuMismatch, guOnPathIsCurrent, updateAttemptRecord, repeatUpdateNotice } from "./update-verify.mjs";
159
159
  import { embedNotice, embedState, setupNoticeRows } from "./embed-model-command.mjs";
160
+ import { HINTS, pickHint, hintRow, hintFits, HINT_AFTER_SECONDS } from "./hints.mjs";
160
161
  import { normalizeQuick, quickReady, quickTarget, quickBarHint, quickWarning, QUICK_MAX } from "./quick-models.mjs";
161
162
  import {
162
163
  MODELFALLBACK_PREFIX, fallbackAfterTurn, fallbackHistoryLine, fallbackNotice, keyMissingNotice,
@@ -518,6 +519,7 @@ const THINKING_WORDS = (() => {
518
519
  * file is missing is playful too, and the last-resort default is a real word rather than the
519
520
  * one word the list is designed to avoid.
520
521
  */
522
+ let lastHintIdx = -1; // the tip the previous turn showed (#234)
521
523
  const pickWord = () =>
522
524
  THINKING_WORDS[Math.floor(Math.random() * THINKING_WORDS.length)] || "Shaving the yak";
523
525
 
@@ -6126,9 +6128,25 @@ async function runModelDoctor() {
6126
6128
  "\n" + GUTTER +
6127
6129
  (bad.length === 0
6128
6130
  ? accent("✓ every configured model answered.")
6129
- : red(`✗ ${bad.length} of ${results.length} did not answer.`)) +
6130
- "\n"
6131
+ : red(`✗ ${bad.length} of ${results.length} did not answer.`))
6132
+ );
6133
+ // THE GU SERVICE TOO (#413): every model can answer and a coding turn still fail, because its
6134
+ // instructions come from the gu service — reported on a Windows VM where only "hi" worked.
6135
+ const sc = await import("./service-check.mjs");
6136
+ const verdict = await withPendingStatus("the gu service", async () =>
6137
+ sc.checkGuService({
6138
+ hostedBase: (await import("./client-expert-api.mjs")).hostedApiBase(),
6139
+ apiBase,
6140
+ workerKey,
6141
+ localApi: isLocalApiBase(apiBase),
6142
+ proxy: await (await import("./proxy-store.mjs")).loadProxy().catch(() => null),
6143
+ dispatcherFor: (await import("./proxy-dispatcher.mjs")).dispatcherFor,
6144
+ })
6131
6145
  );
6146
+ for (const [i, l] of verdict.lines.entries()) {
6147
+ console.log(GUTTER + (i === 0 ? (verdict.ok ? accent(l) : red(l)) : dim(l)));
6148
+ }
6149
+ console.log("");
6132
6150
  }
6133
6151
 
6134
6152
  /** THROUGH THE PROXY (#160), like every other model call — a bare fetch is what fails on a
@@ -6138,410 +6156,6 @@ async function doctorFetch(url, init) {
6138
6156
  return proxyFetch(url, init);
6139
6157
  }
6140
6158
 
6141
- async function reconfigureOllamaServer() {
6142
- const os = await import("./ollama-setup.mjs");
6143
- const am = await import("./agent-model-command.mjs");
6144
- const md = await import("./model-doctor.mjs");
6145
- const { proxyFetch } = await import("./proxy-dispatcher.mjs");
6146
-
6147
- const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6148
- if (!settings) {
6149
- console.log(red(" couldn't reach the API to read your current settings.\n"));
6150
- return;
6151
- }
6152
-
6153
- /** Load a box's models THROUGH THE PROXY (#160) — a bare fetch is what fails on a restricted
6154
- * network, and it fails as "no models", which is the wrong story entirely. */
6155
- const load = async (n, key = "") => {
6156
- const res = await withPendingStatus(`loading models from ${n.host}`, () =>
6157
- proxyFetch(os.tagsUrlFor(n), {
6158
- signal: AbortSignal.timeout(8000),
6159
- // #301: the key typed for this server; absent → the request is what it always was.
6160
- ...(key ? { headers: { Authorization: `Bearer ${key}` } } : {}),
6161
- })
6162
- );
6163
- // The status is kept: a 404 here means "answered, but not an Ollama" (#43), not "unreachable".
6164
- if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { httpStatus: res.status });
6165
- return am.parseOllamaTags(await res.json().catch(() => null));
6166
- };
6167
-
6168
- // #301: the way out, said under every server question (full once, short after), and the keys
6169
- // typed for servers, by host — saved against their rows once the settings are written.
6170
- const ui = await import("./model-setup-ui.mjs");
6171
- const hint = ui.createHinter();
6172
- const typedKeys = new Map();
6173
- const askKeyFor = async (host) => {
6174
- if (typedKeys.has(host)) return typedKeys.get(host); // same server for both roles → asked once
6175
- console.log(dim(` ${hint()}`));
6176
- // askSecret: masked, kept out of history; Esc throws PromptCancelled → /model is cancelled
6177
- // with nothing changed, which is what "skip all" means here.
6178
- const k = (await askSecret(` API key for ${host} (Enter for none): `)).trim();
6179
- typedKeys.set(host, k);
6180
- console.log(GUTTER + dim(ui.keyConfirmation(host, k)));
6181
- return k;
6182
- };
6183
-
6184
- /** Ask for one server and load it. Returns {normalized, models} or null (declined/failed). */
6185
- const askServer = async (label, dflt, { require = false, short = "server", role = "coder" } = {}) => {
6186
- for (let attempt = 0; attempt < 3; attempt++) {
6187
- // The address goes ABOVE, in full — it is long, it is the thing Enter accepts, and the
6188
- // frozen row is too narrow to hold it without truncating exactly that. The row itself
6189
- // carries a short label so it is obvious WHAT is being asked for.
6190
- // Shown WITHOUT /v1 (task #220 follow-up): the suffix in a default reads as an
6191
- // instruction, leaving the reader unsure whether their own address needs it. It does not
6192
- // — normalizeOllamaUrl adds it — and displayUrl round-trips, so what is shown can be fed
6193
- // straight back as the default.
6194
- console.log(dim(` ${label} — Enter keeps ${os.displayUrl(dflt)}`));
6195
- console.log(dim(` ${hint()}`));
6196
- // THE ROW MUST SAY WHICH SERVER IT IS ASKING FOR. It said a bare "server:" — chosen to
6197
- // fit the frozen row, which traded away the one thing the label was for. Two questions
6198
- // in a row, both reading "server:", and the reader cannot tell which one they are
6199
- // answering (reported: "it should say 'Agent + Embedder server' then 'Coding Server',
6200
- // it only says 'server:'"). `short` is the role in the fewest words that still name it.
6201
- const typed = (await ask(` ${short}: `)).trim();
6202
- // "s" is the advertised skip (#301); the older words still work.
6203
- if (ui.readStepAnswer(typed).kind === "skip") return null;
6204
- // EVERY READING OF A BARE HOSTNAME, in order. Typing "ollama2.getmarsic.cc" means
6205
- // https://…:443 behind a proxy, while "192.168.1.40" means http://…:11434 — one guess
6206
- // is right for one of them and produces a timeout that looks like a dead server for the
6207
- // other. See urlCandidates; the load decides, not the heuristic.
6208
- const candidates = os.urlCandidates(typed || dflt);
6209
- if (!candidates.length) {
6210
- console.log(dim(" that is not a server address — try 192.168.1.40 or a full URL"));
6211
- continue;
6212
- }
6213
- let n = null;
6214
- let models = null;
6215
- let lastErr = null;
6216
- // #301: a TYPED public address may need a key — asked before listing, so a server that
6217
- // needs one can list its models. A LAN box is never asked.
6218
- let key = "";
6219
- if (ui.shouldAskKey({ typed, host: candidates[0].host })) key = await askKeyFor(candidates[0].host);
6220
- const tryAll = async () => {
6221
- for (const cand of candidates) {
6222
- try {
6223
- models = await load(cand, key);
6224
- n = cand;
6225
- if (candidates.length > 1) console.log(dim(` using ${os.displayUrl(cand.url)}`));
6226
- return;
6227
- } catch (err) {
6228
- lastErr = err;
6229
- }
6230
- }
6231
- };
6232
- await tryAll();
6233
- // Refused for want of credentials: ask now, whatever the address looked like, and list again.
6234
- if (!n && ui.needsKeyAfterStatus(lastErr?.httpStatus) && !key) {
6235
- console.log(yellow(` ${candidates[0].host} needs an API key to list its models.`));
6236
- typedKeys.delete(candidates[0].host);
6237
- key = await askKeyFor(candidates[0].host);
6238
- if (key) await tryAll();
6239
- }
6240
- if (!n && ui.needsKeyAfterStatus(lastErr?.httpStatus)) {
6241
- console.log(red(` ${candidates[0].host} refused the request (HTTP ${lastErr.httpStatus})${key ? " — that key was not accepted" : ""}.`));
6242
- continue;
6243
- }
6244
- if (!n) {
6245
- if (lastErr?.httpStatus === 404) {
6246
- // IT ANSWERED — it is simply not an Ollama (#43). "Couldn't reach … is Ollama listening"
6247
- // sent people to debug a server that was working.
6248
- for (const l of am.describeNotOllama(candidates[0].host, role)) console.log(yellow(` ${l}`));
6249
- continue;
6250
- }
6251
- // One address typed, one failure reported — naming every candidate would be two errors
6252
- // for one mistake, and neither would be the address they gave.
6253
- console.log(red(` couldn't reach ${candidates[0].host}: ${os.networkReason(lastErr)}`));
6254
- console.log(dim(" check the address, that Ollama is listening on the network, and /proxy."));
6255
- continue;
6256
- }
6257
- const usable = os.usableModelList(models);
6258
- if (!usable.ok) {
6259
- for (const l of os.describeUnusableList(usable.why, n.host)) console.log(dim(` ${l}`));
6260
- continue;
6261
- }
6262
- if (require) {
6263
- // THE AGENT AND THE EMBEDDER ARE NOT A FREE CHOICE. A box without them is "not ready",
6264
- // not "wrong" — it can still be a perfectly good coder host, so this refuses the ROLE
6265
- // rather than the server.
6266
- const need = os.checkRequired(models);
6267
- if (!need.ok) {
6268
- for (const l of os.describeNotReady(n.host, need.missing)) console.log(yellow(` ${l}`));
6269
- console.log(dim(" (or type s to skip — you can set it later with /model)"));
6270
- continue;
6271
- }
6272
- console.log(dim(` ✓ ${n.host} has the ${os.REQUIRED_AGENT_PREFIX} family and the embedder`));
6273
- }
6274
- console.log(dim(` found ${models.length} model${models.length === 1 ? "" : "s"} on ${n.host}`));
6275
- return { normalized: n, models };
6276
- }
6277
- return null;
6278
- };
6279
-
6280
- // AN AGENT ON A PROVIDER IS NOT ASKED ABOUT HERE (#43). This flow is for Ollama boxes; offering
6281
- // https://api.openai.com as the "agent + embedding server" could only fail its /api/tags probe and
6282
- // ask again. Keep it, say where it is changed, and go on to the coder.
6283
- const agentOnProvider = am.agentIsOnProvider(settings);
6284
- const curAgent =
6285
- String(settings.agentModelUrl ?? "").trim() || "https://ollama1.getmarsic.cc/v1";
6286
- const curCode = am.coderServerDefault(settings);
6287
-
6288
- let agent = null;
6289
- if (agentOnProvider) {
6290
- const host = hostOf(String(settings.agentModelUrl ?? ""));
6291
- console.log(dim(` The agent model is on ${host} (OpenAI-compatible) — kept as it is; change it with /agent-model.`));
6292
- console.log(dim(" Enter keeps the current address."));
6293
- } else {
6294
- console.log(dim(" TWO servers may be used: the agent + embedder on one, the coder on another."));
6295
- console.log(dim(" Enter keeps the current address."));
6296
- agent = await askServer("Agent + embedding server", curAgent, {
6297
- require: true,
6298
- short: "agent + embed server",
6299
- role: "agent",
6300
- });
6301
- }
6302
- const coder = await askServer("Coding-model server", agent?.normalized?.url ?? curCode, {
6303
- short: "coding server",
6304
- role: "coder",
6305
- });
6306
-
6307
- // Task #14: skipping is a real answer, not a failure — but the bottom bar must keep saying
6308
- // what it cost. Declining the AGENT + embed server leaves both roles unset → both warnings.
6309
- if (!agent && !agentOnProvider) {
6310
- agentMissing = true; // no agent model chosen → the agent falls back to the coding model
6311
- embedMissing = true; // the embedder lives on the agent server → RAG is off until /embed-model
6312
- }
6313
-
6314
- if (!agent && !coder) {
6315
- console.log(dim(" nothing set — models stay as they are.\n"));
6316
- return;
6317
- }
6318
-
6319
- // THE AGENT IS CHOSEN TOO (user, 2026-09-06). checkRequired now gates on the gemma4 FAMILY
6320
- // rather than one exact tag, so "the box is ready" no longer names a single model — which of
6321
- // its gemma4s routes every turn is a real decision, and a box commonly holds a small one for
6322
- // speed beside a larger one for quality. Skipped when there is only one candidate: a picker
6323
- // with a single row is a question with one answer.
6324
- let chat = "";
6325
- if (agent) {
6326
- // Task #36: the saved agent stays a choice (and the default) even when it is not a gemma4.
6327
- const { cands, current: agentDefault } = os.agentChoices(
6328
- agent.models,
6329
- String(settings.agentModelName ?? "").trim()
6330
- );
6331
- if (cands.length <= 1) {
6332
- chat = agentDefault;
6333
- } else {
6334
- const labels = cands.map((m) => {
6335
- const size = am.formatSize(m.sizeBytes);
6336
- return `${m.model}${size ? dim(` ${size}`) : ""}${m.model === agentDefault ? dim(" (current)") : ""}`;
6337
- });
6338
- console.log(dim(` AGENT model — routes every turn, writes the plain replies (${PICK_HINT}):`));
6339
- const pick = await pickFromList(
6340
- labels,
6341
- Math.max(0, cands.findIndex((m) => m.model === agentDefault))
6342
- );
6343
- if (pick < 0) {
6344
- console.log(dim(" (cancelled — nothing changed)\n"));
6345
- return;
6346
- }
6347
- chat = cands[pick].model;
6348
- }
6349
- }
6350
-
6351
- // The CODER is chosen from whatever its box has; the EMBEDDER is not a choice at all.
6352
- let code = "";
6353
- let extras = [];
6354
- if (coder) {
6355
- const roles = os.defaultRoles(coder.models, {
6356
- preferred: { code: String(settings.agentCodingModelName ?? "").trim() },
6357
- });
6358
- const labels = coder.models.map((m) => {
6359
- const size = am.formatSize(m.sizeBytes);
6360
- const kind = am.isLikelyEmbedder(m) ? dim(" · embedder") : "";
6361
- return `${m.model}${size ? dim(` ${size}`) : ""}${kind}${m.model === roles.code ? dim(" (current)") : ""}`;
6362
- });
6363
- console.log(dim(` CODING model — writes the code; worth waiting for (${PICK_HINT}):`));
6364
- const idx = await pickFromList(labels, Math.max(0, coder.models.findIndex((m) => m.model === roles.code)));
6365
- if (idx < 0) {
6366
- console.log(dim(" (cancelled — nothing changed)\n"));
6367
- return;
6368
- }
6369
- code = coder.models[idx].model;
6370
-
6371
- const rest = coder.models.filter((m) => !am.isLikelyEmbedder(m) && m.model !== code);
6372
- if (rest.length) {
6373
- // Pre-ticked from what /model already offers, so re-running starts from what is saved
6374
- // rather than from empty — otherwise every re-run silently drops the extras you had.
6375
- const already = new Set(
6376
- (Array.isArray(settings.agentCodingBackends) ? settings.agentCodingBackends : [])
6377
- .filter((b) => (b?.kind ?? "") === "ollama")
6378
- .flatMap((b) => (Array.isArray(b.models) ? b.models : []))
6379
- );
6380
- console.log(
6381
- dim(` Also offer these in /model? (up to ${MAX_ALT_CODERS} · ${PICK_MANY_HINT}):`)
6382
- );
6383
- const ticks = await pickManyFromList(
6384
- rest.map((m) => `${m.model}${am.formatSize(m.sizeBytes) ? dim(` ${am.formatSize(m.sizeBytes)}`) : ""}`),
6385
- rest.map((m, i) => (already.has(m.model) ? i : -1)).filter((i) => i >= 0),
6386
- { max: Math.max(MAX_ALT_CODERS, rest.filter((m) => already.has(m.model)).length) }
6387
- );
6388
- // null is Escape and cancels the command; an EMPTY list is a legitimate "offer none".
6389
- if (ticks === null) {
6390
- console.log(dim(" (cancelled — nothing changed)\n"));
6391
- return;
6392
- }
6393
- extras = ticks.map((i) => rest[i].model);
6394
- }
6395
- }
6396
-
6397
- // WRITE ONLY THE HALVES THAT WERE SET. Someone who configured a coder but skipped the agent
6398
- // box must not have their existing agent model wiped — half-configured is a real state and
6399
- // clobbering the other half is the opposite of what skipping asked for.
6400
- const patch = {};
6401
- if (agent) {
6402
- Object.assign(patch, {
6403
- agentModelUrl: agent.normalized.url,
6404
- agentModelName: chat,
6405
- agentModel: chat,
6406
- ragEnabled: true,
6407
- ragEmbedUrl: agent.normalized.url,
6408
- ragEmbedModel: os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL,
6409
- });
6410
- }
6411
- if (coder) {
6412
- Object.assign(patch, {
6413
- agentCodingModelUrl: coder.normalized.url,
6414
- agentCodingModelName: code,
6415
- agentCodingModelKind: "ollama",
6416
- agentCodingDefaultKind: "ollama",
6417
- // MERGED, not replaced (#43): the providers already configured — and the keys bound to them —
6418
- // survive choosing a new Ollama server.
6419
- agentCodingBackends: (await import("./client-expert-api.mjs")).mergeOllamaServerBackends(
6420
- (await import("./coding-provider-setup.mjs")).existingBackendRows(settings),
6421
- (await import("./client-expert-api.mjs")).codingBackendsForClientExpert({
6422
- url: coder.normalized.url,
6423
- chat: agent ? chat : code,
6424
- code,
6425
- altCoders: extras,
6426
- }),
6427
- { isCloud: (await import("./coding-key-prompt.mjs")).isOllamaCloudUrl }
6428
- ),
6429
- });
6430
- }
6431
-
6432
- const saved = await withPendingStatus("saving", () =>
6433
- md.saveWorkerSettings(apiBase, workerKey, patch)
6434
- );
6435
- if (!saved) {
6436
- console.log(red(" couldn't save that to this machine — nothing changed."));
6437
- console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
6438
- return;
6439
- }
6440
- // #301: THE KEYS TYPED FOR THE SERVERS, bound to their rows now that the rows exist (the API
6441
- // refuses a key for a row it does not have). Before the cloud check below, so a key typed for
6442
- // the Ollama cloud is not asked for a second time. Reported by host, never by value.
6443
- {
6444
- const used = [agent?.normalized?.url, coder?.normalized?.url].filter(Boolean);
6445
- // By HOSTNAME: the key was asked for urlCandidates' host, which carries no port, while a URL's
6446
- // .host does — box.example.com:8443 would otherwise never match its own key.
6447
- const hostnameOf = (u) => { try { return new URL(u).hostname; } catch { return ""; } };
6448
- const pending = [...typedKeys].filter(([host, k]) => k && used.some((u) => hostnameOf(u) === host));
6449
- if (pending.length) {
6450
- const fresh = await withPendingStatus("storing the key", fetchWorkerSettings);
6451
- const ps = await import("./coding-provider-setup.mjs");
6452
- for (const [host, k] of pending) {
6453
- const url = used.find((u) => hostnameOf(u) === host);
6454
- const found = ps.providerRowForUrl(ps.existingBackendRows(fresh ?? {}), url);
6455
- if (!found) {
6456
- console.log(yellow(` no server row holds ${host} yet — add its key with ctrl+k in /model.`));
6457
- continue;
6458
- }
6459
- const ok = await withPendingStatus("saving the key", () => addCodingKey(host, k, found.id));
6460
- console.log(ok
6461
- ? GUTTER + dim(`✓ key stored for ${host} — the server's default key`)
6462
- : red(` couldn't store the key for ${host} — add it with ctrl+k.`));
6463
- }
6464
- }
6465
- }
6466
- // THE OLLAMA CLOUD NEEDS ITS OWN KEY. Reported: choosing https://ollama.com here listed its 20 models
6467
- // (that list is public) and saved, without a key — and the next turn failed "REJECTED the coding API
6468
- // key", having borrowed an unrelated sk-… key. Asked for now, bound to the row just saved.
6469
- {
6470
- const cloudUrls = [coder?.normalized?.url, agent?.normalized?.url].filter(
6471
- (u, i, all) => u && all.indexOf(u) === i && isOllamaCloudUrl(u)
6472
- );
6473
- if (cloudUrls.length) {
6474
- const fresh = await withPendingStatus("checking the Ollama cloud key", fetchWorkerSettings);
6475
- const keys = await fetchCodingKeys();
6476
- const ps = await import("./coding-provider-setup.mjs");
6477
- for (const u of cloudUrls) {
6478
- const found = ps.providerRowForUrl(ps.existingBackendRows(fresh ?? {}), u);
6479
- if (found && !am.boundKeyFor(keys, found.id)) {
6480
- console.log(yellow(` ${hostOf(u)} is the Ollama cloud — it needs its own API key (the model list worked without one).`));
6481
- // The shared key prompt (task #245); the server's first key becomes its default.
6482
- const { name, secret } = await askServerKey({ host: hostOf(u) });
6483
- if (secret && !(await withPendingStatus("saving the key", () => addCodingKey(name, secret, found.id)))) {
6484
- console.log(red(" couldn't save the key — add it with ctrl+k.\n"));
6485
- }
6486
- }
6487
- }
6488
- }
6489
- }
6490
- // THE ACCOUNT DEFAULT JUST CHANGED — say so in memory, or the bar reports the old one.
6491
- //
6492
- // The override is cleared just below, correctly: what was picked here IS the account default
6493
- // now, so an override would be a duplicate of it. But the bar reads
6494
- // `sessionCodingLabel || defaultCodingModel`, and defaultCodingModel was read from the
6495
- // startup payload and never touched again. Clearing one while the other stayed stale is why
6496
- // the confirmation said gemma4 and the footer kept showing the old coder (reported
6497
- // 2026-09-05). Both halves of that expression have to be current, not one.
6498
- if (coder) {
6499
- defaultCodingModel = code;
6500
- defaultCodingUrl = coder.normalized.url;
6501
- }
6502
- // The session override names a model on the OLD box; leaving it set would send a name the new
6503
- // host has never heard of, with the settings now correct — the most confusing shape of all.
6504
- sessionCodingModel = "";
6505
- sessionCodingLabel = "";
6506
- sessionCodingUrl = "";
6507
- // Persist the CLEARING too. The stored override names a model on the OLD box; leaving it on
6508
- // disk would resurrect it at the next launch, against a server that has never heard of it.
6509
- await saveSessionCoding(resolve(process.cwd()));
6510
- await syncActiveCoderWhere(); // the bar now names the account default — with ITS host (#263)
6511
- // The GLOBAL output-token total is keyed to the coding backend (task #125), and the backend
6512
- // just moved — re-fetch it for the new one, exactly as /model does after a pick, or the
6513
- // footer keeps counting against the box that is no longer in use.
6514
- if (coder) {
6515
- const t = await fetchOutputTokenTotals(resolve(process.cwd()));
6516
- const g = pickGlobalTotal(t);
6517
- if (g !== null) globalOutputTokens = g;
6518
- }
6519
- paintBar(); // …and show all of it now, not at the next keystroke
6520
- console.log(accent(" ✓ models saved"));
6521
- if (agent) {
6522
- console.log(dim(` chat ${chat} @ ${agent.normalized.host}`));
6523
- console.log(dim(` RAG ${os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL} @ ${agent.normalized.host}`));
6524
- } else {
6525
- console.log(dim(" chat — not set (agent server skipped)"));
6526
- }
6527
- console.log(
6528
- coder
6529
- ? dim(` code ${code} @ ${coder.normalized.host}`)
6530
- : dim(" code — not set (coding server skipped)")
6531
- );
6532
- if (extras.length) console.log(dim(` also ${extras.join(", ")} — offered by /model`));
6533
- // Compare to the embedder this box was seeded with — an accepted OTHER embedder (a
6534
- // qwen3-embedding-only box) was written to settings above, so REQUIRED_EMBED_MODEL would
6535
- // falsely scream "changed" while the seeded value is exactly what settings now hold.
6536
- const seededEmbed = agent ? (os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL) : "";
6537
- // Only a CHANGE when there was an embedder before — from none it is simply set (task #36).
6538
- const prevEmbed = String(settings.ragEmbedModel ?? "").trim();
6539
- if (agent && prevEmbed && prevEmbed !== seededEmbed) {
6540
- console.log(dim(" the embedder changed — existing RAG indexes need rebuilding (/rag)."));
6541
- }
6542
- console.log(dim(" takes effect on your next question.\n"));
6543
- }
6544
-
6545
6159
  // ---------- fetch the ready-to-run agent payload from user settings ----------
6546
6160
  // /model command: fetch the allowed coding models (default + web-curated whitelist),
6547
6161
  // show a numbered picker, and set sessionCodingModel to the choice for this session.
@@ -6805,8 +6419,7 @@ async function changeProviderModel(ps, rows, p) {
6805
6419
  return;
6806
6420
  }
6807
6421
  // THE FOLDER'S PICK NAMES THE OLD MODEL. Leaving it would send a name this provider no longer
6808
- // offers, with the settings now correct — the most confusing shape there is (the same trap
6809
- // reconfigureOllamaServer clears its override for).
6422
+ // offers, with the settings now correct — the most confusing shape there is.
6810
6423
  if (sessionCodingLabel === p.model || sessionCodingModel.endsWith(`::${p.model}`)) {
6811
6424
  sessionCodingModel = "";
6812
6425
  sessionCodingLabel = "";
@@ -7407,35 +7020,24 @@ async function chooseModel() {
7407
7020
  // through to add when there is genuinely nothing there.
7408
7021
  console.log(dim(" No coding model configured."));
7409
7022
  const go = await pickFromList(
7410
- [dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…", "change the Ollama server this machine uses…"],
7023
+ [dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7411
7024
  1
7412
7025
  );
7413
7026
  if (go === 1) await manageCodingProviders();
7414
- else if (go === 2) await reconfigureOllamaServer();
7415
7027
  else console.log("");
7416
7028
  return;
7417
7029
  }
7418
- // ONE ENTRY THAT IS NOT A MODEL (task #220 req 6): re-point this machine at a different Ollama
7419
- // box. Offered HERE because /model is where someone already is when they discover the coder is
7420
- // wrong — and on a Client Expert machine there is no web app to go and change it in. Appended
7421
- // last so it never shifts the index of a model anyone has muscle memory for.
7422
- // …and shown even when there is only ONE model, because "only one is available" is often the
7423
- // symptom of being pointed at the wrong box, and the old message sent people to a web app a
7424
- // Client Expert machine does not have.
7030
+ // Shown even with ONE model: "only one is available" often means the wrong server, and the
7031
+ // provider manager is where another one is added (an Ollama box is "another URL…").
7032
+ // #412 removed "change the Ollama server this machine uses…" from all three menus: it re-asked
7033
+ // the agent, embedder and coder servers in one go, which confused people picking a model.
7425
7034
  if (choices.length === 1) {
7426
7035
  console.log(dim(` Only one coding model is available: ${choices[0].model}.`));
7427
- // APPENDED AFTER the existing entry, here and below, so that entry keeps the index anyone
7428
- // who has used it already reaches for.
7429
7036
  const go = await pickFromList(
7430
- [
7431
- dim("keep it"),
7432
- "change the Ollama server this machine uses…",
7433
- "add or change a provider — Claude API, OpenAI, Kimi…",
7434
- ],
7037
+ [dim("keep it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7435
7038
  0
7436
7039
  );
7437
- if (go === 1) await reconfigureOllamaServer();
7438
- else if (go === 2) await manageCodingProviders();
7040
+ if (go === 1) await manageCodingProviders();
7439
7041
  else console.log("");
7440
7042
  return;
7441
7043
  }
@@ -7447,9 +7049,8 @@ async function chooseModel() {
7447
7049
  // OpenAI-compatible providers are both "OpenAI-compatible", so the kind names neither.
7448
7050
  const { labels, tags, showTag } = codingChoiceLabels(choices, def);
7449
7051
  const activeIdx = choices.findIndex((c) => c.id === active);
7450
- labels.push("change the Ollama server this machine uses…");
7451
- // Task #223. Appended LAST, after the #220 entry, for the same reason that one is appended
7452
- // after the models: a new row must never shift an index someone already has muscle memory for.
7052
+ // Task #223. Appended LAST, after the models: a new row must never shift an index someone
7053
+ // already has muscle memory for.
7453
7054
  //
7454
7055
  // "add or change" rather than "add", because add-only was a one-way door: a model id typed
7455
7056
  // wrong could not be corrected from anywhere, here or in web Settings. The entry leads to the
@@ -7470,11 +7071,9 @@ async function chooseModel() {
7470
7071
  console.log(dim(" (cancelled)\n"));
7471
7072
  return;
7472
7073
  }
7473
- // The appended entries sit past the last model, so they are the only indices without a choice.
7474
- // Their ORDER here must match the order they were pushed in, or a pick runs the other flow.
7074
+ // The one appended entry sits past the last model.
7475
7075
  if (idx >= choices.length) {
7476
- if (idx === choices.length) await reconfigureOllamaServer();
7477
- else await manageCodingProviders();
7076
+ await manageCodingProviders();
7478
7077
  return;
7479
7078
  }
7480
7079
  await applyChosenCoder(choices[idx], { def, tag: showTag ? tags[idx] : "" });
@@ -12389,6 +11988,10 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12389
11988
  let thinking = false;
12390
11989
  let thinkingSince = 0;
12391
11990
  let thinkWord = pickWord(); // playful word shown UNDER the in-progress line (flavor)
11991
+ // ONE TIP PER TURN (#234), under the word: stable for the whole turn (a tip that changed every few
11992
+ // seconds would be read as flicker), never the one the previous turn showed.
11993
+ const hintIdx = (lastHintIdx = pickHint(Math.random, lastHintIdx));
11994
+ let blockHintRows = 0; // 1 while the tip row is part of the live block (see the click arithmetic)
12392
11995
  let lastWordBucket = -1; // 12s window index → rotate the word once per window (task: 30-spinner)
12393
11996
  let almostDone = false; // set from the worker's "…almost completed thinking…" heartbeat
12394
11997
  let runningCmd = null; // the command currently executing (from a "Running → …" event)
@@ -12657,7 +12260,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12657
12260
  // redrawn continuously, so a blank written into the transcript would either accumulate one
12658
12261
  // per redraw or be stranded above a block that has since moved. Owned by the block, they
12659
12262
  // are erased with it — always exactly one, wherever it currently is. See LEAD_ROWS/WORD_GAP.
12660
- const drawn = 2 + LEAD_ROWS + WORD_GAP + expLines.length + detailRows.length;
12263
+ // THE TIP (#234): after a few seconds of waiting, one dim row under the word. Not on a very
12264
+ // short terminal, not with the animation off, and never wrapped.
12265
+ const hintText = !stillStatus && hintIdx >= 0 && secs >= HINT_AFTER_SECONDS && hintFits(process.stdout.rows)
12266
+ ? hintRow(HINTS[hintIdx], { width: Math.max(20, process.stdout.columns || 80), indent: visibleLen(GUTTER) })
12267
+ : "";
12268
+ blockHintRows = hintText ? 1 : 0;
12269
+ const drawn = 2 + LEAD_ROWS + WORD_GAP + expLines.length + detailRows.length + blockHintRows;
12661
12270
  // The bottom bar rides along UNDER the live status while a turn runs, so it stays on screen
12662
12271
  // the whole time the agent is thinking instead of only at the prompt. It's part of the same
12663
12272
  // in-place block: the ticker redraws it every 120ms and clearStatus wipes it with the rest,
@@ -12728,6 +12337,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12728
12337
  contentRows.push(
12729
12338
  clipRow(`${markLine(waitPaint(wc, glyph))}${waitPaint(wc, `${thinkWord}…`)}${outLabel}`)
12730
12339
  );
12340
+ if (hintText) contentRows.push(clipRow(GUTTER + dim(hintText)));
12731
12341
  // PINNED → the block IS frozen rows; unpinned (a window too short to reserve any) there is
12732
12342
  // no zone, so it keeps the old in-place draw at the output position.
12733
12343
  if (barPinned()) {
@@ -12772,7 +12382,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12772
12382
  // LEAD_ROWS · thought · detail… · expansion… · WORD_GAP · word
12773
12383
  // so the responding rows run from `top` to the last expansion row — statusContentLines less
12774
12384
  // the fixed rows around them (the two gaps, the thought line and the word row).
12775
- if (row < top || row > top + statusContentLines - LEAD_ROWS - WORD_GAP - 2) return;
12385
+ if (row < top || row > top + statusContentLines - LEAD_ROWS - WORD_GAP - 2 - blockHintRows) return;
12776
12386
  // Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
12777
12387
  // so we don't flicker an empty expand.
12778
12388
  if (!thoughtExpanded && !liveExpandable) return;
@@ -13979,7 +13589,7 @@ async function main() {
13979
13589
  void syncEmbedWarning({ repair: true });
13980
13590
  }
13981
13591
  // A RESTORED PICK MUST STILL EXIST. The stored id names a model on the box this folder was
13982
- // last pointed at — and the box can change (`/model` → change the Ollama server), a model
13592
+ // last pointed at — and the server can change (`/model` → add or change a provider), a model
13983
13593
  // can be removed, or the folder can be opened on another machine entirely. Sending a name
13984
13594
  // the account no longer allows fails the FIRST TURN with "not in your allowed coding
13985
13595
  // models", which reads as a broken agent rather than as a stale preference.
@@ -14390,7 +14000,8 @@ async function main() {
14390
14000
  red("gu: this machine has no models set up yet.") +
14391
14001
  dim(
14392
14002
  "\n Nothing can run until it knows which Ollama server to use." +
14393
- "\n Start `gu`, then `/model` → change the Ollama server this machine uses." +
14003
+ "\n Start `gu`, then `/agent-model` → use an Ollama server, `/embed-model` for RAG," +
14004
+ "\n and `/model` → add a provider → another URL… for the coding model." +
14394
14005
  `\n The agent server needs a ${REQUIRED_AGENT_PREFIX} model (e.g. ${REQUIRED_AGENT_MODEL})` +
14395
14006
  `\n and any ONE of these embedders: ${ACCEPTED_EMBED_MODELS.map((m) => m.replace(/:latest$/, "")).join(", ")};` +
14396
14007
  "\n the agent and coding models are then yours to pick."
package/hints.mjs ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * TIPS UNDER THE THINKING WORD (#234) — one line that teaches a command or a key while the model
3
+ * works, the way Claude Code's "⎿ Tip: …" does.
4
+ *
5
+ * Pure: the REPL picks one per turn and draws it under the playful word. Every "/command" named
6
+ * here must be a real one (hints.test.mjs checks them against the REPL's COMMANDS list), because a
7
+ * tip that names a command that does not exist is worse than no tip.
8
+ */
9
+
10
+ export const HINTS = [
11
+ "/model switches the coding model for this session",
12
+ "/quick-models picks two models; then ← / → at the prompt flips between them",
13
+ "/agent-model switches the model that talks to you (the agent model)",
14
+ "Type / then Tab to complete a command; /help lists them all",
15
+ "shift+tab switches between accept edits and manually edit",
16
+ "ctrl+t opens the model's full thought while it works",
17
+ "ctrl+c stops the turn; type continue to pick it up again",
18
+ "ctrl+k replaces the coding model's API key without leaving the terminal",
19
+ "/new starts a new conversation in this folder — the old one is kept",
20
+ "/doctor checks that every configured model really answers",
21
+ "/tokens shows how big the last prompt was and how much of it was RAG",
22
+ "/prices shows what the last turn and this session cost",
23
+ "/context-window tells gu how big your coding model's context is",
24
+ "/mcp lists the MCP servers and adds one step by step",
25
+ "/hooks lists the commands gu runs on start, ask and turn end",
26
+ "/shells shows long commands the agent left running in the background",
27
+ "/budget shows your API provider balances (Pro)",
28
+ "/browser checks the gu Chrome extension the agent uses to click web pages",
29
+ "/support sends the gu team a request with your logs attached (secrets masked)",
30
+ "/clear-logs shows what gu's logs and indexes take on disk, then asks what to clear",
31
+ "/switch-skill chooses whose skills and MCP servers this folder uses: Claude's or gu's",
32
+ "/use-presets applies a saved setup after showing what changes",
33
+ "/save-settings writes your providers and models to a file you can load on another machine",
34
+ ];
35
+
36
+ /**
37
+ * One tip index, never `last` when there is a choice (so two turns in a row do not repeat).
38
+ * `rand` is injectable (0 <= rand() < 1) for tests.
39
+ */
40
+ export function pickHint(rand = Math.random, last = -1, list = HINTS) {
41
+ if (list.length === 0) return -1;
42
+ if (list.length === 1) return 0;
43
+ let i = Math.floor(rand() * list.length) % list.length;
44
+ if (i === last) i = (i + 1) % list.length;
45
+ return i;
46
+ }
47
+
48
+ /** The glyph and label in front of a tip — plain text, painted dim by the caller. */
49
+ export const HINT_PREFIX = "⎿ Tip: ";
50
+
51
+ /**
52
+ * The tip as ONE row no wider than `width` - 1 columns including `indent` spaces: cut with "…" and
53
+ * never wrapped (a wrapped row in the live block is the #109/#138 failure). "" when there is no room
54
+ * for anything readable.
55
+ */
56
+ export function hintRow(text, { width = 80, indent = 3 } = {}) {
57
+ const room = Math.max(0, (width || 80) - 1 - indent);
58
+ const full = HINT_PREFIX + String(text ?? "").trim();
59
+ if (!String(text ?? "").trim() || room < 20) return "";
60
+ return full.length <= room ? full : full.slice(0, room - 1).replace(/\s+$/, "") + "…";
61
+ }
62
+
63
+ /** How long the model must have been working before the tip appears, in seconds. */
64
+ export const HINT_AFTER_SECONDS = 4;
65
+
66
+ /** Is there room for the tip row? Not on a very short terminal, where every row counts. */
67
+ export const hintFits = (rows) => (rows || 24) >= 16;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.870",
3
+ "version": "1.0.871",
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",
@@ -114,6 +114,7 @@
114
114
  "ollama-setup.mjs",
115
115
  "s3-setup.mjs",
116
116
  "doctor-check.mjs",
117
+ "service-check.mjs",
117
118
  "embed-model-command.mjs",
118
119
  "proxy-command.mjs",
119
120
  "proxy-selftest.mjs",
@@ -123,6 +124,7 @@
123
124
  "coding-key-prompt.mjs",
124
125
  "model-fallback.mjs",
125
126
  "quick-models.mjs",
127
+ "hints.mjs",
126
128
  "terminal-title.mjs",
127
129
  "gu-relaunch.mjs",
128
130
  "server-keys.mjs",
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Can this machine reach the gu service — and if not, why? (task #413, GH #240)
3
+ *
4
+ * node --test tests/service-check.test.mjs
5
+ *
6
+ * Reported on a Windows VM: the agent model answered "hi", but every coding turn failed with
7
+ * "the gu service couldn't be reached". The coding turn is the only one that needs the gu service:
8
+ * the local API fetches the agent's instructions from it first, with Node's plain fetch. The
9
+ * Python agent and the terminal's model calls reach the internet by other routes (the proxy,
10
+ * the system certificates), so "the internet works" and "the local API can't get out" are both
11
+ * true at once. The usual causes on a work machine need different fixes:
12
+ *
13
+ * · a network that inspects HTTPS — Node does not trust its certificate, Python does
14
+ * · a network that needs a proxy — the local API does not use gu's /proxy
15
+ * · an address that is blocked or cannot be looked up
16
+ *
17
+ * So the check makes the request the local API makes, then the variations that tell the causes
18
+ * apart, and says which one it is. The decisions are pure (serviceVerdict) so they are tested
19
+ * without a network; checkGuService only gathers the facts.
20
+ */
21
+ import { spawn } from "node:child_process";
22
+
23
+ /** Why a fetch failed, as a kind, a code and words. Node says "fetch failed" and keeps the reason in `cause`. */
24
+ export function describeNetError(err) {
25
+ if (err?.name === "TimeoutError" || err?.name === "AbortError") {
26
+ return { kind: "timeout", code: "TIMEOUT", words: "no answer in time" };
27
+ }
28
+ const code = String(err?.cause?.code ?? err?.code ?? "");
29
+ if (/CERT|SELF_SIGNED|UNABLE_TO_(GET|VERIFY)|ERR_TLS/i.test(code)) {
30
+ return { kind: "cert", code, words: "this computer doesn't trust the certificate it was shown" };
31
+ }
32
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") return { kind: "dns", code, words: "the address could not be looked up" };
33
+ if (["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "ENETUNREACH", "EHOSTUNREACH"].includes(code)) {
34
+ return { kind: "blocked", code, words: code === "ECONNREFUSED" ? "the connection was refused" : code === "ECONNRESET" ? "the connection was cut" : "the connection didn't go through" };
35
+ }
36
+ return { kind: "other", code, words: code || String(err?.cause?.message ?? err?.message ?? err ?? "network error") };
37
+ }
38
+
39
+ /** One GET; any HTTP answer means the server was reached (401 included — no token was sent). */
40
+ async function reach(url, { fetchImpl = fetch, dispatcher, timeoutMs = 10_000 } = {}) {
41
+ try {
42
+ const r = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs), ...(dispatcher ? { dispatcher } : {}) });
43
+ return { ok: true, status: r.status };
44
+ } catch (err) {
45
+ return { ok: false, ...describeNetError(err) };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * The same request from a NODE STARTED WITH --use-system-ca: does trusting this computer's own
51
+ * certificates (Windows' store, macOS' keychain) fix it? A Node without the flag says "bad option".
52
+ */
53
+ export function reachWithSystemCa(url, { execPath = process.execPath, timeoutMs = 15_000, spawnImpl = spawn } = {}) {
54
+ const script =
55
+ `fetch(${JSON.stringify(url)},{signal:AbortSignal.timeout(${timeoutMs - 3000})})` +
56
+ `.then(r=>process.stdout.write("ok "+r.status))` +
57
+ `.catch(e=>process.stdout.write("err "+((e.cause&&e.cause.code)||e.name||e.message)))`;
58
+ return new Promise((resolveP) => {
59
+ let out = "";
60
+ let err = "";
61
+ let child;
62
+ try {
63
+ child = spawnImpl(execPath, ["--use-system-ca", "-e", script], { stdio: ["ignore", "pipe", "pipe"] });
64
+ } catch (e) {
65
+ resolveP({ ok: false, unsupported: false, words: e?.message ?? String(e) });
66
+ return;
67
+ }
68
+ const timer = setTimeout(() => child.kill(), timeoutMs);
69
+ child.stdout?.on("data", (d) => { out += d; });
70
+ child.stderr?.on("data", (d) => { err += d; });
71
+ child.on("error", (e) => {
72
+ clearTimeout(timer);
73
+ resolveP({ ok: false, unsupported: false, words: e?.message ?? String(e) });
74
+ });
75
+ child.on("close", () => {
76
+ clearTimeout(timer);
77
+ if (/bad option|unknown option/i.test(err)) resolveP({ ok: false, unsupported: true, words: "" });
78
+ else if (out.startsWith("ok ")) resolveP({ ok: true, status: Number(out.slice(3)) });
79
+ else resolveP({ ok: false, unsupported: false, words: out.replace(/^err /, "") || err.trim().split("\n").pop() || "no answer" });
80
+ });
81
+ });
82
+ }
83
+
84
+ /**
85
+ * THE VERDICT, from the facts. Pure.
86
+ *
87
+ * direct { ok, status } | { ok:false, kind, words } Node's plain fetch (what the local API does)
88
+ * viaProxy same, or null when no proxy is set
89
+ * systemCa { ok } | { ok:false, unsupported, words }, or null when not tried
90
+ * localApi { ok, version } | { ok:false, error }, or null when this machine has no local API
91
+ *
92
+ * Returns { ok, lines }: lines are for the terminal, a ✓ / ✗ line first and the reason after.
93
+ */
94
+ export function serviceVerdict({ host, direct, viaProxy = null, systemCa = null, localApi = null, nodeVersion = process.version }) {
95
+ const lines = [];
96
+ if (localApi?.ok) {
97
+ lines.push(`✓ gu service — private mode can fetch the agent's instructions (v${localApi.version})`);
98
+ return { ok: true, lines };
99
+ }
100
+ if (direct.ok) {
101
+ if (!localApi) {
102
+ lines.push(`✓ gu service — ${host} answers`);
103
+ return { ok: true, lines };
104
+ }
105
+ lines.push(`✗ gu service — ${host} answers this computer, but the local API couldn't get the agent's instructions:`);
106
+ lines.push(` ${localApi.error}`);
107
+ // A token problem says its own fix; anything else is most often a local API started before
108
+ // the network came up, or an older one.
109
+ if (!/gu token/.test(localApi.error)) lines.push(" restart the local API (`gu api restart`) and try again.");
110
+ return { ok: false, lines };
111
+ }
112
+ lines.push(`✗ gu service — ${host} can't be reached: ${direct.words}`);
113
+ lines.push(" a coding turn needs it (the agent's instructions come from it); a plain reply like \"hi\" does not.");
114
+ if (direct.kind === "cert") {
115
+ if (systemCa?.ok) {
116
+ lines.push(" It works when Node trusts this computer's own certificates: this network inspects HTTPS.");
117
+ lines.push(" gu doesn't start its local API that way yet (#413) — ask IT, or use another network meanwhile.");
118
+ } else if (systemCa?.unsupported) {
119
+ lines.push(` This network probably inspects HTTPS. Node ${nodeVersion} can't use the system certificates;`);
120
+ lines.push(" Node 22.15 or newer can. Ask IT, or use another network.");
121
+ } else {
122
+ lines.push(" Even with this computer's own certificates it fails — ask IT which certificate the network uses.");
123
+ }
124
+ } else if (viaProxy?.ok) {
125
+ lines.push(" It works through your /proxy: this network needs it, and the local API doesn't use it yet (#413).");
126
+ } else if (viaProxy && !viaProxy.ok) {
127
+ lines.push(` Through your /proxy it fails too: ${viaProxy.words}. Check the proxy settings with /proxy.`);
128
+ } else if (direct.kind === "dns") {
129
+ lines.push(" Check this computer's DNS, or whether the network blocks the address.");
130
+ } else {
131
+ lines.push(` Allow ${host} on this network (firewall), or set /proxy if the network needs one.`);
132
+ }
133
+ if (localApi && !localApi.ok) lines.push(` the local API says: ${localApi.error}`);
134
+ return { ok: false, lines };
135
+ }
136
+
137
+ /**
138
+ * Gather the facts and judge them. `hostedBase` is where the local API forwards to; `apiBase` +
139
+ * `workerKey` reach this machine's local API, asked only when `localApi` is true (Client Expert).
140
+ */
141
+ export async function checkGuService({
142
+ hostedBase,
143
+ apiBase = "",
144
+ workerKey = "",
145
+ localApi = false,
146
+ proxy = null,
147
+ dispatcherFor = null,
148
+ fetchImpl = fetch,
149
+ systemCaImpl = reachWithSystemCa,
150
+ timeoutMs = 10_000,
151
+ } = {}) {
152
+ const base = String(hostedBase ?? "").replace(/\/+$/, "");
153
+ const host = (() => { try { return new URL(base).host; } catch { return base; } })();
154
+ const url = `${base}/api/health`;
155
+ const direct = await reach(url, { fetchImpl, timeoutMs });
156
+
157
+ let viaProxy = null;
158
+ if (!direct.ok && proxy?.enabled && typeof dispatcherFor === "function") {
159
+ const dispatcher = await dispatcherFor(proxy).catch(() => null);
160
+ if (dispatcher) viaProxy = await reach(url, { fetchImpl, dispatcher, timeoutMs });
161
+ }
162
+ const systemCa = !direct.ok && direct.kind === "cert" ? await systemCaImpl(url) : null;
163
+
164
+ let local = null;
165
+ if (localApi && apiBase) {
166
+ try {
167
+ const r = await fetchImpl(`${apiBase.replace(/\/+$/, "")}/api/worker/prompt-scaffold/refresh`, {
168
+ method: "POST",
169
+ headers: { "X-Worker-Key": workerKey },
170
+ signal: AbortSignal.timeout(timeoutMs + 5000),
171
+ });
172
+ const body = await r.json().catch(() => null);
173
+ local = body?.ok
174
+ ? { ok: true, version: body.version }
175
+ : { ok: false, error: String(body?.error ?? `HTTP ${r.status}`) };
176
+ } catch (err) {
177
+ local = { ok: false, error: `the local API didn't answer (${describeNetError(err).words})` };
178
+ }
179
+ }
180
+ return serviceVerdict({ host, direct, viaProxy, systemCa, localApi: local });
181
+ }