@tiens.nguyen/gu-cli 1.0.897 → 1.0.898

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,7 +1051,7 @@ 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
1056
  { name: "/server", desc: "pick a deployment server (host/user) for this session" },
1058
1057
  { name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
@@ -2720,6 +2719,11 @@ let cliNoticeShown = false;
2720
2719
  function announceCliUpdate() {
2721
2720
  if (cliNoticeShown || !availableUpdates.cli) return;
2722
2721
  if (following) return; // a turn owns the transcript — try again at the next prompt
2722
+ // …and so does an open menu or question (GH #397): printed into a picker's in-place block, the
2723
+ // notice pushed it down and the picker could no longer erase itself — the rows of a menu left
2724
+ // closed stayed on screen. Retried at the next prompt, like a turn.
2725
+ // A slash command still running hides the input row (inputVisible): its output is not done.
2726
+ if (listPickerActive || askWaiting || !inputVisible) return;
2723
2727
  let lines = [];
2724
2728
  try {
2725
2729
  lines = repeatUpdateNotice({ last: readUpdateAttempt(), cli: availableUpdates.cli, running: REPL_VERSION,
@@ -5244,7 +5248,7 @@ const ask = async (q, { prefill = "", hint = [], atomic = false, noCancel = fals
5244
5248
  // Masked prompt for a secret (an API key). Mutes readline's echo/refresh (secretInput)
5245
5249
  // so the typed value never hits the terminal or scrollback (task #127). Returns "" on a
5246
5250
  // bare Enter (skip).
5247
- const askSecret = async (q) => {
5251
+ const askSecret = async (q, { hint = [] } = {}) => {
5248
5252
  // Same rule as ask(): input has ended, so stop. Returning "" here would hand an EMPTY
5249
5253
  // secret to a caller that is about to store or send it.
5250
5254
  if (_stdinClosed) return await ask("");
@@ -5255,6 +5259,9 @@ const askSecret = async (q) => {
5255
5259
  // FOR. With the bar pinned the row itself becomes the prompt, so nothing is left to infer.
5256
5260
  secretPrompt = String(q ?? "").replace(/\s+$/, "") + " ";
5257
5261
  secretInput = true;
5262
+ // A hint UNDER the input, gone once answered (GH #397) — printed above, "type or paste the
5263
+ // key…" stayed in the history after the key was in, as if it were still being asked.
5264
+ askHint = barPinned() ? hint : [];
5258
5265
  // Same reason as ask() above: inputLineFor returns "" when inputVisible is false, so the one
5259
5266
  // bullet per character — the only proof a keystroke registered while the echo is muted —
5260
5267
  // would not be drawn either. askSecret is reached from /model, which is exactly a command
@@ -5264,7 +5271,10 @@ const askSecret = async (q) => {
5264
5271
  // Unpinned (piped output, or a window too short to reserve rows) there IS no frozen row, so
5265
5272
  // the label has to be printed the old way or the prompt is silent.
5266
5273
  if (barPinned()) paintBar();
5267
- else process.stdout.write(q);
5274
+ else {
5275
+ for (const l of hint) console.log(GUTTER + dim(l));
5276
+ process.stdout.write(q);
5277
+ }
5268
5278
  let raw = "";
5269
5279
  try {
5270
5280
  askWaiting = true;
@@ -5277,6 +5287,7 @@ const askSecret = async (q) => {
5277
5287
  askWaiting = false;
5278
5288
  secretInput = false;
5279
5289
  secretPrompt = "";
5290
+ askHint = [];
5280
5291
  inputVisible = _secretWasVisible;
5281
5292
  // READLINE KEEPS EVERY SUBMITTED LINE (task #191). Muting the echo stops the key reaching
5282
5293
  // the screen; it does nothing about rl.history, which is where the line goes on Enter — so
@@ -5343,10 +5354,10 @@ async function ensureWorkspace() {
5343
5354
  // Registered before cloud sync existed → ask once and persist. Otherwise honor the
5344
5355
  // stored choice. (undefined = old record that never opted in → ask.)
5345
5356
  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;
5357
+ // No question (owner, 11-07): the mode chosen at setup decides where history goes —
5358
+ // the cloud on a hosted account, the local database on Client Expert. A folder that
5359
+ // answered No before keeps that answer; only a folder never asked is filled in.
5360
+ hit.allowSync = true;
5350
5361
  await writeFile(WS_FILE, JSON.stringify({ workspaces: list }, null, 2) + "\n");
5351
5362
  }
5352
5363
  // The workspace facts now live in the startup banner (task #131) — just remember them.
@@ -5380,16 +5391,10 @@ async function ensureWorkspace() {
5380
5391
  } catch (e) {
5381
5392
  console.log(GUTTER + yellow(`Could not save the browser permission (${e?.message || e}) — gu will ask when it first needs it.`));
5382
5393
  }
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));
5394
+ // NO SYNC QUESTION (owner, 11-07): Cloud or Client Expert is chosen once, at setup, and it
5395
+ // decides where chat history lives — your account's cloud database, or the local one. To
5396
+ // change it, `gu clear` and set up again. RAG is never synced (it stays where it was built).
5397
+ const allowSync = true;
5393
5398
  const next = list.filter((w) => w.path !== cwd);
5394
5399
  next.push({
5395
5400
  name: basename(cwd),
@@ -6297,10 +6302,12 @@ async function pickExtraModels(rest0, preChecked = []) {
6297
6302
  const already = new Set(preChecked);
6298
6303
  const { codingModelCandidates } = await import("./coding-provider-setup.mjs");
6299
6304
  const keep = new Set(codingModelCandidates(rest0).models);
6300
- const rest = rest0.filter((m) => keep.has(m) || already.has(m));
6305
+ const rest = rest0
6306
+ .filter((m) => keep.has(m) || already.has(m))
6307
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true })); // as the pick above (GH #397)
6301
6308
  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}`));
6309
+ console.log(dim(` Any other models to switch to in /model? optional · up to ${MAX_ALT_CODERS}`));
6310
+ console.log(dim(` space ticks · Enter saves (nothing ticked skips this) · Esc cancels`));
6304
6311
  const ticks = await pickManyFromList(
6305
6312
  rest,
6306
6313
  rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
@@ -6328,7 +6335,12 @@ async function pickExtraModels(rest0, preChecked = []) {
6328
6335
  * Returns the model id, or null when the user cancelled.
6329
6336
  */
6330
6337
  async function chooseCodingModelFrom(ps, all, question, current, { back = false } = {}) {
6331
- const { models: coders, filtered } = ps.codingModelCandidates(all);
6338
+ const { models: coders0, filtered } = ps.codingModelCandidates(all);
6339
+ // SORTED, like /agent-model's list (GH #397): the provider's own order is arbitrary, and a
6340
+ // family (qwen3.8-*, deepseek-v4-*) scattered over the list is hard to scan.
6341
+ const byName = (a, b) => a.localeCompare(b, "en", { numeric: true });
6342
+ const coders = [...coders0].sort(byName);
6343
+ all = [...all].sort(byName);
6332
6344
  let list = coders;
6333
6345
  let showingAll = filtered === 0;
6334
6346
  for (;;) {
@@ -6372,8 +6384,9 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
6372
6384
  * stays available for the same reason it does there — a listed model may be unusable and an
6373
6385
  * unlisted one may be exactly right.
6374
6386
  */
6375
- async function chooseFromModelList(all, question, current = 0, { back = false } = {}) {
6376
- const rows = [...all, "type a model name…"];
6387
+ async function chooseFromModelList(all, question, current = 0, { back = false, notes = null } = {}) {
6388
+ // `notes` (GH #397): a dim note per row ("an audio/speech model"), as /agent-model's own list has.
6389
+ const rows = [...all.map((m, i) => (notes?.[i] ? `${m}${dim(` · ${notes[i]}`)}` : m)), "type a model name…"];
6377
6390
  const at = typeof current === "string" ? all.indexOf(current) : Number(current) || 0;
6378
6391
  const idx = await pickFromList(rows, at >= 0 ? at : 0, {
6379
6392
  back,
@@ -6390,6 +6403,21 @@ async function chooseFromModelList(all, question, current = 0, { back = false }
6390
6403
  return all[idx];
6391
6404
  }
6392
6405
 
6406
+ /**
6407
+ * The agent's pick from a fresh provider (GH #397): the SAME order and labels as /agent-model's
6408
+ * "pick another model on <host>…" — chat models alphabetically, then audio/image/video models
6409
+ * sunk to the bottom and named. The add flow showed them mixed in and unlabelled.
6410
+ */
6411
+ async function pickAgentModelFrom(models, question, current, opts) {
6412
+ const am = await import("./agent-model-command.mjs");
6413
+ const sorted = am.agentModelsFromProvider(models);
6414
+ const ids = sorted.map((m) => m.model);
6415
+ return chooseFromModelList(ids, question, Math.max(0, ids.indexOf(current)), {
6416
+ ...opts,
6417
+ notes: sorted.map((m) => m.note),
6418
+ });
6419
+ }
6420
+
6393
6421
  /**
6394
6422
  * /model's provider manager. `back` (#242): opened from /model's list, so its first menu offers
6395
6423
  * "← back" and returns BACK. Inside it, each sub-menu's "← back" returns HERE — re-read, because a
@@ -6750,6 +6778,7 @@ async function removeCodingProvider(ps, rows, p) {
6750
6778
  * newKey ask for a key even if the server has one (the stored one was refused)
6751
6779
  */
6752
6780
  async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false, back = false } = {}) {
6781
+ let claudeSdkLater = false; // offered after the save, see step 1
6753
6782
  const as = await import("./add-server.mjs");
6754
6783
  const ps = await import("./coding-provider-setup.mjs");
6755
6784
  const sk = await import("./server-keys.mjs");
@@ -6802,7 +6831,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6802
6831
  const OLLAMA_HERE = 0;
6803
6832
  const rowsShown = [
6804
6833
  `Ollama — this machine or your network…${dim(" · e.g. 127.0.0.1:11434")}`,
6805
- ...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
6834
+ // The HOST, not the full URL (GH #397): "…/compatible-mode/v1" ran past 100 columns and was
6835
+ // cut mid-word; the path is plumbing, and the URL is echoed in full once one is picked.
6836
+ ...presets.map((p) => `${p.name}${dim(` · ${hostOf(p.url)}`)}`),
6806
6837
  "another URL…",
6807
6838
  ];
6808
6839
  // A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
@@ -6857,10 +6888,10 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6857
6888
  console.log("");
6858
6889
  return;
6859
6890
  }
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
- }
6891
+ // Claude as the CODER: prompt caching needs Anthropic's SDK (#262). Offered AFTER the key and the
6892
+ // model are saved (GH #397): asked first, it interrupted the flow before the user had even been
6893
+ // asked for a key, and dumped pip's raw output over the screen.
6894
+ claudeSdkLater = Boolean(target.claude && role === "coding");
6864
6895
 
6865
6896
  // --- 2. the server's key --------------------------------------------------------------
6866
6897
  // AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
@@ -6968,7 +6999,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6968
6999
  const canBack = { back: providerPicker || back };
6969
7000
  model = R.filterCoders
6970
7001
  ? await chooseCodingModelFrom(ps, models, R.question, 0, canBack)
6971
- : await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)), canBack);
7002
+ : role === "agent"
7003
+ ? await pickAgentModelFrom(models, R.question, current, canBack)
7004
+ : await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)), canBack);
6972
7005
  if (model === BACK) {
6973
7006
  if (providerPicker) continue; // → Which provider?
6974
7007
  return BACK;
@@ -7083,7 +7116,16 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
7083
7116
  }
7084
7117
  return;
7085
7118
  }
7086
- await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName });
7119
+ await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName, extras });
7120
+ if (claudeSdkLater) {
7121
+ // pip's own lines ("Requirement already satisfied…", "Collecting…") are noise here: keep gu's.
7122
+ 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;
7123
+ await md.ensureClaudeSdk({
7124
+ log: (l) => { const t = String(l ?? "").trim(); if (t && !pipNoise.test(t)) console.log(GUTTER + dim(t)); },
7125
+ confirm: (q) => askYesNo(` ${q}`, true),
7126
+ });
7127
+ console.log("");
7128
+ }
7087
7129
  }
7088
7130
 
7089
7131
  /**
@@ -7096,7 +7138,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
7096
7138
  * · DID SAVING EVICT ANOTHER PROVIDER? An API older than row ids keys a backend by its KIND,
7097
7139
  * so a second openai row replaced the first with a 200. Said BEFORE the success line.
7098
7140
  */
7099
- async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName }) {
7141
+ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName, extras = [] }) {
7100
7142
  const cwd = resolve(process.cwd());
7101
7143
  const after = await withPendingStatus("checking it is offered", () =>
7102
7144
  fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null)
@@ -7145,15 +7187,23 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
7145
7187
  // that was just entered, which reads as the save having failed.
7146
7188
  codingKeyMissingKnown = false;
7147
7189
  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`));
7190
+ if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)}`));
7191
+ // The extras were ticked a screen ago and never mentioned again (GH #397) — say where they went.
7192
+ if (extras.length) console.log(dim(` also in /model: ${extras.join(", ")}`));
7193
+ console.log("");
7149
7194
  // A HOSTED SERVER NEEDS ITS WINDOW SET: with nothing set gu assumes 24,000 prompt tokens and
7150
7195
  // compacts there — on a large hosted model that throws history away every turn.
7151
7196
  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"));
7197
+ console.log(GUTTER + dim("tip: set this model's context size with /context-window —"));
7198
+ console.log(GUTTER + dim(" hosted providers don't report it, so gu assumes 24k tokens.\n"));
7154
7199
  }
7155
7200
  const t = await fetchOutputTokenTotals(cwd);
7156
7201
  { const g = pickGlobalTotal(t); if (g !== null) globalOutputTokens = g; }
7202
+ // The bar still said "coding model not set · /model" under the success line of the FIRST model
7203
+ // added on a fresh install, until the next turn repainted it (GH #397).
7204
+ noModelAtAll = after?.payload ? nothingToAnswerWith(after.payload) : false;
7205
+ if (after?.payload) agentMissing = after.payload.agentMissing === true;
7206
+ if (barPinned()) paintBar();
7157
7207
  }
7158
7208
 
7159
7209
 
@@ -7258,7 +7308,7 @@ async function chooseModel() {
7258
7308
  // through to add when there is genuinely nothing there.
7259
7309
  // As the picker's header (GH #397), so "← back" re-opening this menu does not stack it.
7260
7310
  const go = await pickFromList(
7261
- [dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7311
+ [dim("leave it"), "add a provider — Claude API, OpenAI, Kimi, Ollama…"],
7262
7312
  1,
7263
7313
  { header: () => [dim(" No coding model configured.")], headerRows: 1 }
7264
7314
  );
@@ -7271,16 +7321,10 @@ async function chooseModel() {
7271
7321
  // provider manager is where another one is added (an Ollama box is "another URL…").
7272
7322
  // #412 removed "change the Ollama server this machine uses…" from all three menus: it re-asked
7273
7323
  // 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
- }
7324
+ // GH #397: ONE model used to get its own screen ("Only one coding model is available: X ·
7325
+ // keep it / add or change a provider"). Set up from /agent-model first, that was the agent's
7326
+ // model — and the other 12 coders on the same provider were three menus away. The full menu
7327
+ // below already has "pick another model on <host>…", keys and add, so one model uses it too.
7284
7328
  const active = sessionCodingModel || def;
7285
7329
  // Arrow-key picker (↑/↓ + Enter) — pre-highlight the active model, tag the default, and
7286
7330
  // name the backend when more than one is in play (the same model name can appear twice).
@@ -7289,6 +7333,9 @@ async function chooseModel() {
7289
7333
  // OpenAI-compatible providers are both "OpenAI-compatible", so the kind names neither.
7290
7334
  const { labels, tags, showTag } = codingChoiceLabels(choices, def);
7291
7335
  const activeIdx = choices.findIndex((c) => c.id === active);
7336
+ // GH #397: "(default)" marked the account default while this folder used another model, and
7337
+ // nothing said which one runs here. The one in use is marked.
7338
+ if (activeIdx >= 0 && choices[activeIdx].id !== def) labels[activeIdx] += accent(" (in use here)");
7292
7339
  // THE CURRENT CODER'S KEYS, ON THIS LIST (#248) — the manager ctrl+k opens, for a provider that
7293
7340
  // takes a key (OpenAI-compatible, ollama.com). Before the add row, so no model row moves.
7294
7341
  const activeChoice = choices[activeIdx >= 0 ? activeIdx : choices.findIndex((c) => c.id === def)];
@@ -9357,8 +9404,9 @@ async function askServerKey({ host = "", optional = false, escBack = false, name
9357
9404
  // A RETRY KEEPS THE NAME (GH #397): only the secret was wrong, so only the secret is asked.
9358
9405
  if (knownName === null) console.log("\n" + GUTTER + dim(escBack ? "Esc goes back to the providers." : "Esc to cancel."));
9359
9406
  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();
9407
+ const secret = (
9408
+ await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `, { hint: [SECRET_KEY_HINT] })
9409
+ ).trim();
9362
9410
  if (!secret) {
9363
9411
  console.log(GUTTER + dim(optional ? "(no key — the server will be called anonymously)" : "(cancelled — nothing was saved)\n"));
9364
9412
  return { name, secret: "" };
@@ -9446,19 +9494,38 @@ async function chooseAgentModel() {
9446
9494
  const source = am.agentListSource(settings);
9447
9495
  const needsKey = am.agentSourceNeedsKey(source);
9448
9496
  for (;;) {
9449
- const labels = saved.map((c) => c.label + (c.current ? accent(" (current)") : ""));
9497
+ // The first row is the default and the way back to ONE model: the coding model talks too (GH #397).
9498
+ const labels = [
9499
+ `same as the coding model${dim(" — follows /model")}` + (current ? "" : accent(" (current)")),
9500
+ ...saved.map((c) => c.label + (c.current ? accent(" (current)") : "")),
9501
+ ];
9450
9502
  const MODELS = curHost ? labels.length : -1;
9451
9503
  if (curHost) labels.push(actionRow(`pick another model on ${curHost}…`));
9504
+ // NO SEPARATE AGENT MODEL YET: offer the coder's own server, so a talking model from the provider
9505
+ // already set up is one pick away — its saved key is reused (GH #397).
9506
+ const coderUrl = (await import("./coding-provider-setup.mjs")).ensureV1Root(String(sessionCodingUrl || defaultCodingUrl || "")).toLowerCase();
9507
+ const coderRow = curHost ? null : (saved.find((c) => c.url.toLowerCase() === coderUrl) ?? null);
9508
+ const CODER_MODELS = coderRow ? labels.length : -1;
9509
+ if (coderRow) labels.push(actionRow(`pick another model on ${coderRow.host}…`));
9452
9510
  const KEYS = curHost && needsKey ? labels.length : -1;
9453
9511
  if (KEYS >= 0) labels.push(actionRow(`API keys for ${curHost} — switch, add or remove…`));
9454
9512
  const ADD = labels.length;
9455
9513
  labels.push(actionRow("add a provider — Claude API, OpenAI, Kimi, Ollama…"));
9456
9514
  // 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}` : ""})`;
9515
+ // GH #397: "(now unset)" hid that the coding model answers in the meantime; and printed
9516
+ // before the list, this title stacked each time "← back" re-opened the menu.
9517
+ const now = current
9518
+ ? `now ${current}${curHost ? ` on ${curHost}` : ""}`
9519
+ : "same as the coding model";
9520
+ const head = ` Agent model — the one that TALKS (${now})`;
9458
9521
  const hint = `(${PICK_ARROWS}):`;
9459
9522
  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 });
9523
+ const headLines = head.length + 2 + hint.length < cols ? [`${head} ${hint}`] : [head, ` ${hint}`];
9524
+ const idx = await pickFromList(labels, saved.findIndex((c) => c.current) + 1, {
9525
+ footer: ESC_FOOTER,
9526
+ header: () => headLines.map((l) => dim(l)),
9527
+ headerRows: headLines.length,
9528
+ });
9462
9529
  if (idx < 0) {
9463
9530
  console.log(dim(" (cancelled)\n"));
9464
9531
  return;
@@ -9468,6 +9535,11 @@ async function chooseAgentModel() {
9468
9535
  if ((await agentModelOnServer({ back: true, start: "models" })) === BACK) continue;
9469
9536
  return;
9470
9537
  }
9538
+ if (idx === CODER_MODELS) {
9539
+ const dialect = coderRow.kind === "ollama" ? "ollama" : "openai";
9540
+ if ((await addServer({ role: "agent", dialect, url: coderRow.url, back: true })) === BACK) continue;
9541
+ return;
9542
+ }
9471
9543
  if (idx === KEYS) {
9472
9544
  const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
9473
9545
  if (!rowId) return enterAgentProviderKey(source.row, curHost);
@@ -9478,7 +9550,12 @@ async function chooseAgentModel() {
9478
9550
  if ((await addServer({ role: "agent", dialect: "openai", back: true })) === BACK) continue;
9479
9551
  return;
9480
9552
  }
9481
- await useSavedAgentChoice(saved[idx]);
9553
+ if (idx === 0) {
9554
+ if (!current) console.log(dim(" already the same as the coding model — nothing changed.\n"));
9555
+ else await useCoderForAgent();
9556
+ return;
9557
+ }
9558
+ await useSavedAgentChoice(saved[idx - 1]);
9482
9559
  return;
9483
9560
  }
9484
9561
  }
@@ -9488,6 +9565,31 @@ async function chooseAgentModel() {
9488
9565
  * API finds the key by the URL (resolveKeyForRole), so nothing is re-entered — unless the server
9489
9566
  * needs a key and has none, which is asked here rather than failing on the next question.
9490
9567
  */
9568
+ /**
9569
+ * "Same as the coding model" (GH #397): clear the separate agent model, so the coding model talks
9570
+ * too — and keeps following each folder's /model pick. The way BACK to one model: before this the
9571
+ * only route was removing the whole provider (and its key).
9572
+ */
9573
+ async function useCoderForAgent() {
9574
+ const md = await import("./model-doctor.mjs");
9575
+ const saved = await withPendingStatus("saving", () =>
9576
+ md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: "", agentModelName: "", agentModel: "" })
9577
+ );
9578
+ if (!saved) {
9579
+ console.log(red(" couldn't save that — nothing changed.\n"));
9580
+ return;
9581
+ }
9582
+ if (sessionAgentKeyId) await saveFolderKeyPick("agent", "");
9583
+ console.log(accent(" ✓ agent model → same as the coding model") + dim(" · /model switches both"));
9584
+ console.log(GUTTER + dim("takes effect on your next question.\n"));
9585
+ const after = (await fetchAgentPayload([{ role: "user", content: "." }]).catch(() => null))?.payload;
9586
+ if (after) {
9587
+ agentMissing = after.agentMissing === true;
9588
+ noModelAtAll = nothingToAnswerWith(after);
9589
+ }
9590
+ if (barPinned()) paintBar();
9591
+ }
9592
+
9491
9593
  async function useSavedAgentChoice(c) {
9492
9594
  if (c.current) {
9493
9595
  console.log(dim(` already using ${c.model} on ${c.host} — nothing changed.\n`));
@@ -10921,8 +11023,7 @@ async function replaceCodingKey({ back = false } = {}) {
10921
11023
  (ks.stored ? dim("(replacing ") + accent(maskKey(ks.stored)) + dim(")") : dim("(none stored)")) +
10922
11024
  dim(" · Enter alone cancels, \"clear\" removes it\n")
10923
11025
  );
10924
- console.log(GUTTER + dim(SECRET_KEY_HINT));
10925
- const typed = (await askSecret(` ${ks.model} API key: `)).trim();
11026
+ const typed = (await askSecret(` ${ks.model} API key: `, { hint: [SECRET_KEY_HINT] })).trim();
10926
11027
  if (!typed) {
10927
11028
  console.log(GUTTER + dim("(cancelled — the stored key is unchanged)\n"));
10928
11029
  paintBar();
@@ -10976,7 +11077,9 @@ async function manageServerKeys({ role = "coding", backendId, host, model = "",
10976
11077
  });
10977
11078
  const ADD = rows.length;
10978
11079
  rows.push(dim("add a new key…"));
10979
- console.log("\n" + GUTTER + dim(`Keys for ${host} — ${folderLabel()}'s ${roleLabel}${model ? ` (${model})` : ""}\n`));
11080
+ // The agent model is a MACHINE setting (GH #397): "proj's agent model" named a folder it is not tied to.
11081
+ const whose = role === "agent" ? `the ${roleLabel}` : `${folderLabel()}'s ${roleLabel}`;
11082
+ console.log("\n" + GUTTER + dim(`Keys for ${host} — ${whose}${model ? ` (${model})` : ""}\n`));
10980
11083
  console.log(GUTTER + dim("Enter chooses · ctrl+d deletes the highlighted key\n"));
10981
11084
  const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true, back });
10982
11085
  const idx = picked?.index ?? -1;
@@ -11904,6 +12007,28 @@ function pickFromList(items, initialSel = 0, opts = {}) {
11904
12007
  listTimeoutIndex = -1;
11905
12008
  listHeader = typeof opts.header === "function" ? opts.header : null;
11906
12009
  listHeaderRows = listHeader ? Math.max(0, opts.headerRows | 0) : 0;
12010
+ // A STATIC HEADER WIDER THAN THE TERMINAL IS WRAPPED AT WORDS (GH #397), not clipped: on 80
12011
+ // columns "Which model on token-plan.maas.qwencloudapi.com? (↑/↓ then Enter · Esc to c" lost
12012
+ // its way out. Done once, here, so the block keeps the constant height its redraw walks
12013
+ // depend on. A live panel (refreshMs) is already sized by its caller and is left alone.
12014
+ if (listHeader && !(opts.refreshMs > 0)) {
12015
+ const w = Math.max(20, process.stdout.columns || 80) - 1;
12016
+ let given = [];
12017
+ try { given = (listHeader(listHeaderRows) || []).slice(0, listHeaderRows); } catch { /* blank */ }
12018
+ if (given.some((l) => visibleLen(l) > w)) {
12019
+ const wrapped = given.flatMap((l) => {
12020
+ if (visibleLen(l) <= w) return [l];
12021
+ const plain = stripAnsiEscapes(l);
12022
+ const lead = /^\s*/.exec(plain)[0];
12023
+ // The key hint " (↑/↓ then Enter · Esc …)" moves to its own line whole, before any word wrap.
12024
+ const at = plain.indexOf(" (", lead.length);
12025
+ const parts = at > 0 ? [plain.slice(lead.length, at), plain.slice(at + 2)] : [plain.slice(lead.length)];
12026
+ return parts.flatMap((p) => wrapIndented(p, { width: w, indent: lead, hang: "" })).map((r) => dim(r));
12027
+ });
12028
+ listHeader = () => wrapped;
12029
+ listHeaderRows = wrapped.length;
12030
+ }
12031
+ }
11907
12032
  if (listHeader && opts.refreshMs > 0) {
11908
12033
  // A live panel (#376): redrawn in place until the picker closes.
11909
12034
  const live = setInterval(() => {
@@ -12382,6 +12507,7 @@ async function localScaffoldState() {
12382
12507
  /** Print the startup scaffold line — deferred while a turn owns the transcript. */
12383
12508
  function announceScaffold() {
12384
12509
  if (!scaffoldNotice || following) return;
12510
+ if (listPickerActive || askWaiting || !inputVisible) return; // as announceCliUpdate (GH #397)
12385
12511
  const { kind, text } = scaffoldNotice;
12386
12512
  scaffoldNotice = null;
12387
12513
  if (kind === "link") {
@@ -14434,7 +14560,11 @@ async function main() {
14434
14560
  await import("./agent-model-label.mjs");
14435
14561
  // Skip the `ps` sweep entirely when settings already name the model — the overwhelmingly
14436
14562
  // common case, and startup is not the place to spend a subprocess proving it.
14437
- const agentLabel = namesAModel(p.agentModelId)
14563
+ // NO AGENT MODEL SET: the payload's agentModelId is only the account-default coder, which may
14564
+ // not be this folder's pick — naming it here said "kimi" over a folder running qwen (GH #397).
14565
+ const agentLabel = p.agentMissing === true
14566
+ ? { name: "", known: false, reason: "not-set" }
14567
+ : namesAModel(p.agentModelId)
14438
14568
  ? { name: String(p.agentModelId).trim(), known: true, reason: "settings" }
14439
14569
  : agentModelLabel({
14440
14570
  configuredId: p.agentModelId,
@@ -14695,7 +14825,7 @@ async function main() {
14695
14825
  dim(" Skipped — this machine won't SERVE models; the agent still runs here and\n") +
14696
14826
  dim(" calls your model machine over the network. Point the Agent model URL at\n") +
14697
14827
  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")
14828
+ dim(" `gu doctor` sets models up here · `gu clear` starts setup over.\n")
14699
14829
  );
14700
14830
  void st;
14701
14831
  }
@@ -15437,18 +15567,6 @@ async function main() {
15437
15567
  );
15438
15568
  continue;
15439
15569
  }
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
15570
  if (line === "/rag" || line === "/rag-cloud" || line === "/rag-local") {
15453
15571
  // ONE QUESTION, THREE ANSWERS (task #180). This was two commands that toggled one flag,
15454
15572
  // 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.898",
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
  *