@tiens.nguyen/gu-cli 1.0.897 → 1.0.899

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  <h1 align="center">gu — the coding agent for your own models</h1>
2
2
 
3
3
  <p align="center">
4
- <strong>A coding agent in your terminal that runs on Ollama or any OpenAI-compatible API.</strong><br>
5
- It reads your repo, makes the change, runs the tests and shows you what it did.<br>
6
- <strong>Free installs run entirely on your machine. Free for 30 days: no account, no sign-in, no card.</strong>
4
+ <strong>Describe the change. gu edits your repo, runs the tests and shows you the diff, with Ollama or any OpenAI-compatible API.</strong><br>
5
+ Your prompts and code go to the model you choose, not to gu: its API and database run on your computer.<br>
6
+ <strong>Free for 30 days: no account, no sign-in, no card.</strong>
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -18,7 +18,8 @@
18
18
  <a href="https://gucli.com">Website</a> ·
19
19
  <a href="https://gucli.com/start">Docs</a> ·
20
20
  <a href="https://gucli.com/pricing">Pricing</a> ·
21
- <a href="https://gucli.com/support">Support</a>
21
+ <a href="https://gucli.com/support">Support</a><br>
22
+ <sub>The website is also in Tiếng Việt, 中文 and 日本語.</sub>
22
23
  </p>
23
24
 
24
25
  <p align="center">
@@ -40,8 +41,13 @@ gu
40
41
  computer. Press Enter to use it, pick a model provider, then type what you want done.
41
42
 
42
43
  A free install is **private**: gu's API and its database (MongoDB) run on this computer, next to
43
- your code. The first run shows what it will install and asks before installing anything.
44
- Needs **Node.js 20.19+**.
44
+ your code, and your prompts and code go to the model you pick, not to gu. The first run shows what it will
45
+ install and asks before installing anything. Needs **Node.js 20.19+**.
46
+
47
+ **Local or hosted: you choose once, at the first run.** Free installs run in *Local mode* (above).
48
+ With a Pro token, gu uses gu's hosted API and models instead. To switch later, run `gu clear` and
49
+ set gu up again. In Local mode `gu clear` also deletes this computer's conversations (it asks
50
+ first); `gu clear --keep-history` keeps them.
45
51
 
46
52
  > **`gu: command not found`?** Run `npx @tiens.nguyen/gu-cli fix-path`. See [Troubleshooting](#troubleshooting).
47
53
 
@@ -65,11 +71,15 @@ Needs **Node.js 20.19+**.
65
71
  ## Is gu for you?
66
72
 
67
73
  **A good fit if** you run (or want to run) your own models, can't send code to someone else's
68
- cloud, work on remote Linux boxes over SSH, or want a coding agent that costs $4 a month instead
69
- of a model subscription.
74
+ cloud, work on remote Linux boxes over SSH, or want a terminal coding agent that is free with
75
+ your own models and $4 a month with ours.
70
76
 
71
77
  **Want a frontier model?** gu runs those too, through any OpenAI-compatible API, and keeps
72
- everything else: the edits in your repo, the tests, the browser check and `/undo`.
78
+ everything else: the edits in your repo, the tests, the browser check and `/undo`. (Your code then
79
+ goes to that provider, as with any tool that uses it.)
80
+
81
+ **Not a fit if** you want one click and no setup: gu asks you to pick a model, and it works best
82
+ with a strong one.
73
83
 
74
84
  ## Quick start
75
85
 
@@ -89,8 +99,8 @@ gu uses up to three models, each with its own command:
89
99
  | `/agent-model` | **agent**: talks to you and plans | this machine |
90
100
  | `/embed-model` | **embedding**: RAG (`nomic-embed-text`, `qwen3-embedding`), local or remote | this machine |
91
101
 
92
- Run `/doctor` any time to check that each one answers. Coding works best with a strong model:
93
- small local models often fail at multi-step changes.
102
+ Run `/doctor` any time to check that each one answers. Pick a strong model for coding: small
103
+ local models often fail at multi-step changes.
94
104
 
95
105
  ## Using gu
96
106
 
@@ -167,7 +177,7 @@ gu doctor set up and check models on this machine
167
177
  gu rag [plan [path] | --test] test the embedder, estimate index time
168
178
  gu workspace revert [runId] undo an agent run's file edits from its backups
169
179
  gu support send a support request with masked logs
170
- gu clear [--yes] back to a fresh machine (asks first)
180
+ gu clear [--keep-history] back to a fresh machine (asks first)
171
181
  gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
172
182
  ```
173
183
 
@@ -188,6 +198,7 @@ gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
188
198
  | --- | --- | --- |
189
199
  | **Free trial** | 30 days, from the first run | nothing: the token is created for you |
190
200
  | **Free plan** | 3 more months on that computer | a free account at [gucli.com](https://gucli.com) |
201
+ | **After that** | gu asks for a token | Pro, or a voucher for more free days |
191
202
  | **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run), any number of computers, provider budgets |
192
203
 
193
204
  On Pro, the hosted models and the hosted API run on gu's servers, so the prompts and code you
@@ -206,6 +217,11 @@ so the step that adds npm's bin folder to your PATH may not have run. Run
206
217
  `npx @tiens.nguyen/gu-cli fix-path`. It also finds what an old install left behind (broken links,
207
218
  a second gu from another npm, a stale PATH line) and asks before fixing each one. `--check` only reports.
208
219
 
220
+ **`EACCES` / permission denied during install (macOS, Linux).** npm's global folder belongs to
221
+ root. No sudo needed: run `npm config set prefix ~/.npm-global`, then install again. gu adds that
222
+ folder to your PATH; open a new terminal. With nvm, skip that (nvm refuses an npm prefix):
223
+ `nvm use` a Node 20.19 or newer and install again.
224
+
209
225
  **Windows: `gu` runs `Get-Unique`.** PowerShell has a built-in `gu` alias. Run `gu-cli fix-powershell`.
210
226
 
211
227
  **Node version.** gu needs **Node.js 20.19+** (a Pro install using only hosted models runs on 18+).
@@ -153,7 +153,7 @@ export function sameModelName(a, b) {
153
153
  /** Plain-text fallback for each reason we cannot name the model. Never a sentinel. */
154
154
  export const UNKNOWN_MODEL_TEXT = Object.freeze({
155
155
  remote: "(named by the remote server)",
156
- "not-set": "not set — /agent-model",
156
+ "not-set": "same as the coding model",
157
157
  "no-server": "(no model server running)",
158
158
  ambiguous: "(the loaded model)",
159
159
  });
@@ -20,8 +20,9 @@ import { homedir } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import { promisify } from "node:util";
22
22
  import { createHash } from "node:crypto";
23
- import { localModeNodeProblem } from "./node-version.mjs";
23
+ import { localModeNodeProblem, nvmNodeVersions } from "./node-version.mjs";
24
24
  import { downloadMongodExe, startPortableMongod, portableMongoInstalled, mongoWinPaths, mongodCommandLine, MONGO_WIN_VERSION, mongodRuns, isDllNotFoundExit, installVcRuntime, VC_REDIST_URL } from "./mongo-windows.mjs";
25
+ import { macosMajor, macNeedsOwnMongo, mongoMacPaths, macMongoInstalled, downloadMongodMac, mongodRunsMac, MONGO_MAC_OLD_VERSION, MONGO_MAC_BREW_MIN_MAJOR } from "./mongo-mac.mjs";
25
26
  import {
26
27
  apiDepsInstalled,
27
28
  installApiDeps,
@@ -248,11 +249,17 @@ export async function probeMongo() {
248
249
  // saying ("you already have mongosh") and because `verify` runs through it.
249
250
  const serverPath = (await tryRun("sh", ["-c", "command -v mongod || true"])) || "";
250
251
  const shellPath = (await tryRun("sh", ["-c", "command -v mongosh || true"])) || "";
252
+ // gu's own mongod on macOS 13 and older (GH #403) is not on PATH; `portable` says to start that one.
253
+ const portable = process.platform === "darwin" && (await macMongoInstalled());
254
+ // On those Macs Homebrew's mongod cannot run, so it does not count as installed: "it is not
255
+ // running" would send the reader to a server that can never start.
256
+ const brewUsable = !(process.platform === "darwin" && macNeedsOwnMongo(macosMajor()));
251
257
  return {
252
258
  running: false,
253
259
  how: null,
254
- installed: Boolean(serverPath.trim()),
260
+ installed: (brewUsable && Boolean(serverPath.trim())) || portable,
255
261
  shellInstalled: Boolean(shellPath.trim()),
262
+ portable,
256
263
  };
257
264
  }
258
265
 
@@ -298,7 +305,12 @@ export async function probeDockerForMongo(name = MONGO_DOCKER_NAME) {
298
305
  * macOS: make a native MongoDB answer on 27017 (task #304). → { ok, explained }.
299
306
  * `explained` = it printed its own guidance, so the caller adds none.
300
307
  */
301
- export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.log(l), brewHasTrust = null } = {}) {
308
+ export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.log(l), brewHasTrust = null, osMajor = macosMajor, ownMongo = ensureOwnMongoMac } = {}) {
309
+ // macOS 13 and older: Homebrew's MongoDB installs and then cannot run there (GH #403), so gu
310
+ // runs its own. A Mac that already has gu's own mongod keeps using it after a macOS upgrade.
311
+ if (!mongo.silentPort && (mongo.portable || macNeedsOwnMongo(osMajor()))) {
312
+ return ownMongo(ask, mongo, { log });
313
+ }
302
314
  const mn = await import("./mongo-native.mjs");
303
315
  brewHasTrust ??= mn.brewHasTrust;
304
316
  const brew = mn.findBrew();
@@ -351,6 +363,71 @@ export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.lo
351
363
  return { ok: true, explained: true };
352
364
  }
353
365
 
366
+ /**
367
+ * macOS 13 and older (GH #403): gu's own mongod 8.0.5 in ~/.gonext/mongodb, run on 127.0.0.1 —
368
+ * no Homebrew, no service, no sudo. Shown, asked, then done, like the other platforms.
369
+ * Not started at login: gu's start-up check (preflightLocalMongo) starts it again when needed.
370
+ */
371
+ export async function ensureOwnMongoMac(ask, mongo, {
372
+ log = (l) => console.log(l),
373
+ osMajor = macosMajor,
374
+ paths = mongoMacPaths(),
375
+ download = downloadMongodMac,
376
+ runs = (p) => mongodRunsMac(p.exe),
377
+ start = startPortableMongod,
378
+ waitFor = null,
379
+ } = {}) {
380
+ const installing = !mongo.portable;
381
+ const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
382
+ if (installing) {
383
+ const major = osMajor();
384
+ log(dim(`\n Homebrew's MongoDB needs macOS ${MONGO_MAC_BREW_MIN_MAJOR} or newer${major ? ` (this Mac has macOS ${major})` : ""}, so gu runs`));
385
+ log(dim(` its own MongoDB ${MONGO_MAC_OLD_VERSION} instead — no Homebrew, no service, no password:\n`));
386
+ log(` download MongoDB ${MONGO_MAC_OLD_VERSION} (about 85 MB) into ${paths.dir}`);
387
+ } else {
388
+ log(dim("\n gu's MongoDB is installed but not running. This starts it:\n"));
389
+ }
390
+ log(" " + mongodCommandLine({ port, paths }));
391
+ log("");
392
+ const yes = await readYesNo(ask, ` ${installing ? "Install" : "Start"} MongoDB now? [Y/n]: `, true, log);
393
+ if (!yes) {
394
+ log(dim(`\n Not ${installing ? "installed" : "started"} — run the commands above yourself.`));
395
+ return { ok: false, explained: true };
396
+ }
397
+ log("\n " + bold(installing ? "Installing MongoDB" : "Starting MongoDB") + dim(installing ? " about 85 MB" : ""));
398
+ if (installing) {
399
+ try {
400
+ await download({ paths, log });
401
+ } catch (e) {
402
+ log("\n " + red("✗ ") + `could not download MongoDB: ${e?.message ?? e}`);
403
+ return { ok: false, explained: true };
404
+ }
405
+ }
406
+ const probe = await runs(paths);
407
+ if (!probe.ok) {
408
+ log("\n " + red("✗ ") + `MongoDB cannot run on this Mac: ${probe.why}`);
409
+ return { ok: false, explained: true };
410
+ }
411
+ let started;
412
+ try {
413
+ started = await start({ port, paths });
414
+ } catch (e) {
415
+ started = { ok: false, error: e };
416
+ }
417
+ if (!started?.ok) {
418
+ log("\n " + red("✗ ") + `MongoDB could not be started${started?.error ? `: ${started.error.message ?? started.error}` : ""}`);
419
+ return { ok: false, explained: true };
420
+ }
421
+ const wait = waitFor ?? (await import("./mongo-native.mjs")).waitForMongo;
422
+ if (!(await wait(host, port, { timeoutMs: 60000 }))) {
423
+ log("\n " + red("✗ ") + `MongoDB was ${installing ? "installed" : "started"} but is not answering at ${host}:${port} yet.`);
424
+ log(dim(` Its log: ${paths.log} — then run gu again.`));
425
+ return { ok: false, explained: true };
426
+ }
427
+ log(" " + cyan("✓ MongoDB is running") + dim(` at ${host}:${port} (started by gu — data in ${paths.data})`));
428
+ return { ok: true, explained: true };
429
+ }
430
+
354
431
  /** /etc/os-release → { id, codename, versionId }, or null where there is none (not Linux). */
355
432
  export function readOsRelease({ read = (f) => readFileSync(f, "utf8") } = {}) {
356
433
  let text = "";
@@ -622,7 +699,8 @@ export async function preflightLocalMongo({
622
699
  log("\n " + red("gu: MongoDB is not answering") + dim(` at ${host}:${port}`) +
623
700
  red(mongo.installed || mongo.silentPort || !local ? "." : " — it is not installed on this machine."));
624
701
  log(dim(" Local mode keeps your conversations, models and keys in that database, so"));
625
- log(dim(` nothing can start without it. The API on :${LOCAL_API_PORT} is fine — it is waiting on this.`));
702
+ // Nothing here checks the API, so say nothing about it (it was "fine" with no API process, GH #406).
703
+ log(dim(" nothing can start without it. gu starts the API once the database answers."));
626
704
  if (!local) {
627
705
  log(dim(`\n That database is on another machine (${host}). Start it there, then run gu again.\n`));
628
706
  return { ok: false, fixed: false, installed: false };
@@ -1132,7 +1210,7 @@ export async function runClientExpertSetup({
1132
1210
  // BEFORE ANYTHING IS INSTALLED (task #333): the API needs a newer Node than gu does. Not part
1133
1211
  // of the gate on purpose — a gate "no" makes the first run settle on Cloud mode for good,
1134
1212
  // while the fix here is a Node upgrade, after which `gu` must resume Local setup.
1135
- const nodeProblem = localModeNodeProblem(process.version, machine.platform);
1213
+ const nodeProblem = localModeNodeProblem(process.version, machine.platform, { nvmVersions: nvmNodeVersions() });
1136
1214
  if (nodeProblem) {
1137
1215
  console.log("\n " + red("✗ ") + nodeProblem + "\n");
1138
1216
  return { complete: false, stoppedAt: "node" };
@@ -1189,6 +1267,7 @@ export async function runClientExpertSetup({
1189
1267
  // "Docker is not available here": a machine without Docker is the ordinary case on a server
1190
1268
  // and on Windows, not an error to open with.
1191
1269
  let explained = false;
1270
+ const hadServer = Boolean(mongo.installed);
1192
1271
  if (machine.platform !== "darwin" && !mongo.silentPort && (await probeDockerForMongo()).usable) {
1193
1272
  if (await startMongoInDocker(ask)) {
1194
1273
  mongo.running = true;
@@ -1233,6 +1312,8 @@ export async function runClientExpertSetup({
1233
1312
  mongo.running = true;
1234
1313
  mongo.how = after.how;
1235
1314
  }
1315
+ // Setup INSTALLED it a moment ago, so it has no credentials of its own to ask about.
1316
+ mongo.installedNow = !hadServer;
1236
1317
  }
1237
1318
 
1238
1319
  console.log("\n " + cyan("✓ MongoDB is running") + dim(` (${mongo.how})`));
@@ -1251,6 +1332,8 @@ export async function runClientExpertSetup({
1251
1332
  if (mongoUri !== LOCAL_MONGO_URI) {
1252
1333
  console.log(dim(" using the MongoDB credentials from the previous run"));
1253
1334
  }
1335
+ } else if (credsDecision === "ask" && mongo.installedNow) {
1336
+ state = { ...state, mongoCredsConfirmed: true };
1254
1337
  } else if (credsDecision === "ask") {
1255
1338
  const credsChoice = await askMongoCredentials(ask, askSecret);
1256
1339
  if (credsChoice.exit) {
@@ -1575,8 +1658,10 @@ export async function runClientExpertSetup({
1575
1658
  }
1576
1659
  if (skipModels) {
1577
1660
  console.log("");
1578
- console.log(" " + cyan("✓ Model setup skipped") + dim(addProvider
1579
- ? ` — ${addProvider.name || "the provider list"}: its key and model are asked as soon as gu starts`
1661
+ // "skipped" is only true when nothing was chosen: a provider that was picked is NOT skipped,
1662
+ // its key and model are just asked in gu itself (GH #397).
1663
+ console.log(" " + cyan(addProvider ? `✓ ${addProvider.name || "Provider"} chosen` : "✓ Model setup skipped") + dim(addProvider
1664
+ ? " — gu asks for its key and model as soon as it starts"
1580
1665
  : loadPreset
1581
1666
  ? " — the preset picker opens as soon as gu starts"
1582
1667
  : " — set them any time with /model (or /use-presets)"));
package/doctor-check.mjs CHANGED
@@ -83,14 +83,17 @@ export function rolesFromPayload(payload, settings, codingKey = "") {
83
83
  const realKey = (k) => (str(k) && str(k) !== "local" ? str(k) : "");
84
84
  const [, , embed] = rolesFromSettings(settings);
85
85
  const agentSet = payload.agentMissing !== true;
86
+ // NO SEPARATE AGENT MODEL: the coding model answers the chat calls too (GH #397), so check it
87
+ // on THAT path with its key — a "not set" here hid a 401 on "hi" while coding turns worked.
88
+ const chat = agentSet
89
+ ? { url: str(payload.agentBaseURL), model: str(payload.agentModelId), apiKey: realKey(payload.agentApiKey) }
90
+ : { url: str(payload.codingBaseURL), model: str(payload.codingModelId), apiKey: realKey(codingKey) };
86
91
  return [
87
92
  {
88
93
  role: "chat",
89
94
  label: "chat model",
90
- why: "answers and routes every turn",
91
- url: agentSet ? str(payload.agentBaseURL) : "",
92
- model: agentSet ? str(payload.agentModelId) : "",
93
- apiKey: agentSet ? realKey(payload.agentApiKey) : "",
95
+ why: agentSet ? "answers and routes every turn" : "answers and routes every turn (the coding model, none set apart)",
96
+ ...chat,
94
97
  },
95
98
  {
96
99
  role: "code",
@@ -228,14 +228,14 @@ export function embedNotice({ state, model } = {}) {
228
228
  * WHICH SETUP ROWS THE BOTTOM BAR SHOWS (first-run noise, #188). A fresh install used to stack
229
229
  * three warnings at once. Now they come in order of need, and pure (no OS-specific logic):
230
230
  * - nothing to answer with → ONLY "coding model not set" (the one thing to do first);
231
- * - a coding model exists but no agent model → the agent row (the coding model stands in);
231
+ * - a coding model exists but no agent model → NOTHING: one model that also talks is the normal
232
+ * setup, not a fault (GH #397). `agentMissing` is still accepted and ignored.
232
233
  * - no embed model → NOTHING until embedding was actually needed (`embedNeeded`: the agent tried
233
234
  * a RAG tool, or the user opened /embed-model), then the embed row.
234
235
  */
235
236
  export function setupNoticeRows({ noModel = false, agentMissing = false, embedMissing = false, embedNeeded = false, embedWhy } = {}) {
236
237
  if (noModel) return ["coding model not set · /model"];
237
238
  return [
238
- ...(agentMissing ? ["agent model not set — using the coding model · /agent-model"] : []),
239
239
  ...(embedMissing && embedNeeded ? [embedNotice(embedWhy) || "no embed model — RAG is off · /embed-model"] : []),
240
240
  ];
241
241
  }
package/gu-cli.mjs CHANGED
@@ -400,7 +400,7 @@ if (["start", "stop", "status", "logs", "restart"].includes(args[0])) {
400
400
  if (await ctl.isClientMode()) {
401
401
  console.log(
402
402
  "[gu-worker] CLIENT mode — this machine runs the agent but serves no models;\n" +
403
- " it calls your model machine over the network. `/host-mode` undoes it."
403
+ " it calls your model machine over the network. `gu clear` starts setup over."
404
404
  );
405
405
  }
406
406
  if (s.running) {
@@ -4214,7 +4214,13 @@ async function runAgentChatJob(job) {
4214
4214
  const input = JSON.stringify({
4215
4215
  messages: payload?.messages ?? [],
4216
4216
  agentBaseURL,
4217
- agentApiKey: payload?.agentApiKey ?? "",
4217
+ // NO AGENT MODEL SET (GH #397): the API points the chat calls at the coding model and sends
4218
+ // the placeholder key "local". Chat calls ("hi", the plain-reply fallback, routing) must
4219
+ // carry the CODER's real key, or a hosted provider answers 401 while coding turns work.
4220
+ agentApiKey:
4221
+ payload?.agentMissing === true && codingApiKey
4222
+ ? codingApiKey
4223
+ : (payload?.agentApiKey ?? ""),
4218
4224
  agentModelId,
4219
4225
  // Task #134: on a CLIENT the RAG embedder lives on the model machine, so "run doctor
4220
4226
  // here" is advice that cannot work. The agent uses this to say the right thing.
package/gu-repl.mjs CHANGED
@@ -126,7 +126,6 @@ import {
126
126
  nothingToAnswerWith,
127
127
  NO_MODEL_LINES,
128
128
  resizeRepaintTarget,
129
- workspaceSyncQuestion,
130
129
  workspaceAccessQuestion,
131
130
  workspaceSyncLabel,
132
131
  activeCoderName,
@@ -1052,9 +1051,8 @@ const COMMANDS = [
1052
1051
  // The two synonyms were dispatched but declared nowhere (found by slash-command-wiring.test.mjs):
1053
1052
  // they worked, Tab-completed to nothing, and appeared in no help. Aliases is what the registry
1054
1053
  // already has for exactly this — /quit, /rag-local.
1055
- { name: "/host-mode", desc: "undo client mode — run agent turns on THIS machine again" },
1054
+ // /host-mode is gone (owner, 11-07): modes are chosen at setup; `gu clear` sets up again.
1056
1055
  // /undo is HIDDEN_COMMANDS now — still works when typed.
1057
- { name: "/server", desc: "pick a deployment server (host/user) for this session" },
1058
1056
  { name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
1059
1057
  { name: "/s3", usage: "/s3 [test]", desc: "set up YOUR S3 bucket (RAG index, and the PDFs/downloads gu makes) and check the AWS keys work — without it, files are saved on this computer" },
1060
1058
  { name: "/context-window", usage: "/context-window [tokens|auto]", desc: "tell gu how big the coding model's context is — hosted models cannot be probed" },
@@ -2720,6 +2718,11 @@ let cliNoticeShown = false;
2720
2718
  function announceCliUpdate() {
2721
2719
  if (cliNoticeShown || !availableUpdates.cli) return;
2722
2720
  if (following) return; // a turn owns the transcript — try again at the next prompt
2721
+ // …and so does an open menu or question (GH #397): printed into a picker's in-place block, the
2722
+ // notice pushed it down and the picker could no longer erase itself — the rows of a menu left
2723
+ // closed stayed on screen. Retried at the next prompt, like a turn.
2724
+ // A slash command still running hides the input row (inputVisible): its output is not done.
2725
+ if (listPickerActive || askWaiting || !inputVisible) return;
2723
2726
  let lines = [];
2724
2727
  try {
2725
2728
  lines = repeatUpdateNotice({ last: readUpdateAttempt(), cli: availableUpdates.cli, running: REPL_VERSION,
@@ -5244,7 +5247,7 @@ const ask = async (q, { prefill = "", hint = [], atomic = false, noCancel = fals
5244
5247
  // Masked prompt for a secret (an API key). Mutes readline's echo/refresh (secretInput)
5245
5248
  // so the typed value never hits the terminal or scrollback (task #127). Returns "" on a
5246
5249
  // bare Enter (skip).
5247
- const askSecret = async (q) => {
5250
+ const askSecret = async (q, { hint = [] } = {}) => {
5248
5251
  // Same rule as ask(): input has ended, so stop. Returning "" here would hand an EMPTY
5249
5252
  // secret to a caller that is about to store or send it.
5250
5253
  if (_stdinClosed) return await ask("");
@@ -5255,6 +5258,9 @@ const askSecret = async (q) => {
5255
5258
  // FOR. With the bar pinned the row itself becomes the prompt, so nothing is left to infer.
5256
5259
  secretPrompt = String(q ?? "").replace(/\s+$/, "") + " ";
5257
5260
  secretInput = true;
5261
+ // A hint UNDER the input, gone once answered (GH #397) — printed above, "type or paste the
5262
+ // key…" stayed in the history after the key was in, as if it were still being asked.
5263
+ askHint = barPinned() ? hint : [];
5258
5264
  // Same reason as ask() above: inputLineFor returns "" when inputVisible is false, so the one
5259
5265
  // bullet per character — the only proof a keystroke registered while the echo is muted —
5260
5266
  // would not be drawn either. askSecret is reached from /model, which is exactly a command
@@ -5264,7 +5270,10 @@ const askSecret = async (q) => {
5264
5270
  // Unpinned (piped output, or a window too short to reserve rows) there IS no frozen row, so
5265
5271
  // the label has to be printed the old way or the prompt is silent.
5266
5272
  if (barPinned()) paintBar();
5267
- else process.stdout.write(q);
5273
+ else {
5274
+ for (const l of hint) console.log(GUTTER + dim(l));
5275
+ process.stdout.write(q);
5276
+ }
5268
5277
  let raw = "";
5269
5278
  try {
5270
5279
  askWaiting = true;
@@ -5277,6 +5286,7 @@ const askSecret = async (q) => {
5277
5286
  askWaiting = false;
5278
5287
  secretInput = false;
5279
5288
  secretPrompt = "";
5289
+ askHint = [];
5280
5290
  inputVisible = _secretWasVisible;
5281
5291
  // READLINE KEEPS EVERY SUBMITTED LINE (task #191). Muting the echo stops the key reaching
5282
5292
  // the screen; it does nothing about rl.history, which is where the line goes on Enter — so
@@ -5343,10 +5353,10 @@ async function ensureWorkspace() {
5343
5353
  // Registered before cloud sync existed → ask once and persist. Otherwise honor the
5344
5354
  // stored choice. (undefined = old record that never opted in → ask.)
5345
5355
  if (hit.allowSync === undefined) {
5346
- const q = workspaceSyncQuestion({ clientExpert: isClientExpert() });
5347
- hit.allowSync = q.ask
5348
- ? await askYesNo("Allow syncing this workspace's chat history to the cloud?", true)
5349
- : q.value;
5356
+ // No question (owner, 11-07): the mode chosen at setup decides where history goes —
5357
+ // the cloud on a hosted account, the local database on Client Expert. A folder that
5358
+ // answered No before keeps that answer; only a folder never asked is filled in.
5359
+ hit.allowSync = true;
5350
5360
  await writeFile(WS_FILE, JSON.stringify({ workspaces: list }, null, 2) + "\n");
5351
5361
  }
5352
5362
  // The workspace facts now live in the startup banner (task #131) — just remember them.
@@ -5380,16 +5390,10 @@ async function ensureWorkspace() {
5380
5390
  } catch (e) {
5381
5391
  console.log(GUTTER + yellow(`Could not save the browser permission (${e?.message || e}) — gu will ask when it first needs it.`));
5382
5392
  }
5383
- // On Client Expert there is no cloud to sync TO — see workspaceSyncQuestion.
5384
- const syncQ = workspaceSyncQuestion({ clientExpert: isClientExpert() });
5385
- const allowSync = syncQ.ask
5386
- ? await askYesNo(
5387
- "Allow syncing this workspace's chat history to the cloud?\n" +
5388
- "(your conversation is saved to your account so you can see it on the web app)",
5389
- true /* default Yes — RAG is NOT synced (it stays on your S3); only chat history. */
5390
- )
5391
- : syncQ.value;
5392
- if (!syncQ.ask && syncQ.note) console.log(wrapGutter(syncQ.note, dim));
5393
+ // NO SYNC QUESTION (owner, 11-07): Cloud or Client Expert is chosen once, at setup, and it
5394
+ // decides where chat history lives — your account's cloud database, or the local one. To
5395
+ // change it, `gu clear` and set up again. RAG is never synced (it stays where it was built).
5396
+ const allowSync = true;
5393
5397
  const next = list.filter((w) => w.path !== cwd);
5394
5398
  next.push({
5395
5399
  name: basename(cwd),
@@ -6297,10 +6301,12 @@ async function pickExtraModels(rest0, preChecked = []) {
6297
6301
  const already = new Set(preChecked);
6298
6302
  const { codingModelCandidates } = await import("./coding-provider-setup.mjs");
6299
6303
  const keep = new Set(codingModelCandidates(rest0).models);
6300
- const rest = rest0.filter((m) => keep.has(m) || already.has(m));
6304
+ const rest = rest0
6305
+ .filter((m) => keep.has(m) || already.has(m))
6306
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true })); // as the pick above (GH #397)
6301
6307
  if (rest.length === 0) return [];
6302
- console.log(dim(` Also offer these in /model? (up to ${MAX_ALT_CODERS})`));
6303
- console.log(dim(` ${PICK_MANY_HINT}`));
6308
+ console.log(dim(` Any other models to switch to in /model? optional · up to ${MAX_ALT_CODERS}`));
6309
+ console.log(dim(` space ticks · Enter saves (nothing ticked skips this) · Esc cancels`));
6304
6310
  const ticks = await pickManyFromList(
6305
6311
  rest,
6306
6312
  rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
@@ -6328,7 +6334,12 @@ async function pickExtraModels(rest0, preChecked = []) {
6328
6334
  * Returns the model id, or null when the user cancelled.
6329
6335
  */
6330
6336
  async function chooseCodingModelFrom(ps, all, question, current, { back = false } = {}) {
6331
- const { models: coders, filtered } = ps.codingModelCandidates(all);
6337
+ const { models: coders0, filtered } = ps.codingModelCandidates(all);
6338
+ // SORTED, like /agent-model's list (GH #397): the provider's own order is arbitrary, and a
6339
+ // family (qwen3.8-*, deepseek-v4-*) scattered over the list is hard to scan.
6340
+ const byName = (a, b) => a.localeCompare(b, "en", { numeric: true });
6341
+ const coders = [...coders0].sort(byName);
6342
+ all = [...all].sort(byName);
6332
6343
  let list = coders;
6333
6344
  let showingAll = filtered === 0;
6334
6345
  for (;;) {
@@ -6372,8 +6383,9 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
6372
6383
  * stays available for the same reason it does there — a listed model may be unusable and an
6373
6384
  * unlisted one may be exactly right.
6374
6385
  */
6375
- async function chooseFromModelList(all, question, current = 0, { back = false } = {}) {
6376
- const rows = [...all, "type a model name…"];
6386
+ async function chooseFromModelList(all, question, current = 0, { back = false, notes = null } = {}) {
6387
+ // `notes` (GH #397): a dim note per row ("an audio/speech model"), as /agent-model's own list has.
6388
+ const rows = [...all.map((m, i) => (notes?.[i] ? `${m}${dim(` · ${notes[i]}`)}` : m)), "type a model name…"];
6377
6389
  const at = typeof current === "string" ? all.indexOf(current) : Number(current) || 0;
6378
6390
  const idx = await pickFromList(rows, at >= 0 ? at : 0, {
6379
6391
  back,
@@ -6390,6 +6402,21 @@ async function chooseFromModelList(all, question, current = 0, { back = false }
6390
6402
  return all[idx];
6391
6403
  }
6392
6404
 
6405
+ /**
6406
+ * The agent's pick from a fresh provider (GH #397): the SAME order and labels as /agent-model's
6407
+ * "pick another model on <host>…" — chat models alphabetically, then audio/image/video models
6408
+ * sunk to the bottom and named. The add flow showed them mixed in and unlabelled.
6409
+ */
6410
+ async function pickAgentModelFrom(models, question, current, opts) {
6411
+ const am = await import("./agent-model-command.mjs");
6412
+ const sorted = am.agentModelsFromProvider(models);
6413
+ const ids = sorted.map((m) => m.model);
6414
+ return chooseFromModelList(ids, question, Math.max(0, ids.indexOf(current)), {
6415
+ ...opts,
6416
+ notes: sorted.map((m) => m.note),
6417
+ });
6418
+ }
6419
+
6393
6420
  /**
6394
6421
  * /model's provider manager. `back` (#242): opened from /model's list, so its first menu offers
6395
6422
  * "← back" and returns BACK. Inside it, each sub-menu's "← back" returns HERE — re-read, because a
@@ -6750,6 +6777,7 @@ async function removeCodingProvider(ps, rows, p) {
6750
6777
  * newKey ask for a key even if the server has one (the stored one was refused)
6751
6778
  */
6752
6779
  async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false, back = false } = {}) {
6780
+ let claudeSdkLater = false; // offered after the save, see step 1
6753
6781
  const as = await import("./add-server.mjs");
6754
6782
  const ps = await import("./coding-provider-setup.mjs");
6755
6783
  const sk = await import("./server-keys.mjs");
@@ -6802,7 +6830,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6802
6830
  const OLLAMA_HERE = 0;
6803
6831
  const rowsShown = [
6804
6832
  `Ollama — this machine or your network…${dim(" · e.g. 127.0.0.1:11434")}`,
6805
- ...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
6833
+ // The HOST, not the full URL (GH #397): "…/compatible-mode/v1" ran past 100 columns and was
6834
+ // cut mid-word; the path is plumbing, and the URL is echoed in full once one is picked.
6835
+ ...presets.map((p) => `${p.name}${dim(` · ${hostOf(p.url)}`)}`),
6806
6836
  "another URL…",
6807
6837
  ];
6808
6838
  // A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
@@ -6857,10 +6887,10 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6857
6887
  console.log("");
6858
6888
  return;
6859
6889
  }
6860
- // Claude as the CODER: prompt caching needs Anthropic's SDK (#262) — offer it once, here.
6861
- if (target.claude && role === "coding") {
6862
- await md.ensureClaudeSdk({ log: (l) => console.log(GUTTER + dim(l.trim())), confirm: (q) => askYesNo(` ${q}`, true) });
6863
- }
6890
+ // Claude as the CODER: prompt caching needs Anthropic's SDK (#262). Offered AFTER the key and the
6891
+ // model are saved (GH #397): asked first, it interrupted the flow before the user had even been
6892
+ // asked for a key, and dumped pip's raw output over the screen.
6893
+ claudeSdkLater = Boolean(target.claude && role === "coding");
6864
6894
 
6865
6895
  // --- 2. the server's key --------------------------------------------------------------
6866
6896
  // AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
@@ -6968,7 +6998,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6968
6998
  const canBack = { back: providerPicker || back };
6969
6999
  model = R.filterCoders
6970
7000
  ? await chooseCodingModelFrom(ps, models, R.question, 0, canBack)
6971
- : await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)), canBack);
7001
+ : role === "agent"
7002
+ ? await pickAgentModelFrom(models, R.question, current, canBack)
7003
+ : await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)), canBack);
6972
7004
  if (model === BACK) {
6973
7005
  if (providerPicker) continue; // → Which provider?
6974
7006
  return BACK;
@@ -7083,7 +7115,16 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
7083
7115
  }
7084
7116
  return;
7085
7117
  }
7086
- await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName });
7118
+ await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName, extras });
7119
+ if (claudeSdkLater) {
7120
+ // pip's own lines ("Requirement already satisfied…", "Collecting…") are noise here: keep gu's.
7121
+ const pipNoise = /^(Requirement already satisfied|Collecting|Downloading|Using cached|Installing collected|Successfully installed|Obtaining|Preparing|Building|Created wheel|Stored in directory|WARNING|\[notice\]|\s*[━╸─]+)/i;
7122
+ await md.ensureClaudeSdk({
7123
+ log: (l) => { const t = String(l ?? "").trim(); if (t && !pipNoise.test(t)) console.log(GUTTER + dim(t)); },
7124
+ confirm: (q) => askYesNo(` ${q}`, true),
7125
+ });
7126
+ console.log("");
7127
+ }
7087
7128
  }
7088
7129
 
7089
7130
  /**
@@ -7096,7 +7137,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
7096
7137
  * · DID SAVING EVICT ANOTHER PROVIDER? An API older than row ids keys a backend by its KIND,
7097
7138
  * so a second openai row replaced the first with a 200. Said BEFORE the success line.
7098
7139
  */
7099
- async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName }) {
7140
+ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName, extras = [] }) {
7100
7141
  const cwd = resolve(process.cwd());
7101
7142
  const after = await withPendingStatus("checking it is offered", () =>
7102
7143
  fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null)
@@ -7145,15 +7186,23 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
7145
7186
  // that was just entered, which reads as the save having failed.
7146
7187
  codingKeyMissingKnown = false;
7147
7188
  console.log(accent(` ✓ coding model → ${model}`) + dim(` · ${host}`));
7148
- if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key\n`));
7189
+ if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)}`));
7190
+ // The extras were ticked a screen ago and never mentioned again (GH #397) — say where they went.
7191
+ if (extras.length) console.log(dim(` also in /model: ${extras.join(", ")}`));
7192
+ console.log("");
7149
7193
  // A HOSTED SERVER NEEDS ITS WINDOW SET: with nothing set gu assumes 24,000 prompt tokens and
7150
7194
  // compacts there — on a large hosted model that throws history away every turn.
7151
7195
  if (ps.shouldMentionContextWindow(locality)) {
7152
- console.log(GUTTER + dim("tip: /context-window — hosted servers do not report their size,"));
7153
- console.log(GUTTER + dim(" so gu assumes 24,000 prompt tokens and compacts there.\n"));
7196
+ console.log(GUTTER + dim("tip: set this model's context size with /context-window —"));
7197
+ console.log(GUTTER + dim(" hosted providers don't report it, so gu assumes 24k tokens.\n"));
7154
7198
  }
7155
7199
  const t = await fetchOutputTokenTotals(cwd);
7156
7200
  { const g = pickGlobalTotal(t); if (g !== null) globalOutputTokens = g; }
7201
+ // The bar still said "coding model not set · /model" under the success line of the FIRST model
7202
+ // added on a fresh install, until the next turn repainted it (GH #397).
7203
+ noModelAtAll = after?.payload ? nothingToAnswerWith(after.payload) : false;
7204
+ if (after?.payload) agentMissing = after.payload.agentMissing === true;
7205
+ if (barPinned()) paintBar();
7157
7206
  }
7158
7207
 
7159
7208
 
@@ -7258,7 +7307,7 @@ async function chooseModel() {
7258
7307
  // through to add when there is genuinely nothing there.
7259
7308
  // As the picker's header (GH #397), so "← back" re-opening this menu does not stack it.
7260
7309
  const go = await pickFromList(
7261
- [dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7310
+ [dim("leave it"), "add a provider — Claude API, OpenAI, Kimi, Ollama…"],
7262
7311
  1,
7263
7312
  { header: () => [dim(" No coding model configured.")], headerRows: 1 }
7264
7313
  );
@@ -7271,16 +7320,10 @@ async function chooseModel() {
7271
7320
  // provider manager is where another one is added (an Ollama box is "another URL…").
7272
7321
  // #412 removed "change the Ollama server this machine uses…" from all three menus: it re-asked
7273
7322
  // the agent, embedder and coder servers in one go, which confused people picking a model.
7274
- if (choices.length === 1) {
7275
- console.log(dim(` Only one coding model is available: ${choices[0].model}.`));
7276
- const go = await pickFromList(
7277
- [dim("keep it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7278
- 0
7279
- );
7280
- if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7281
- if (go !== 1) console.log("");
7282
- return;
7283
- }
7323
+ // GH #397: ONE model used to get its own screen ("Only one coding model is available: X ·
7324
+ // keep it / add or change a provider"). Set up from /agent-model first, that was the agent's
7325
+ // model — and the other 12 coders on the same provider were three menus away. The full menu
7326
+ // below already has "pick another model on <host>…", keys and add, so one model uses it too.
7284
7327
  const active = sessionCodingModel || def;
7285
7328
  // Arrow-key picker (↑/↓ + Enter) — pre-highlight the active model, tag the default, and
7286
7329
  // name the backend when more than one is in play (the same model name can appear twice).
@@ -7289,6 +7332,9 @@ async function chooseModel() {
7289
7332
  // OpenAI-compatible providers are both "OpenAI-compatible", so the kind names neither.
7290
7333
  const { labels, tags, showTag } = codingChoiceLabels(choices, def);
7291
7334
  const activeIdx = choices.findIndex((c) => c.id === active);
7335
+ // GH #397: "(default)" marked the account default while this folder used another model, and
7336
+ // nothing said which one runs here. The one in use is marked.
7337
+ if (activeIdx >= 0 && choices[activeIdx].id !== def) labels[activeIdx] += accent(" (in use here)");
7292
7338
  // THE CURRENT CODER'S KEYS, ON THIS LIST (#248) — the manager ctrl+k opens, for a provider that
7293
7339
  // takes a key (OpenAI-compatible, ollama.com). Before the add row, so no model row moves.
7294
7340
  const activeChoice = choices[activeIdx >= 0 ? activeIdx : choices.findIndex((c) => c.id === def)];
@@ -9357,8 +9403,9 @@ async function askServerKey({ host = "", optional = false, escBack = false, name
9357
9403
  // A RETRY KEEPS THE NAME (GH #397): only the secret was wrong, so only the secret is asked.
9358
9404
  if (knownName === null) console.log("\n" + GUTTER + dim(escBack ? "Esc goes back to the providers." : "Esc to cancel."));
9359
9405
  const name = knownName === null ? (await askKeyName()).trim() : String(knownName);
9360
- console.log(GUTTER + dim(SECRET_KEY_HINT));
9361
- const secret = (await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `)).trim();
9406
+ const secret = (
9407
+ await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `, { hint: [SECRET_KEY_HINT] })
9408
+ ).trim();
9362
9409
  if (!secret) {
9363
9410
  console.log(GUTTER + dim(optional ? "(no key — the server will be called anonymously)" : "(cancelled — nothing was saved)\n"));
9364
9411
  return { name, secret: "" };
@@ -9446,19 +9493,38 @@ async function chooseAgentModel() {
9446
9493
  const source = am.agentListSource(settings);
9447
9494
  const needsKey = am.agentSourceNeedsKey(source);
9448
9495
  for (;;) {
9449
- const labels = saved.map((c) => c.label + (c.current ? accent(" (current)") : ""));
9496
+ // The first row is the default and the way back to ONE model: the coding model talks too (GH #397).
9497
+ const labels = [
9498
+ `same as the coding model${dim(" — follows /model")}` + (current ? "" : accent(" (current)")),
9499
+ ...saved.map((c) => c.label + (c.current ? accent(" (current)") : "")),
9500
+ ];
9450
9501
  const MODELS = curHost ? labels.length : -1;
9451
9502
  if (curHost) labels.push(actionRow(`pick another model on ${curHost}…`));
9503
+ // NO SEPARATE AGENT MODEL YET: offer the coder's own server, so a talking model from the provider
9504
+ // already set up is one pick away — its saved key is reused (GH #397).
9505
+ const coderUrl = (await import("./coding-provider-setup.mjs")).ensureV1Root(String(sessionCodingUrl || defaultCodingUrl || "")).toLowerCase();
9506
+ const coderRow = curHost ? null : (saved.find((c) => c.url.toLowerCase() === coderUrl) ?? null);
9507
+ const CODER_MODELS = coderRow ? labels.length : -1;
9508
+ if (coderRow) labels.push(actionRow(`pick another model on ${coderRow.host}…`));
9452
9509
  const KEYS = curHost && needsKey ? labels.length : -1;
9453
9510
  if (KEYS >= 0) labels.push(actionRow(`API keys for ${curHost} — switch, add or remove…`));
9454
9511
  const ADD = labels.length;
9455
9512
  labels.push(actionRow("add a provider — Claude API, OpenAI, Kimi, Ollama…"));
9456
9513
  // The hint drops to its own row when the header would wrap (80 columns over SSH).
9457
- const head = ` Agent model — the one that TALKS (now ${current || "unset"}${curHost ? ` on ${curHost}` : ""})`;
9514
+ // GH #397: "(now unset)" hid that the coding model answers in the meantime; and printed
9515
+ // before the list, this title stacked each time "← back" re-opened the menu.
9516
+ const now = current
9517
+ ? `now ${current}${curHost ? ` on ${curHost}` : ""}`
9518
+ : "same as the coding model";
9519
+ const head = ` Agent model — the one that TALKS (${now})`;
9458
9520
  const hint = `(${PICK_ARROWS}):`;
9459
9521
  const cols = Math.max(20, process.stdout.columns || 80);
9460
- console.log(dim(head.length + 2 + hint.length < cols ? `${head} ${hint}` : `${head}\n ${hint}`));
9461
- const idx = await pickFromList(labels, Math.max(0, saved.findIndex((c) => c.current)), { footer: ESC_FOOTER });
9522
+ const headLines = head.length + 2 + hint.length < cols ? [`${head} ${hint}`] : [head, ` ${hint}`];
9523
+ const idx = await pickFromList(labels, saved.findIndex((c) => c.current) + 1, {
9524
+ footer: ESC_FOOTER,
9525
+ header: () => headLines.map((l) => dim(l)),
9526
+ headerRows: headLines.length,
9527
+ });
9462
9528
  if (idx < 0) {
9463
9529
  console.log(dim(" (cancelled)\n"));
9464
9530
  return;
@@ -9468,6 +9534,11 @@ async function chooseAgentModel() {
9468
9534
  if ((await agentModelOnServer({ back: true, start: "models" })) === BACK) continue;
9469
9535
  return;
9470
9536
  }
9537
+ if (idx === CODER_MODELS) {
9538
+ const dialect = coderRow.kind === "ollama" ? "ollama" : "openai";
9539
+ if ((await addServer({ role: "agent", dialect, url: coderRow.url, back: true })) === BACK) continue;
9540
+ return;
9541
+ }
9471
9542
  if (idx === KEYS) {
9472
9543
  const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
9473
9544
  if (!rowId) return enterAgentProviderKey(source.row, curHost);
@@ -9478,7 +9549,12 @@ async function chooseAgentModel() {
9478
9549
  if ((await addServer({ role: "agent", dialect: "openai", back: true })) === BACK) continue;
9479
9550
  return;
9480
9551
  }
9481
- await useSavedAgentChoice(saved[idx]);
9552
+ if (idx === 0) {
9553
+ if (!current) console.log(dim(" already the same as the coding model — nothing changed.\n"));
9554
+ else await useCoderForAgent();
9555
+ return;
9556
+ }
9557
+ await useSavedAgentChoice(saved[idx - 1]);
9482
9558
  return;
9483
9559
  }
9484
9560
  }
@@ -9488,6 +9564,31 @@ async function chooseAgentModel() {
9488
9564
  * API finds the key by the URL (resolveKeyForRole), so nothing is re-entered — unless the server
9489
9565
  * needs a key and has none, which is asked here rather than failing on the next question.
9490
9566
  */
9567
+ /**
9568
+ * "Same as the coding model" (GH #397): clear the separate agent model, so the coding model talks
9569
+ * too — and keeps following each folder's /model pick. The way BACK to one model: before this the
9570
+ * only route was removing the whole provider (and its key).
9571
+ */
9572
+ async function useCoderForAgent() {
9573
+ const md = await import("./model-doctor.mjs");
9574
+ const saved = await withPendingStatus("saving", () =>
9575
+ md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: "", agentModelName: "", agentModel: "" })
9576
+ );
9577
+ if (!saved) {
9578
+ console.log(red(" couldn't save that — nothing changed.\n"));
9579
+ return;
9580
+ }
9581
+ if (sessionAgentKeyId) await saveFolderKeyPick("agent", "");
9582
+ console.log(accent(" ✓ agent model → same as the coding model") + dim(" · /model switches both"));
9583
+ console.log(GUTTER + dim("takes effect on your next question.\n"));
9584
+ const after = (await fetchAgentPayload([{ role: "user", content: "." }]).catch(() => null))?.payload;
9585
+ if (after) {
9586
+ agentMissing = after.agentMissing === true;
9587
+ noModelAtAll = nothingToAnswerWith(after);
9588
+ }
9589
+ if (barPinned()) paintBar();
9590
+ }
9591
+
9491
9592
  async function useSavedAgentChoice(c) {
9492
9593
  if (c.current) {
9493
9594
  console.log(dim(` already using ${c.model} on ${c.host} — nothing changed.\n`));
@@ -9725,39 +9826,6 @@ async function agentModelOnServer({ back = false, start = "menu" } = {}) {
9725
9826
  console.log(dim(" takes effect on your next question.\n"));
9726
9827
  }
9727
9828
 
9728
- // Probe whether SSH KEY auth already works for a server (so we can tell the user to run
9729
- // ssh-copy-id if not). BatchMode=yes = never prompt for a password → exit!=0 if no key.
9730
- async function checkServerKeyAuth(s) {
9731
- try {
9732
- const ok = await new Promise((resolve) => {
9733
- const p = spawn(
9734
- "ssh",
9735
- [
9736
- "-o", "BatchMode=yes",
9737
- "-o", "ConnectTimeout=6",
9738
- "-o", "StrictHostKeyChecking=accept-new",
9739
- `${s.user}@${s.host}`,
9740
- "true",
9741
- ],
9742
- { stdio: "ignore" }
9743
- );
9744
- p.on("exit", (code) => resolve(code === 0));
9745
- p.on("error", () => resolve(false));
9746
- });
9747
- if (ok) console.log(dim(" ✓ key auth works — deploys will connect without a password."));
9748
- else
9749
- console.log(
9750
- yellow(" key auth not set up yet.") +
9751
- dim(` Run once in your terminal: `) +
9752
- `ssh-copy-id ${s.user}@${s.host}`
9753
- );
9754
- } catch {
9755
- /* best-effort check — never block the picker */
9756
- }
9757
- }
9758
-
9759
- // /server — pick a deployment target (from the web app's Servers registry) for this
9760
- // session. Host/user only, never a secret; the agent deploys here with KEY auth (#69).
9761
9829
  /**
9762
9830
  * `/proxy` — configure the outbound proxy for MODEL calls (task #160).
9763
9831
  *
@@ -10146,53 +10214,6 @@ async function runProxyCommand(line) {
10146
10214
  }
10147
10215
  }
10148
10216
 
10149
- async function chooseServer() {
10150
- let servers;
10151
- try {
10152
- const res = await withPendingStatus("loading servers", () =>
10153
- fetch(`${apiBase}/api/worker/deploy-servers`, {
10154
- headers: { "X-Worker-Key": workerKey },
10155
- signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
10156
- })
10157
- );
10158
- const data = await res.json().catch(() => ({}));
10159
- if (!res.ok) {
10160
- console.log(red(` couldn't load servers: ${data?.error || `HTTP ${res.status}`}\n`));
10161
- return;
10162
- }
10163
- servers = Array.isArray(data?.servers) ? data.servers : [];
10164
- } catch (err) {
10165
- console.log(red(` ${describeApiFailure(err, "couldn't load servers")}\n`));
10166
- return;
10167
- }
10168
- if (servers.length === 0) {
10169
- console.log(dim(" No deployment servers yet — add them in the web app → Servers.\n"));
10170
- return;
10171
- }
10172
- // Arrow-key picker: ↑/↓ to move, Enter to choose (no number typing). The list ends with
10173
- // a "none" row to clear the selection. Pre-highlight the current pick if there is one.
10174
- const labels = servers.map((s) => `${s.name} (${s.user}@${s.host})`);
10175
- labels.push("none (clear selection)");
10176
- const activeIdx = servers.findIndex((s) => s.host === selectedServer?.host);
10177
- console.log(dim(` Choose a deployment server (${PICK_HINT}):`));
10178
- const idx = await pickFromList(labels, activeIdx >= 0 ? activeIdx : 0);
10179
- if (idx < 0) {
10180
- console.log(dim(" (cancelled)\n"));
10181
- return;
10182
- }
10183
- if (idx === servers.length) {
10184
- // the trailing "none" row
10185
- selectedServer = null;
10186
- console.log(dim(" ✓ deployment server cleared.\n"));
10187
- return;
10188
- }
10189
- const s = servers[idx];
10190
- selectedServer = { name: s.name, host: s.host, user: s.user };
10191
- console.log(accent(` ✓ deploy target → ${s.name} (${s.user}@${s.host})`));
10192
- await checkServerKeyAuth(selectedServer);
10193
- console.log("");
10194
- }
10195
-
10196
10217
  /**
10197
10218
  * Ask for the coding model's API key if the resolved coder needs one and none is stored.
10198
10219
  *
@@ -10921,8 +10942,7 @@ async function replaceCodingKey({ back = false } = {}) {
10921
10942
  (ks.stored ? dim("(replacing ") + accent(maskKey(ks.stored)) + dim(")") : dim("(none stored)")) +
10922
10943
  dim(" · Enter alone cancels, \"clear\" removes it\n")
10923
10944
  );
10924
- console.log(GUTTER + dim(SECRET_KEY_HINT));
10925
- const typed = (await askSecret(` ${ks.model} API key: `)).trim();
10945
+ const typed = (await askSecret(` ${ks.model} API key: `, { hint: [SECRET_KEY_HINT] })).trim();
10926
10946
  if (!typed) {
10927
10947
  console.log(GUTTER + dim("(cancelled — the stored key is unchanged)\n"));
10928
10948
  paintBar();
@@ -10976,7 +10996,9 @@ async function manageServerKeys({ role = "coding", backendId, host, model = "",
10976
10996
  });
10977
10997
  const ADD = rows.length;
10978
10998
  rows.push(dim("add a new key…"));
10979
- console.log("\n" + GUTTER + dim(`Keys for ${host} — ${folderLabel()}'s ${roleLabel}${model ? ` (${model})` : ""}\n`));
10999
+ // The agent model is a MACHINE setting (GH #397): "proj's agent model" named a folder it is not tied to.
11000
+ const whose = role === "agent" ? `the ${roleLabel}` : `${folderLabel()}'s ${roleLabel}`;
11001
+ console.log("\n" + GUTTER + dim(`Keys for ${host} — ${whose}${model ? ` (${model})` : ""}\n`));
10980
11002
  console.log(GUTTER + dim("Enter chooses · ctrl+d deletes the highlighted key\n"));
10981
11003
  const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true, back });
10982
11004
  const idx = picked?.index ?? -1;
@@ -11904,6 +11926,28 @@ function pickFromList(items, initialSel = 0, opts = {}) {
11904
11926
  listTimeoutIndex = -1;
11905
11927
  listHeader = typeof opts.header === "function" ? opts.header : null;
11906
11928
  listHeaderRows = listHeader ? Math.max(0, opts.headerRows | 0) : 0;
11929
+ // A STATIC HEADER WIDER THAN THE TERMINAL IS WRAPPED AT WORDS (GH #397), not clipped: on 80
11930
+ // columns "Which model on token-plan.maas.qwencloudapi.com? (↑/↓ then Enter · Esc to c" lost
11931
+ // its way out. Done once, here, so the block keeps the constant height its redraw walks
11932
+ // depend on. A live panel (refreshMs) is already sized by its caller and is left alone.
11933
+ if (listHeader && !(opts.refreshMs > 0)) {
11934
+ const w = Math.max(20, process.stdout.columns || 80) - 1;
11935
+ let given = [];
11936
+ try { given = (listHeader(listHeaderRows) || []).slice(0, listHeaderRows); } catch { /* blank */ }
11937
+ if (given.some((l) => visibleLen(l) > w)) {
11938
+ const wrapped = given.flatMap((l) => {
11939
+ if (visibleLen(l) <= w) return [l];
11940
+ const plain = stripAnsiEscapes(l);
11941
+ const lead = /^\s*/.exec(plain)[0];
11942
+ // The key hint " (↑/↓ then Enter · Esc …)" moves to its own line whole, before any word wrap.
11943
+ const at = plain.indexOf(" (", lead.length);
11944
+ const parts = at > 0 ? [plain.slice(lead.length, at), plain.slice(at + 2)] : [plain.slice(lead.length)];
11945
+ return parts.flatMap((p) => wrapIndented(p, { width: w, indent: lead, hang: "" })).map((r) => dim(r));
11946
+ });
11947
+ listHeader = () => wrapped;
11948
+ listHeaderRows = wrapped.length;
11949
+ }
11950
+ }
11907
11951
  if (listHeader && opts.refreshMs > 0) {
11908
11952
  // A live panel (#376): redrawn in place until the picker closes.
11909
11953
  const live = setInterval(() => {
@@ -12382,6 +12426,7 @@ async function localScaffoldState() {
12382
12426
  /** Print the startup scaffold line — deferred while a turn owns the transcript. */
12383
12427
  function announceScaffold() {
12384
12428
  if (!scaffoldNotice || following) return;
12429
+ if (listPickerActive || askWaiting || !inputVisible) return; // as announceCliUpdate (GH #397)
12385
12430
  const { kind, text } = scaffoldNotice;
12386
12431
  scaffoldNotice = null;
12387
12432
  if (kind === "link") {
@@ -14434,7 +14479,11 @@ async function main() {
14434
14479
  await import("./agent-model-label.mjs");
14435
14480
  // Skip the `ps` sweep entirely when settings already name the model — the overwhelmingly
14436
14481
  // common case, and startup is not the place to spend a subprocess proving it.
14437
- const agentLabel = namesAModel(p.agentModelId)
14482
+ // NO AGENT MODEL SET: the payload's agentModelId is only the account-default coder, which may
14483
+ // not be this folder's pick — naming it here said "kimi" over a folder running qwen (GH #397).
14484
+ const agentLabel = p.agentMissing === true
14485
+ ? { name: "", known: false, reason: "not-set" }
14486
+ : namesAModel(p.agentModelId)
14438
14487
  ? { name: String(p.agentModelId).trim(), known: true, reason: "settings" }
14439
14488
  : agentModelLabel({
14440
14489
  configuredId: p.agentModelId,
@@ -14695,7 +14744,7 @@ async function main() {
14695
14744
  dim(" Skipped — this machine won't SERVE models; the agent still runs here and\n") +
14696
14745
  dim(" calls your model machine over the network. Point the Agent model URL at\n") +
14697
14746
  dim(" that machine (not 127.0.0.1) — `gu status` shows what it resolves to.\n") +
14698
- dim(" `gu doctor` sets models up here · `/host-mode` undoes this.\n")
14747
+ dim(" `gu doctor` sets models up here · `gu clear` starts setup over.\n")
14699
14748
  );
14700
14749
  void st;
14701
14750
  }
@@ -15349,11 +15398,6 @@ async function main() {
15349
15398
  await syncEmbedWarning({ repair: false });
15350
15399
  continue;
15351
15400
  }
15352
- if (line === "/server") {
15353
- await chooseServer();
15354
- await saveSession(cwd, history); // remember the pick for this folder
15355
- continue;
15356
- }
15357
15401
  if (line === "/full-log" || line.startsWith("/full-log ")) {
15358
15402
  await runFullLogCommand(line);
15359
15403
  continue;
@@ -15437,18 +15481,6 @@ async function main() {
15437
15481
  );
15438
15482
  continue;
15439
15483
  }
15440
- if (line === "/host-mode") {
15441
- const ctl2 = await import("./daemon-control.mjs");
15442
- await ctl2.setClientMode(false);
15443
- await saveSessionSkip(cwd, "skipModelSetupPrompt", false);
15444
- await saveSessionSkip(cwd, "skipDaemonPrompt", false);
15445
- console.log(
15446
- accent("host mode") +
15447
- dim(" — this machine will run turns again. Start the worker with `gu start`, ") +
15448
- dim("and `gu doctor` to set up models here.\n")
15449
- );
15450
- continue;
15451
- }
15452
15484
  if (line === "/rag" || line === "/rag-cloud" || line === "/rag-local") {
15453
15485
  // ONE QUESTION, THREE ANSWERS (task #180). This was two commands that toggled one flag,
15454
15486
  // so the command you typed did not determine the state you got — and the reply named a
package/mongo-mac.mjs ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * MONGODB ON macOS 13 AND OLDER (GH #403).
3
+ *
4
+ * Homebrew's mongodb-community is 9.0, and every MongoDB from 8.0.6 on is built for macOS 14+
5
+ * (LC_BUILD_VERSION minos 14.0 — checked with otool on the 8.0.6 … 9.0.4 downloads). On macOS 13
6
+ * it installs fine and then cannot start: "dyld: Symbol not found … built for macOS 14.0".
7
+ * 8.0.5 is the last release built for older Macs (minos 10.14 on Intel, 11.0 on Apple Silicon).
8
+ *
9
+ * So on those Macs gu runs its own mongod 8.0.5, like the Windows one (#210): no Homebrew, no
10
+ * service, no sudo. Everything lives under ~/.gonext/mongodb and goes away with that folder:
11
+ * bin/mongod data/ mongod.log launch.log version.txt
12
+ *
13
+ * The .tgz is gzip, so unlike the Windows zip one file cannot be fetched on its own: the whole
14
+ * archive (~80-90 MB) is downloaded, checked against MongoDB's published SHA-256, and only
15
+ * bin/mongod is kept.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ import { execFile, execFileSync } from "node:child_process";
19
+ import { createWriteStream } from "node:fs";
20
+ import { mkdir, rename, rm, stat, writeFile, mkdtemp, chmod } from "node:fs/promises";
21
+ import { homedir, release } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ export const MONGO_MAC_OLD_VERSION = "8.0.5";
25
+ /** Homebrew's MongoDB (and every release after 8.0.5) needs this macOS or newer. */
26
+ export const MONGO_MAC_BREW_MIN_MAJOR = 14;
27
+
28
+ // From https://downloads.mongodb.org/full.json, and re-checked by downloading both (2026-11-07).
29
+ export const MONGO_MAC_DOWNLOADS = {
30
+ x64: {
31
+ url: `https://fastdl.mongodb.org/osx/mongodb-macos-x86_64-${MONGO_MAC_OLD_VERSION}.tgz`,
32
+ sha256: "eb528846663ef106003b3c491dfe3722f918cb75d056670b23a620b339f7f6ec",
33
+ },
34
+ arm64: {
35
+ url: `https://fastdl.mongodb.org/osx/mongodb-macos-arm64-${MONGO_MAC_OLD_VERSION}.tgz`,
36
+ sha256: "71e2510f0455ff24e1961aa0507eb0660b300c84f6da9899052be409090fb990",
37
+ },
38
+ };
39
+
40
+ /** "13.7.1" → 13. Darwin's kernel number is the fallback (Darwin 20-24 = macOS 11-15, 25 = 26). */
41
+ export function macosMajor({ productVersion = null, darwin = release(), platform = process.platform } = {}) {
42
+ if (productVersion == null && platform !== "darwin") return 0; // not a Mac: no answer, not a guess
43
+ let v = productVersion;
44
+ if (v == null) {
45
+ try { v = execFileSync("sw_vers", ["-productVersion"], { encoding: "utf8", timeout: 5000 }); } catch { v = ""; }
46
+ }
47
+ const m = /^(\d+)/.exec(String(v).trim());
48
+ if (m) return Number(m[1]);
49
+ const d = parseInt(String(darwin), 10);
50
+ if (!Number.isFinite(d)) return 0;
51
+ return d >= 25 ? d + 1 : d >= 20 ? d - 9 : 10;
52
+ }
53
+
54
+ /** Does this Mac need gu's own MongoDB? 0 (unknown) keeps Homebrew — the path that worked before. */
55
+ export function macNeedsOwnMongo(major) {
56
+ return major > 0 && major < MONGO_MAC_BREW_MIN_MAJOR;
57
+ }
58
+
59
+ /** Where gu keeps its own mongod on a Mac — the same layout as Windows. `home` for tests. */
60
+ export function mongoMacPaths(home = homedir()) {
61
+ const dir = join(home, ".gonext", "mongodb");
62
+ return {
63
+ dir,
64
+ exe: join(dir, "bin", "mongod"),
65
+ data: join(dir, "data"),
66
+ log: join(dir, "mongod.log"),
67
+ launchLog: join(dir, "launch.log"),
68
+ version: join(dir, "version.txt"),
69
+ };
70
+ }
71
+
72
+ /** Is gu's own mongod there? */
73
+ export async function macMongoInstalled(paths = mongoMacPaths()) {
74
+ try { return (await stat(paths.exe)).isFile(); } catch { return false; }
75
+ }
76
+
77
+ /** The download for this CPU, or null for one MongoDB does not build. */
78
+ export function macMongoDownload(arch = process.arch) {
79
+ return MONGO_MAC_DOWNLOADS[arch] ?? null;
80
+ }
81
+
82
+ /**
83
+ * Download the archive, check its SHA-256, keep only bin/mongod. → { bytes }.
84
+ * Throws a plain reason; nothing half-written is left at paths.exe.
85
+ * `fetchFn` and `extract` are injectable so tests never touch the network or tar.
86
+ */
87
+ export async function downloadMongodMac({
88
+ paths = mongoMacPaths(),
89
+ arch = process.arch,
90
+ fetchFn = fetch,
91
+ extract = extractMongod,
92
+ log = () => {},
93
+ version = MONGO_MAC_OLD_VERSION,
94
+ dl = macMongoDownload(arch),
95
+ } = {}) {
96
+ if (!dl) throw new Error(`MongoDB has no macOS build for ${arch}`);
97
+ await mkdir(paths.dir, { recursive: true });
98
+ const tgz = join(paths.dir, "mongodb.tgz.part");
99
+ const res = await fetchFn(dl.url);
100
+ if (!res.ok || !res.body) throw new Error(`download failed (HTTP ${res.status})`);
101
+ const total = Number(res.headers?.get?.("content-length")) || 0;
102
+ const mb = (n) => (n / 1048576).toFixed(0);
103
+ const hash = createHash("sha256");
104
+ const out = createWriteStream(tgz);
105
+ let got = 0;
106
+ let shown = -1;
107
+ try {
108
+ const reader = res.body.getReader();
109
+ for (;;) {
110
+ const { done, value } = await reader.read();
111
+ if (done) break;
112
+ hash.update(value);
113
+ got += value.length;
114
+ if (!out.write(value)) await new Promise((r) => out.once("drain", r));
115
+ if (total) {
116
+ const pct = Math.floor((got / total) * 10) * 10;
117
+ if (pct !== shown) { shown = pct; log(` … ${pct}% (${mb(got)} of ${mb(total)} MB)`); }
118
+ }
119
+ }
120
+ } finally {
121
+ await new Promise((r) => out.end(r));
122
+ }
123
+ try {
124
+ if (hash.digest("hex") !== dl.sha256) throw new Error("the download is damaged (SHA-256 does not match) — run gu again");
125
+ const tmp = await mkdtemp(join(paths.dir, "unpack-"));
126
+ try {
127
+ const mongod = await extract(tgz, tmp);
128
+ await mkdir(join(paths.dir, "bin"), { recursive: true });
129
+ await rm(paths.exe, { force: true });
130
+ await rename(mongod, paths.exe);
131
+ await chmod(paths.exe, 0o755);
132
+ } finally {
133
+ await rm(tmp, { recursive: true, force: true });
134
+ }
135
+ } finally {
136
+ await rm(tgz, { force: true });
137
+ }
138
+ await writeFile(paths.version, `${version}\n`);
139
+ return { bytes: got };
140
+ }
141
+
142
+ /** tar -x of only the archive's bin/mongod into `dir` → the extracted file's path. */
143
+ export function extractMongod(tgz, dir) {
144
+ return new Promise((resolve, reject) => {
145
+ execFile("tar", ["-xzf", tgz, "-C", dir, "--strip-components", "2", "*/bin/mongod"], { timeout: 300000 }, (err, _o, stderr) => {
146
+ if (err) reject(new Error(`could not unpack the archive: ${String(stderr || err.message).trim().split("\n").pop()}`));
147
+ else resolve(join(dir, "mongod"));
148
+ });
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Can this mongod run here at all? `mongod --version` stops at once with a dyld error when the
154
+ * build is for a newer macOS — the failure this module exists for — so ask it before starting it
155
+ * detached, where that would only show up as "not answering". → { ok, why }.
156
+ */
157
+ export function mongodRunsMac(exe, { execFn = execFile, timeoutMs = 20000 } = {}) {
158
+ return new Promise((resolve) => {
159
+ execFn(exe, ["--version"], { timeout: timeoutMs }, (err, stdout, stderr) => {
160
+ if (!err) return resolve({ ok: true, why: "" });
161
+ const text = `${stderr || ""}\n${stdout || ""}`;
162
+ const newer = /built for macOS ([\d.]+) which is newer/.exec(text);
163
+ resolve({ ok: false, why: newer ? `it is built for macOS ${newer[1]} or newer` : (text.trim().split("\n")[0] || String(err.message)) });
164
+ });
165
+ });
166
+ }
package/node-version.mjs CHANGED
@@ -12,6 +12,9 @@
12
12
  *
13
13
  * Checked at the TOP of both entry points, before any import that might itself fail.
14
14
  */
15
+ import { readdirSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
15
18
  export const MIN_NODE_MAJOR = 18;
16
19
 
17
20
  /** The message for a too-old runtime, or "" when this one is fine. Pure, so it is testable. */
@@ -52,11 +55,30 @@ export function nodeAtLeast(version, min) {
52
55
  return true;
53
56
  }
54
57
 
58
+ /**
59
+ * The Node versions nvm has installed when THIS node is one of nvm's, else []. Found on the 2017
60
+ * MacBook Pro (GH #406): nvm's default was 18 while 22 was already installed, and "install a new
61
+ * Node" was the wrong advice — one `nvm alias default` was the whole fix.
62
+ */
63
+ export function nvmNodeVersions({ env = process.env, execPath = process.execPath, list = null } = {}) {
64
+ const dir = env.NVM_DIR;
65
+ if (!dir || !String(execPath).startsWith(dir)) {return [];}
66
+ let names = list;
67
+ if (!names) {
68
+ try { names = readdirSync(join(dir, "versions", "node")); } catch { return []; }
69
+ }
70
+ return names.filter((n) => /^v\d+\.\d+\.\d+$/.test(n));
71
+ }
72
+
55
73
  /** 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) {
74
+ export function localModeNodeProblem(version = process.version, platform = process.platform, { nvmVersions = [] } = {}) {
57
75
  if (nodeAtLeast(version, LOCAL_API_MIN_NODE)) {return "";}
58
- const how =
59
- platform === "win32"
76
+ // nvm already has a new enough Node: switch to it, nothing to install.
77
+ const ready = nvmVersions.filter((v) => nodeAtLeast(v, LOCAL_API_MIN_NODE))
78
+ .sort((a, b) => (nodeAtLeast(a, b) ? -1 : 1))[0];
79
+ const how = ready
80
+ ? [` nvm already has ${ready}. Make it the default, then open a new terminal:`, ` nvm alias default ${ready.replace(/^v/, "")}`]
81
+ : platform === "win32"
60
82
  ? [" winget install OpenJS.NodeJS.LTS (or: nvm install lts with nvm-windows)"]
61
83
  : platform === "darwin"
62
84
  ? [" brew install node (or: nvm install --lts)"]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.897",
4
- "description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama or any OpenAI-compatible provider). Free installs run entirely on your machine. macOS, Linux and Windows.",
3
+ "version": "1.0.899",
4
+ "description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama or any OpenAI-compatible provider). Free installs keep the agent, its API and its database on your machine. macOS, Linux and Windows.",
5
5
  "keywords": [
6
6
  "ai",
7
7
  "coding-agent",
@@ -74,6 +74,7 @@
74
74
  "turn-stop.mjs",
75
75
  "workspace-revert.mjs",
76
76
  "mongo-native.mjs",
77
+ "mongo-mac.mjs",
77
78
  "mongo-windows.mjs",
78
79
  "python-windows.mjs",
79
80
  "terminal-hover.mjs",
package/server-keys.mjs CHANGED
@@ -49,7 +49,6 @@ export function keyTags(k, { folderKeyId = "", inUseId = "" } = {}) {
49
49
  if (k.id === folderKeyId) tags.push("this folder");
50
50
  else if (k.id === inUseId) tags.push("in use");
51
51
  if (k.default) tags.push("default");
52
- if (k.first) tags.push("first key");
53
52
  if (!k.backendId) tags.push("not tied to a server yet");
54
53
  return tags;
55
54
  }
@@ -2790,29 +2790,6 @@ export function resizeRepaintTarget({ barPinned, modal, busy, scrolledBack }) {
2790
2790
  return scrolledBack ? "scrollback" : "live";
2791
2791
  }
2792
2792
 
2793
- /**
2794
- * Should the workspace "sync chat history to the cloud?" question be ASKED? (task #154)
2795
- *
2796
- * Not on a Client Expert machine. There is no cloud there: the API and the database are on
2797
- * this box, "your account" is a local row, and there is no web app to see it on — so every
2798
- * clause of the question is false. Asking a question whose premise is wrong is worse than not
2799
- * asking, because the answer cannot mean what the reader thinks it means.
2800
- *
2801
- * The sync itself still happens — it writes to the local database, which is where that
2802
- * machine's conversations already live — so the value is true and it is stated rather than
2803
- * asked. Reported live: a Client Expert machine on Windows offering to send chat to a cloud
2804
- * it does not have.
2805
- */
2806
- export function workspaceSyncQuestion({ clientExpert }) {
2807
- return clientExpert
2808
- ? {
2809
- ask: false,
2810
- value: true,
2811
- note: "chat history is kept in the local database on this machine (Local mode)",
2812
- }
2813
- : { ask: true, value: null, note: "" };
2814
- }
2815
-
2816
2793
  /**
2817
2794
  * The ONE first-run question for a new folder (GitHub #192). It used to be two or three separate
2818
2795
  * Y/n prompts (register, trust to run commands, then the browser on first use). They are one
@@ -2928,8 +2905,9 @@ export function identityRow(
2928
2905
  // The mark only means anything beside a named coder: with none shown there is nothing for it
2929
2906
  // to be about, and it would read as a fault in the folder.
2930
2907
  const mark = name && keyUnchosen ? red(" key not chosen") : "";
2931
- // API key tail: show the last 4 characters of the API key when available (e.g., "| abcd")
2932
- const keyIndicator = apiKeyTail ? dim(" | ") + dim(apiKeyTail) : "";
2908
+ // API key tail, LABELLED (GH #397): a bare "| VW3Y" between the model and the folder read as a
2909
+ // stray code. Same "····" form every key line in gu uses.
2910
+ const keyIndicator = apiKeyTail ? dim(" | ") + dim(`key ····${apiKeyTail}`) : "";
2933
2911
  return (name ? orange(name) + mark + keyIndicator + dim(" | ") : "") + cyan(where);
2934
2912
  }
2935
2913
 
@@ -2938,7 +2916,7 @@ export function identityRow(
2938
2916
  *
2939
2917
  * ON CLIENT EXPERT, NOTHING. That mode's whole promise is that the machine keeps everything —
2940
2918
  * API, database, models — and shares none of it, so "cloud sync on" is not merely noise, it
2941
- * contradicts the mode the user chose (reported 2026-09-04). workspaceSyncQuestion already
2919
+ * contradicts the mode the user chose (reported 2026-09-04). The workspace record already
2942
2920
  * knows this and stores `true` meaning "persist it", but the LABEL read that value as "to the
2943
2921
  * cloud", which is the one place it does not go.
2944
2922
  *